Skip to content

server: stream the answer past a stray </think> instead of holding it (#783) - #787

Open
Flor1an-B wants to merge 1 commit into
antirez:mainfrom
Flor1an-B:fix-streaming-guard-second-reasoning-hold
Open

server: stream the answer past a stray </think> instead of holding it (#783)#787
Flor1an-B wants to merge 1 commit into
antirez:mainfrom
Flor1an-B:fix-streaming-guard-second-reasoning-hold

Conversation

@Flor1an-B

Copy link
Copy Markdown

Fixes #783.

Problem

fe2d3b0 (#678) guards against a tool-enabled model writing a second reasoning pass into the answer channel and closing it with a literal </think>. On the streaming path the guard holds all answer text back until a second </think>, a tool call, or the final chunk arrives. For the common single-pass case — thinking + tools, one reasoning block, then a plain answer with no tool call — none of those arrive until the end, so the whole answer is buffered and flushed at once. Streaming is dead (bisected to fe2d3b0 by @links486; reported against Open WebUI, which defaults to native tool calling).

Why the old guard can't stream

The routing of post-</think> text depends on whether a second </think> ever appears — a whole-stream property with no early deterministic signal. "Hold until proven" is correct but fundamentally cannot stream. This PR trades that: stream the answer as content in real time and simply drop any stray </think> so it never reaches the client. The #678 diagnostics make this safe to do inline — the tag is a single vocabulary token (never split across deltas, 0 split cases measured), the model never re-opens <think> (0 opening tags measured), and a partial trailing < is already held by text_stream_safe_limit().

Net effect vs the two prior states

Applied identically to the OpenAI (/v1/chat/completions) and Anthropic (/v1/messages) streams.

Testing

  • The two fe2d3b0 guard tests are updated to the new semantics and renamed (*_suppresses_second_think_tag).
  • Two regressions added (*_streams_answer_incrementally) assert content is emitted mid-stream, before the final chunk.
  • Negative control: reverting the production hunk fails both new assertions (st.sent_content and the content-routing check), so the tests genuinely pin the regression.
  • ./ds4_test --server green; warning-free ds4-server build on Apple M5 Max (Metal).

End-to-end, M5 Max / Metal / DeepSeek-V4-Flash resident, streaming /v1/chat/completions with a tool defined and thinking on, on a prompt the model answers directly (the case #783 regressed):

reasoning deltas : n=23  first=1.487s last=1.981s
content   deltas : n=43  first=2.003s last=2.957s
[DONE] at        : 2.957s
content started  : 0.954s BEFORE [DONE]   (was: single burst at [DONE])
content spread   : 0.953s across 43 deltas

Trace confirms the guarded path (tool-schema prompt injected, <think>…</think> then a plain answer, no tool call); output contained no stray </think>.

🤖 Generated with Claude Code

…antirez#783)

fe2d3b0 ("Keep repeated reasoning out of streamed answers", antirez#678) guards
against a tool-enabled model writing a second reasoning pass into the answer
channel and closing it with a literal </think>. On the streaming path the guard
held ALL answer text back until the second </think>, a tool call, or the final
chunk. For the common single-pass case (thinking + tools, one reasoning block,
a plain answer with no tool call) none of those arrive until the end, so the
whole answer was buffered and flushed at once — no streaming (antirez#783, bisected to
fe2d3b0; reported against Open WebUI which defaults to native tool calling).

The routing of post-</think> text depends on whether a *second* </think> ever
appears — a whole-stream property with no early deterministic signal — so the
old "hold until proven" is correct but cannot stream. This trades that for
streaming: emit the answer as content in real time and simply drop any stray
</think> so it never reaches the client (issue antirez#678 data: the tag is a single
vocabulary token, never split across deltas, and the model never re-opens
<think>; a partial trailing '<' is already held by text_stream_safe_limit()).

Net vs the two prior states: streaming is restored for every thinking+tools
turn (the fix); in the now-rarer second-pass case (fe2d3b0 also fixed the
tool-schema prompt that provoked it) the draft streams as content and the stray
tag is suppressed — strictly better than the pre-antirez#678 leak (which forwarded the
tag verbatim), at the cost of the draft no longer landing in the reasoning
channel. Applied identically to the OpenAI and Anthropic streams.

Tests: the two fe2d3b0 guard tests are updated to the new semantics and renamed
(*_suppresses_second_think_tag); two regressions added
(*_streams_answer_incrementally) assert content is emitted mid-stream, before
the final chunk. Verified with a negative control: reverting the guard fails
both the incremental assertion (st.sent_content) and the content-routing one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Non-thinking streaming output broken after commit fe2d3b0

1 participant