server: stream the answer past a stray </think> instead of holding it (#783) - #787
Open
Flor1an-B wants to merge 1 commit into
Open
server: stream the answer past a stray </think> instead of holding it (#783)#787Flor1an-B wants to merge 1 commit into
Flor1an-B wants to merge 1 commit into
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 tofe2d3b0by @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,0split cases measured), the model never re-opens<think>(0opening tags measured), and a partial trailing<is already held bytext_stream_safe_limit().Net effect vs the two prior states
main(blocking): streaming restored for every thinking+tools turn./v1/messages: a second literal think-close tag is emitted as answer text (ANTH_STREAM_TEXThas no close-tag handling) #678 (leaking): the stray tag is now suppressed rather than forwarded verbatim — strictly better. The residual is that in the now-rarer second-pass case (fe2d3b0also fixed the tool-schema prompt that provoked it) the draft streams as content instead of landing in the reasoning channel. Given the impossibility of early detection, this is the streaming-side tradeoff for real-time output.Applied identically to the OpenAI (
/v1/chat/completions) and Anthropic (/v1/messages) streams.Testing
fe2d3b0guard tests are updated to the new semantics and renamed (*_suppresses_second_think_tag).*_streams_answer_incrementally) assert content is emitted mid-stream, before the final chunk.st.sent_contentand the content-routing check), so the tests genuinely pin the regression../ds4_test --servergreen; warning-freeds4-serverbuild on Apple M5 Max (Metal).End-to-end, M5 Max / Metal / DeepSeek-V4-Flash resident, streaming
/v1/chat/completionswith a tool defined and thinking on, on a prompt the model answers directly (the case #783 regressed):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