Skip to content

fix: split the think block in non-streaming responses - #740

Open
Javinator9889 wants to merge 2 commits into
ROCm:mainfrom
Javinator9889:fix/nonstream-reasoning-split
Open

Javinator9889 wants to merge 2 commits into
ROCm:mainfrom
Javinator9889:fix/nonstream-reasoning-split

Conversation

@Javinator9889

Copy link
Copy Markdown

Fixes #739.

build_nstream_response takes reasoning_content from parse_nstream_content,
which only gemma4_12b, gemma4e, gpt_oss, lfm2, llama3, nanbeige and qwen3
populate. For qwen3_5vl, qwen3_6_moe, qwen3vl and qwen3_5_omni the think block
stays in content and reaches the client as raw <think> tags.

Two commits:

  1. Split the block once in build_nstream_response, so every model reports
    reasoning the same way. No-op when the model already populated
    reasoning_content. Handles the generation prompt having opened the block,
    where only the closing tag appears in the generated text.
  2. Route an unclosed block — generation truncated mid-thought, finish_reason
    length — to reasoning_content with empty content, since there is no
    answer yet. Previously the partial thought was delivered as the answer.

The streaming path is untouched: its parser stays in REASONING when the block
never closes, so it already behaves correctly.

Verified on qwen3.5:9b, same two requests as the issue:

before (92f3f13) after
completes reasoning_content absent, content = '<think>\nThinking Process:...' reasoning_content set, no tags in content
truncated same reasoning_content set, content empty

Only some models separate reasoning in parse_nstream_content (gpt-oss,
nanbeige); the rest leave the block inline in content. The split otherwise
happens only in the streaming parser, so any buffered response -- which is
what tool_choice=required and named produce -- reached the client as literal
<think> tags instead of reasoning_content. Observed on qwen3.5:9b through
OpenCode, which renders the tags rather than a reasoning block.

Split it once in build_nstream_response so every model reports reasoning the
same way. No-op when the model already filled reasoning_content. Handles the
generation prompt having opened the block, where only the closing tag appears
in the generated text.

Measured on qwen3.5:9b: tool_choice=required goes from a literal <think> in
content to reasoning_content with clean content; plain non-streaming likewise.
Offline ctest passes, tool-policy suite unchanged at 7/8 (the one failure is
the pre-existing local include_usage deviation), usage contract passes.
When generation hits the token limit inside the think block the closing
</think> never arrives, so the split could not fire and the partial thought
was delivered as content. The client then shows "Thinking Process: 1.
**Analyze the Request:** The user wants me to ..." as the reply, which reads
like the prompt being echoed back.

Measured on qwen3.5:9b at 81k context over 30 turns: 14 turns produced a
wrong answer and every one of them had finish_reason "length" with empty
reasoning_content. Not one turn that finished normally was wrong. The same
conversation at 97k in a single turn answers perfectly, so this is not
context-length degradation -- it is truncation surfacing as content.

Route the unclosed remainder to reasoning_content and leave content empty,
since there is no answer yet. The streaming path already handles this: its
parser stays in REASONING mode when the block never closes.
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-streaming responses leak <think> tags into content for qwen3.5, qwen3.6-moe, qwen3vl and qwen3.5-omni

1 participant