Keep reasoning-only Responses output hidden
This commit is contained in:
parent
6705053e9e
commit
4c877cc7d8
4 changed files with 55 additions and 164 deletions
|
|
@ -10,20 +10,19 @@ or ``<function=name><parameter=k>v...`` shape.
|
|||
import json
|
||||
import re
|
||||
|
||||
|
||||
# _TOOL_CLOSED_PATS: closed pairs only. _TOOL_ALL_PATS: also trailing unclosed
|
||||
# runs so truncated tails don't leak markup. The [\w-] name set matches OpenAI's
|
||||
# so hyphenated MCP tool names (mcp__srv__list-issues) parse like built-ins.
|
||||
_TOOL_CLOSED_PATS = [
|
||||
re.compile(r"<tool_call>.*?</tool_call>", re.DOTALL),
|
||||
re.compile(r"<\|tool_call>.*?<tool_call\|>", re.DOTALL),
|
||||
re.compile(r"<function=[\w-]+>.*?</function>", re.DOTALL),
|
||||
]
|
||||
_TOOL_ALL_PATS = _TOOL_CLOSED_PATS + [
|
||||
re.compile(r"<tool_call>.*$", re.DOTALL),
|
||||
re.compile(r"<\|tool_call>.*$", re.DOTALL),
|
||||
re.compile(r"<function=[\w-]+>.*$", re.DOTALL),
|
||||
]
|
||||
from core.tool_healing import (
|
||||
_TC_END_TAG_RE,
|
||||
_TC_FUNC_CLOSE_RE,
|
||||
_TC_FUNC_START_RE,
|
||||
_TC_GEMMA_START_RE,
|
||||
_TC_JSON_START_RE,
|
||||
_TC_PARAM_CLOSE_RE,
|
||||
_TC_PARAM_START_RE,
|
||||
_TOOL_ALL_PATS,
|
||||
_TOOL_CLOSED_PATS,
|
||||
_balanced_brace_end,
|
||||
_gemma_arguments_to_json,
|
||||
)
|
||||
|
||||
|
||||
# Prefixes the streaming buffer watches for to gate in-progress text.
|
||||
|
|
@ -76,19 +75,9 @@ RAG_SEARCH_CAP_NUDGE = (
|
|||
)
|
||||
|
||||
|
||||
# Pre-compiled patterns reused by ``parse_tool_calls_from_text``.
|
||||
_TC_JSON_START_RE = re.compile(r"<tool_call>\s*\{")
|
||||
_TC_GEMMA_START_RE = re.compile(r"<\|tool_call>call:([\w-]+)\s*\{")
|
||||
_TC_FUNC_START_RE = re.compile(r"<function=([\w-]+)>\s*")
|
||||
_TC_END_TAG_RE = re.compile(r"</tool_call>")
|
||||
_TC_GEMMA_END_TAG_RE = re.compile(r"<tool_call\|>")
|
||||
_TC_FUNC_CLOSE_RE = re.compile(r"\s*</function>\s*$")
|
||||
# [\w-] so hyphenated MCP param names (issue-number) aren't dropped.
|
||||
_TC_PARAM_START_RE = re.compile(r"<parameter=([\w-]+)>\s*")
|
||||
_TC_PARAM_CLOSE_RE = re.compile(r"\s*</parameter>\s*$")
|
||||
_PARAM_CLOSE_TAG = "</parameter>"
|
||||
_FUNC_CLOSE_TAG = "</function>"
|
||||
_GEMMA_QUOTE = '<|"|>'
|
||||
|
||||
|
||||
def _inside_open_parameter(content: str, pos: int) -> bool:
|
||||
|
|
@ -116,116 +105,6 @@ def strip_tool_markup(text: str, *, final: bool = False) -> str:
|
|||
return text.strip() if final else text
|
||||
|
||||
|
||||
def _balanced_brace_end(content: str, brace_start: int) -> int:
|
||||
depth = 0
|
||||
i = brace_start
|
||||
in_string = False
|
||||
in_gemma_string = False
|
||||
while i < len(content):
|
||||
if content.startswith(_GEMMA_QUOTE, i):
|
||||
in_gemma_string = not in_gemma_string
|
||||
i += len(_GEMMA_QUOTE)
|
||||
continue
|
||||
ch = content[i]
|
||||
if in_gemma_string:
|
||||
i += 1
|
||||
continue
|
||||
if in_string:
|
||||
if ch == "\\" and i + 1 < len(content):
|
||||
i += 2
|
||||
continue
|
||||
if ch == '"':
|
||||
in_string = False
|
||||
elif ch == '"':
|
||||
in_string = True
|
||||
elif ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return i
|
||||
i += 1
|
||||
return -1
|
||||
|
||||
|
||||
def _normalise_gemma_quoted_strings(src: str) -> str:
|
||||
parts: list[str] = []
|
||||
i = 0
|
||||
while i < len(src):
|
||||
if not src.startswith(_GEMMA_QUOTE, i):
|
||||
parts.append(src[i])
|
||||
i += 1
|
||||
continue
|
||||
end = src.find(_GEMMA_QUOTE, i + len(_GEMMA_QUOTE))
|
||||
if end < 0:
|
||||
parts.append(src[i:])
|
||||
break
|
||||
raw_value = src[i + len(_GEMMA_QUOTE) : end]
|
||||
parts.append(json.dumps(raw_value))
|
||||
i = end + len(_GEMMA_QUOTE)
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _quote_gemma_object_keys(src: str) -> str:
|
||||
parts: list[str] = []
|
||||
i = 0
|
||||
in_string = False
|
||||
while i < len(src):
|
||||
ch = src[i]
|
||||
if in_string:
|
||||
parts.append(ch)
|
||||
if ch == "\\" and i + 1 < len(src):
|
||||
parts.append(src[i + 1])
|
||||
i += 2
|
||||
continue
|
||||
if ch == '"':
|
||||
in_string = False
|
||||
i += 1
|
||||
continue
|
||||
if ch == '"':
|
||||
in_string = True
|
||||
parts.append(ch)
|
||||
i += 1
|
||||
continue
|
||||
if ch not in "{,":
|
||||
parts.append(ch)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
parts.append(ch)
|
||||
i += 1
|
||||
key_start = i
|
||||
while i < len(src) and src[i].isspace():
|
||||
i += 1
|
||||
key_name_start = i
|
||||
while i < len(src) and (src[i].isalnum() or src[i] in "_-"):
|
||||
i += 1
|
||||
key_name = src[key_name_start:i]
|
||||
colon_pos = i
|
||||
while colon_pos < len(src) and src[colon_pos].isspace():
|
||||
colon_pos += 1
|
||||
if key_name and colon_pos < len(src) and src[colon_pos] == ":":
|
||||
parts.append(src[key_start:key_name_start])
|
||||
parts.append(json.dumps(key_name))
|
||||
parts.append(src[i:colon_pos])
|
||||
parts.append(":")
|
||||
i = colon_pos + 1
|
||||
else:
|
||||
parts.append(src[key_start:i])
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _gemma_arguments_to_json(args_src: str) -> dict:
|
||||
"""Parse Gemma 4's native call:name{key:value} argument object."""
|
||||
args_src = args_src.strip()
|
||||
if not args_src:
|
||||
return {}
|
||||
src = _normalise_gemma_quoted_strings(args_src)
|
||||
src = "{" + src + "}"
|
||||
src = _quote_gemma_object_keys(src)
|
||||
return json.loads(src)
|
||||
|
||||
|
||||
def parse_tool_calls_from_text(
|
||||
content: str,
|
||||
*,
|
||||
|
|
|
|||
|
|
@ -1,14 +1,10 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Tool-call XML parsing and stripping helpers.
|
||||
"""Lightweight tool-call XML parsing and stripping helpers.
|
||||
|
||||
Extracted verbatim from studio/backend/core/inference/llama_cpp.py so external
|
||||
inference servers can reuse the logic without importing the inference
|
||||
External inference servers import this module without pulling in the inference
|
||||
orchestrator, structlog, httpx, or the rest of the studio backend.
|
||||
|
||||
Regexes and bodies are byte-for-byte identical to the original; any change must
|
||||
preserve that. test_tool_healing_extraction_is_exact.py verifies via AST.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
|
|
|||
|
|
@ -7606,21 +7606,6 @@ async def _responses_stream(
|
|||
"delta": final_visible,
|
||||
},
|
||||
)
|
||||
if full_reasoning and not full_text and not tool_call_state:
|
||||
for event in _ensure_message_open():
|
||||
yield event
|
||||
full_text = full_reasoning
|
||||
api_monitor.set_reply(monitor_id, full_text)
|
||||
yield _sse(
|
||||
"response.output_text.delta",
|
||||
{
|
||||
"type": "response.output_text.delta",
|
||||
"item_id": message_state["item_id"],
|
||||
"output_index": message_state["output_index"],
|
||||
"content_index": 0,
|
||||
"delta": full_text,
|
||||
},
|
||||
)
|
||||
|
||||
close_items: list[tuple[int, str, dict[str, Any]]] = []
|
||||
if reasoning_state["opened"]:
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ from models.inference import (
|
|||
from routes.inference import (
|
||||
_build_chat_request,
|
||||
_chat_tool_calls_to_responses_output,
|
||||
_extract_responses_reasoning,
|
||||
_normalise_responses_input,
|
||||
_responses_tool_output_content,
|
||||
_responses_non_streaming,
|
||||
|
|
@ -782,6 +783,15 @@ class TestResponsesNonStreamingAdapter:
|
|||
assert "<think>" not in body["output"][1]["content"][0]["text"]
|
||||
assert "</think>" not in body["output"][1]["content"][0]["text"]
|
||||
|
||||
def test_unclosed_think_block_extracts_as_reasoning(self):
|
||||
reasoning, visible = _extract_responses_reasoning(
|
||||
"<think>partial plan",
|
||||
parse_think_markers = True,
|
||||
)
|
||||
|
||||
assert reasoning == "partial plan"
|
||||
assert visible == ""
|
||||
|
||||
def test_monitor_records_translated_visible_text(self, monkeypatch):
|
||||
import routes.inference as inf_mod
|
||||
|
||||
|
|
@ -1318,7 +1328,7 @@ class TestResponsesStreamAdapter:
|
|||
assert entry["status"] == "completed"
|
||||
assert entry["reply"] == "tail"
|
||||
|
||||
def test_reasoning_only_fallback_updates_monitor(self, monkeypatch):
|
||||
def test_reasoning_only_stream_does_not_update_visible_monitor_reply(self, monkeypatch):
|
||||
import routes.inference as inf_mod
|
||||
|
||||
class FakeExtractor:
|
||||
|
|
@ -1359,10 +1369,11 @@ class TestResponsesStreamAdapter:
|
|||
|
||||
lines = asyncio.run(run())
|
||||
|
||||
assert self._payloads(lines, "response.output_text.delta")[-1]["delta"] == "plan"
|
||||
assert self._payloads(lines, "response.output_text.delta") == []
|
||||
assert self._payloads(lines, "response.reasoning_text.delta")[-1]["delta"] == "plan"
|
||||
[entry] = monitor.snapshot()
|
||||
assert entry["status"] == "completed"
|
||||
assert entry["reply"] == "plan"
|
||||
assert entry["reply"] == ""
|
||||
|
||||
def test_reasoning_capable_gguf_stream_parses_think_tags_by_default(self, monkeypatch):
|
||||
chunks = [
|
||||
|
|
@ -1418,7 +1429,7 @@ class TestResponsesStreamAdapter:
|
|||
"show <think>x</think> tags"
|
||||
)
|
||||
|
||||
def test_reasoning_only_streams_as_visible_message_text(self, monkeypatch):
|
||||
def test_reasoning_only_stream_stays_out_of_visible_message_text(self, monkeypatch):
|
||||
chunks = [
|
||||
{"choices": [{"delta": {"content": "<think>plan</think>"}}]},
|
||||
{"choices": [], "usage": {"prompt_tokens": 2, "completion_tokens": 3}},
|
||||
|
|
@ -1436,14 +1447,34 @@ class TestResponsesStreamAdapter:
|
|||
reasoning_deltas = self._payloads(lines, "response.reasoning_text.delta")
|
||||
text_deltas = self._payloads(lines, "response.output_text.delta")
|
||||
assert "".join(event["delta"] for event in reasoning_deltas) == "plan"
|
||||
assert "".join(event["delta"] for event in text_deltas) == "plan"
|
||||
assert text_deltas == []
|
||||
completed = self._payloads(lines, "response.completed")[0]
|
||||
assert [item["type"] for item in completed["response"]["output"]] == [
|
||||
"reasoning",
|
||||
"message",
|
||||
]
|
||||
assert [item["type"] for item in completed["response"]["output"]] == ["reasoning"]
|
||||
assert completed["response"]["output"][0]["content"][0]["text"] == "plan"
|
||||
|
||||
def test_unclosed_think_stream_stays_out_of_visible_message_text(self, monkeypatch):
|
||||
chunks = [
|
||||
{"choices": [{"delta": {"content": "<thi"}}]},
|
||||
{"choices": [{"delta": {"content": "nk>plan"}}]},
|
||||
{"choices": [], "usage": {"prompt_tokens": 2, "completion_tokens": 3}},
|
||||
]
|
||||
self._install_stream_mock(monkeypatch, chunks)
|
||||
payload = ResponsesRequest(input = "hi", stream = True, reasoning = {"effort": "high"})
|
||||
messages = [ChatMessage(role = "user", content = "hi")]
|
||||
|
||||
async def run():
|
||||
response = await _responses_stream(payload, messages, self._Request())
|
||||
return await self._collect(response)
|
||||
|
||||
lines = asyncio.run(run())
|
||||
|
||||
reasoning_deltas = self._payloads(lines, "response.reasoning_text.delta")
|
||||
text_deltas = self._payloads(lines, "response.output_text.delta")
|
||||
assert "".join(event["delta"] for event in reasoning_deltas) == "plan"
|
||||
assert text_deltas == []
|
||||
completed = self._payloads(lines, "response.completed")[0]
|
||||
assert [item["type"] for item in completed["response"]["output"]] == ["reasoning"]
|
||||
assert completed["response"]["output"][0]["content"][0]["text"] == "plan"
|
||||
assert completed["response"]["output"][1]["content"][0]["text"] == "plan"
|
||||
|
||||
def test_structured_reasoning_content_streams_as_reasoning(self, monkeypatch):
|
||||
chunks = [
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue