Studio: stream reasoning tokens in the tool-loop generator (fixes DeepSeek thinking not streaming with a pill on) (#6947)

This commit is contained in:
oobabooga 2026-07-07 19:50:40 -03:00 committed by GitHub
commit a9db53e189
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 338 additions and 18 deletions

View file

@ -258,6 +258,10 @@ class AnthropicStreamEmitter:
self._open_tool_use_id: Optional[str] = None
self._open_tool_args_sent: bool = False
self._prev_text: str = ""
# Net <think> minus </think> in the text emitted to the client. Tracked
# from emitted deltas (not _prev_text, which a final bare shrink clobbers)
# so an unclosed reasoning-only block can be balanced before close.
self._open_think_tags: int = 0
self._usage: dict = {}
def start(
@ -317,6 +321,7 @@ class AnthropicStreamEmitter:
"""Close any open block and emit message_delta + message_stop."""
events = []
if self._text_block_open or self._open_tool_call_id is not None:
events.extend(self._close_open_think())
events.append(self._close_block())
self._open_tool_call_id = None
self._open_tool_use_id = None
@ -344,12 +349,33 @@ class AnthropicStreamEmitter:
)
return events
def _close_open_think(self) -> list[str]:
"""Emit a ``</think>`` delta when the streamed text left a ``<think>``
open. This emitter diffs cumulative snapshots and drops the generator's
final bare shrink, so a reasoning-only reply would otherwise end on an
unclosed tag. Mirrors the chat route's reasoning extractor, which closes
the block on finish; balances the block before it is closed."""
if not self._text_block_open or self._open_think_tags <= 0:
return []
self._open_think_tags = 0
return [
build_anthropic_sse_event(
"content_block_delta",
{
"type": "content_block_delta",
"index": self.block_index,
"delta": {"type": "text_delta", "text": "</think>"},
},
)
]
def _handle_content(self, event: dict) -> list[str]:
cumulative = event.get("text", "")
new_text = cumulative[len(self._prev_text) :]
self._prev_text = cumulative
if not new_text:
return []
self._open_think_tags += new_text.count("<think>") - new_text.count("</think>")
if not self._text_block_open:
events = self._open_text_block()
else:
@ -374,6 +400,7 @@ class AnthropicStreamEmitter:
events = []
if self._text_block_open:
events.extend(self._close_open_think())
events.append(self._close_block())
# Defensive: close a stale open tool_use block before starting another.
elif self._open_tool_call_id is not None:
@ -452,6 +479,7 @@ class AnthropicStreamEmitter:
events.extend(self._open_text_block())
# Reset text tracking for the next synthesis turn
self._prev_text = ""
self._open_think_tags = 0
return events
def _open_text_block(self) -> list[str]:

View file

@ -8603,13 +8603,31 @@ class LlamaCppBackend:
}
def _flush_reasoning_and_buffer():
"""Append buffered reasoning (as a <think> block) then the held
"""Close a live-streamed <think> block (or emit the buffered reasoning
as one block if it never streamed), then append the held
content_buffer to the cumulative display text."""
nonlocal cumulative_display
if reasoning_accum:
nonlocal cumulative_display, in_thinking
if in_thinking:
cumulative_display += "</think>"
in_thinking = False
elif reasoning_accum:
cumulative_display += "<think>" + reasoning_accum + "</think>"
cumulative_display += content_buffer
def _close_streamed_think() -> bool:
"""Close a live-streamed <think> before a tool call drains, so
consumers without a reasoning extractor (Anthropic) get a balanced
block. Returns True when the caller should yield the result."""
nonlocal cumulative_display, in_thinking, _last_emitted
if not in_thinking:
return False
cumulative_display += "</think>"
in_thinking = False
if len(cumulative_display) > len(_last_emitted) and not _suppress_visible_output:
_last_emitted = cumulative_display
return True
return False
def _looks_like_enabled_bare_json(text: str, enabled_tool_names: set) -> bool:
"""True when ``text`` opens with an ENABLED markerless bare-JSON call; an ordinary JSON answer returns False."""
probe = strip_llama3_leading_sentinels(text.lstrip())
@ -8797,6 +8815,10 @@ class LlamaCppBackend:
# the structured tool call.
has_structured_tc = True
detect_state = _S_DRAINING
# Close the reasoning prefix before the tool card
# (mirrors the is_match path).
if _close_streamed_think():
yield {"type": "content", "text": cumulative_display}
for tc_d in tc_deltas:
idx = tc_d.get("index", 0)
if idx not in tool_calls_acc:
@ -8882,17 +8904,17 @@ class LlamaCppBackend:
continue
# ── Reasoning tokens ──
# Yield only in STREAMING. In BUFFERING and
# DRAINING, accumulate silently so we don't
# corrupt the consumer's prev_text tracker
# (routes/inference.py never resets it
# between tool iterations).
# Stream live except while DRAINING: reasoning is
# orthogonal to tool detection (content_buffer
# only), and the route resets prev_text on
# tool_start, so the <think> block stays a
# monotonic prefix like the no-tool path.
reasoning = delta.get("reasoning_content", "")
if reasoning:
if _reasoning_started_at is None:
_reasoning_started_at = time.monotonic()
reasoning_accum += reasoning
if detect_state == _S_STREAMING:
if detect_state != _S_DRAINING:
if not in_thinking:
cumulative_display += "<think>"
in_thinking = True
@ -9020,9 +9042,15 @@ class LlamaCppBackend:
_hold_buffer = True
if _drain_silently:
# No visible prefix -- the buffered text IS
# the call; drain without yielding it.
# The buffered content IS the call; drain it
# without yielding. A live <think> prefix is
# separate from it -- close that.
detect_state = _S_DRAINING
if _close_streamed_think():
yield {
"type": "content",
"text": cumulative_display,
}
elif is_match:
# Tool signal -- flush any visible
# prefix before DRAINING so the
@ -9115,7 +9143,9 @@ class LlamaCppBackend:
),
}
elif reasoning_accum and not has_content_tokens:
# Reasoning-only reply: show it as plain text.
# Reasoning-only reply: show it as the main response,
# not a thinking block (mirrors the no-tool path; the
# route's extractor closes the streamed <think>).
if _reasoning_started_at is not None and not _reasoning_summary_emitted:
_reasoning_summary_emitted = True
yield _reasoning_summary_event(_reasoning_started_at)

View file

@ -52,6 +52,49 @@ from io import BytesIO as _BytesIO
from types import SimpleNamespace
def _emitter_client_text(events: list[str]) -> str:
"""Concatenate the text_delta payloads an SSE event list carries."""
text = ""
for line in events:
for raw in line.split("\n"):
raw = raw.strip()
if not raw.startswith("data: "):
continue
data = json.loads(raw[len("data: ") :])
delta = data.get("delta", {})
if delta.get("type") == "text_delta":
text += delta.get("text", "")
return text
def test_anthropic_emitter_closes_reasoning_only_think_block():
# A reasoning-only reply streams <think>X live then shrinks to bare X at EOF.
# This emitter diffs cumulative snapshots and drops the shrink, so without a
# closing pass the client text would end on an unclosed <think>. finish()
# must balance it.
emitter = AnthropicStreamEmitter()
events = emitter.start("msg_1", "m")
events += emitter.feed({"type": "content", "text": "<think>The capital"})
events += emitter.feed({"type": "content", "text": "<think>The capital of France is Paris."})
# The generator's final bare-text shrink (dropped by the cumulative diff).
events += emitter.feed({"type": "content", "text": "The capital of France is Paris."})
events += emitter.finish()
assert _emitter_client_text(events) == "<think>The capital of France is Paris.</think>"
def test_anthropic_emitter_does_not_double_close_balanced_think():
# A reasoning-then-answer reply already closes its own </think>; the balancer
# must not append a second one.
emitter = AnthropicStreamEmitter()
events = emitter.start("msg_1", "m")
events += emitter.feed({"type": "content", "text": "<think>Thinking."})
events += emitter.feed({"type": "content", "text": "<think>Thinking.</think>Answer."})
events += emitter.finish()
assert _emitter_client_text(events) == "<think>Thinking.</think>Answer."
def test_streamed_anthropic_tool_use_records_api_monitor_reply(monkeypatch):
import routes.inference as inf_mod

View file

@ -221,7 +221,7 @@ def test_structured_tool_call_after_visible_preface_is_executed(monkeypatch):
assert assistant_messages[-1]["tool_calls"][0]["function"]["name"] == "render_html"
def test_buffered_reasoning_answer_emits_backend_summary(monkeypatch):
def test_streamed_reasoning_answer_emits_backend_summary(monkeypatch):
stream = [
_sse({"reasoning_content": "I am thinking."}),
_sse({"reasoning_content": " Still thinking."}),
@ -240,17 +240,236 @@ def test_buffered_reasoning_answer_emits_backend_summary(monkeypatch):
)
)
content_texts = [e["text"] for e in events if e["type"] == "content"]
# Reasoning streams live during BUFFERING instead of arriving as one block:
# each reasoning delta is emitted immediately, wrapped in <think>.
assert content_texts[0] == "<think>I am thinking."
assert content_texts[1] == "<think>I am thinking. Still thinking."
# The final event closes the block and appends the answer.
assert content_texts[-1] == "<think>I am thinking. Still thinking.</think>Final answer."
summary_index = next(
i for i, event in enumerate(events) if event["type"] == "reasoning_summary"
)
content_index = next(i for i, event in enumerate(events) if event["type"] == "content")
assert summary_index < content_index
final_content_index = max(i for i, event in enumerate(events) if event["type"] == "content")
assert summary_index < final_content_index
assert events[summary_index]["duration_ms"] == 62000
assert (
events[content_index]["text"]
== "<think>I am thinking. Still thinking.</think>Final answer."
def test_reasoning_streams_incrementally_with_tools(monkeypatch):
# Regression (DeepSeek "thinking doesn't stream"): with a tool/pill active the
# tool-loop generator must stream reasoning token-by-token like the no-tool
# path, not accumulate it and dump one buffered <think> block.
stream = [
_sse({"reasoning_content": "Step one."}),
_sse({"reasoning_content": " Step two."}),
_sse({"reasoning_content": " Step three."}),
_sse({"content": "Done."}),
_done(),
]
payloads: list[dict] = []
backend = _make_backend(monkeypatch, [stream], payloads)
_patch_monotonic(monkeypatch, [1.0, 2.0, 3.0, 4.0, 4.0])
events = list(
backend.generate_chat_completion_with_tools(
messages = [{"role": "user", "content": "think then answer"}],
tools = [{"type": "function", "function": {"name": "web_search"}}],
max_tool_iterations = 1,
)
)
reasoning_stage = [
e["text"]
for e in events
if e["type"] == "content"
and e["text"].startswith("<think>")
and "</think>" not in e["text"]
]
# One live emission per reasoning delta -- not a single dump.
assert reasoning_stage == [
"<think>Step one.",
"<think>Step one. Step two.",
"<think>Step one. Step two. Step three.",
]
final = [e["text"] for e in events if e["type"] == "content"][-1]
assert final == "<think>Step one. Step two. Step three.</think>Done."
def test_reasoning_only_reply_matches_no_tool_path_with_tools(monkeypatch):
# A reasoning-only turn (whole answer in reasoning_content, no content, no
# tool) with a tool active streams the reasoning live, then resolves to the
# bare reasoning text -- identical to the no-tool generate_chat_completion
# path -- so the non-streaming drain still returns it as `content`, not an
# empty answer.
stream = [
_sse({"reasoning_content": "The capital of France is Paris."}),
_done(),
]
payloads: list[dict] = []
backend = _make_backend(monkeypatch, [stream], payloads)
_patch_monotonic(monkeypatch, [1.0, 5.0, 5.0])
events = list(
backend.generate_chat_completion_with_tools(
messages = [{"role": "user", "content": "just think"}],
tools = [{"type": "function", "function": {"name": "web_search"}}],
max_tool_iterations = 1,
)
)
content_texts = [e["text"] for e in events if e["type"] == "content"]
# Reasoning streamed live during BUFFERING (the fix).
assert content_texts[0] == "<think>The capital of France is Paris."
# Resolves to bare reasoning, matching the no-tool sibling.
assert content_texts[-1] == "The capital of France is Paris."
def test_reasoning_before_structured_tool_closes_think_block(monkeypatch):
# Regression: reasoning streamed live during BUFFERING must be closed with
# </think> before a structured tool_call drains, so consumers without a
# reasoning extractor (Anthropic /v1/messages) never receive an unclosed
# <think>. Mirrors the is_match (XML tool signal) path.
tool_stream = [
_sse({"reasoning_content": "Let me search."}),
*_structured_tool_call("web_search", {"query": "weather"}, "call_1"),
]
final_stream = [
_sse({"content": "It is sunny."}),
_done(),
]
payloads: list[dict] = []
backend = _make_backend(monkeypatch, [tool_stream, final_stream], payloads)
_patch_monotonic(monkeypatch, [1.0, 2.0, 3.0, 4.0, 4.0])
monkeypatch.setattr(
"core.inference.tools.execute_tool", lambda name, arguments, **_kwargs: "sunny"
)
events = list(
backend.generate_chat_completion_with_tools(
messages = [{"role": "user", "content": "weather?"}],
tools = [{"type": "function", "function": {"name": "web_search"}}],
max_tool_iterations = 1,
)
)
tool_start_index = next(i for i, e in enumerate(events) if e["type"] == "tool_start")
content_before_tool = [e["text"] for e in events[:tool_start_index] if e["type"] == "content"]
# Reasoning streamed live, then closed before the tool -- balanced block.
assert content_before_tool[0] == "<think>Let me search."
assert content_before_tool[-1] == "<think>Let me search.</think>"
def _replay_route_reasoning_extractor(cumulatives: list[str]) -> tuple[str, str]:
"""Replay the route's cumulative suffix-diff + reasoning extractor (the
shared core of routes/inference.py gguf_stream_chunks and the tool-loop
consumer) over content snapshots. Returns (visible, reasoning)."""
from routes.inference import _ResponsesReasoningExtractor
extractor = _ResponsesReasoningExtractor(parse_think_markers = True)
prev_text = ""
visible: list[str] = []
reasoning: list[str] = []
for cumulative in cumulatives:
new_text = cumulative[len(prev_text) :]
prev_text = cumulative
if not new_text:
continue
reasoning_delta, visible_delta = extractor.feed(new_text)
if reasoning_delta:
reasoning.append(reasoning_delta)
if visible_delta:
visible.append(visible_delta)
final_reasoning, final_visible = extractor.finish()
if final_reasoning:
reasoning.append(final_reasoning)
if final_visible:
visible.append(final_visible)
return "".join(visible), "".join(reasoning)
def test_reasoning_only_route_output_matches_no_tool_path(monkeypatch):
# Parity contract: a reasoning-only reply must reach the client identically
# whether tools are on or off. Both generators stream <think> live then
# resolve to the bare reasoning text; the route's suffix-diff + extractor
# must therefore produce the same (visible, reasoning) split for both.
stream = [
_sse({"reasoning_content": "The capital"}),
_sse({"reasoning_content": " of France is Paris."}),
_done(),
]
tool_backend = _make_backend(monkeypatch, [list(stream)], [])
_patch_monotonic(monkeypatch, [1.0, 2.0, 2.0])
tool_cumulatives = [
e["text"]
for e in tool_backend.generate_chat_completion_with_tools(
messages = [{"role": "user", "content": "capital of France?"}],
tools = [{"type": "function", "function": {"name": "web_search"}}],
max_tool_iterations = 1,
)
if e.get("type") == "content"
]
no_tool_backend = _make_backend(monkeypatch, [list(stream)], [])
no_tool_cumulatives = [
y
for y in no_tool_backend.generate_chat_completion(
messages = [{"role": "user", "content": "capital of France?"}],
)
if isinstance(y, str)
]
# Both paths stream the reasoning live with the same leading shape. (Raw
# yield lists aren't compared verbatim: the tool path emits a pre-existing
# duplicate trailing event that the route's suffix-diff dedupes.)
assert tool_cumulatives[:3] == no_tool_cumulatives[:3]
# The contract that matters: identical route-level output.
tool_out = _replay_route_reasoning_extractor(tool_cumulatives)
no_tool_out = _replay_route_reasoning_extractor(no_tool_cumulatives)
assert tool_out == no_tool_out
# Pin the shared contract so a change to either path shows up here.
_visible, reasoning = tool_out
assert reasoning == "The capital of France is Paris."
def test_reasoning_before_bare_json_tool_closes_think_block(monkeypatch):
# _drain_silently sibling of the structured-tool close: a bare-JSON tool call
# with a live reasoning prefix must also close </think> before draining, and
# must never leak the drained call text as content.
tool_stream = [
_sse({"reasoning_content": "Searching now."}),
_sse({"content": '{"name":"web_search","arguments":{"query":"weather"}}'}),
_done(),
]
final_stream = [
_sse({"content": "It is sunny."}),
_done(),
]
payloads: list[dict] = []
backend = _make_backend(monkeypatch, [tool_stream, final_stream], payloads)
_patch_monotonic(monkeypatch, [1.0, 2.0, 3.0, 4.0, 4.0])
monkeypatch.setattr(
"core.inference.tools.execute_tool", lambda name, arguments, **_kwargs: "sunny"
)
events = list(
backend.generate_chat_completion_with_tools(
messages = [{"role": "user", "content": "weather?"}],
tools = [{"type": "function", "function": {"name": "web_search"}}],
max_tool_iterations = 1,
)
)
tool_start_index = next(i for i, e in enumerate(events) if e["type"] == "tool_start")
content_before_tool = [e["text"] for e in events[:tool_start_index] if e["type"] == "content"]
assert content_before_tool[0] == "<think>Searching now."
assert content_before_tool[-1] == "<think>Searching now.</think>"
# The bare-JSON call text was drained, never surfaced as content.
assert not any('"name"' in t for t in content_before_tool)
def test_consumed_tool_final_pass_emits_latest_reasoning_summary(monkeypatch):
tool_stream = [