Keep reasoning-only Responses output hidden

This commit is contained in:
wasimysaid 2026-06-19 16:51:40 +02:00
commit 4c877cc7d8
4 changed files with 55 additions and 164 deletions

View file

@ -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,
*,

View file

@ -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

View file

@ -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"]:

View file

@ -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 = [