Skip to content

fix(server): keep the visible answer of a thinking model on the MLX chat path - #736

Merged
krisztian-gajdar merged 2 commits into
mainfrom
fix/mlx-thinking-answer
Oct 14, 2026
Merged

krisztian-gajdar merged 2 commits into
mainfrom
fix/mlx-thinking-answer

Conversation

@krisztian-gajdar

@krisztian-gajdar krisztian-gajdar commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Summary

On Apple Silicon, mlx_lm.server (0.31.3 and 0.32.0 checked) returns a thinking model's reasoning in its own reasoning field. The answer comes back in content, without the </think> marker. Qwen/Qwen3.5-4B:thinking seeds <think> in the prompt, so _sanitize_chat_payload started its ThinkingBlockStripper inside the thinking block. It never saw the closing marker, so it returned content: "". With include_reasoning: true, the answer was appended to reasoning_content instead.

Fix. The prompt can seed an open thinking block (initial_inside_thinking). When it does, and a choice's first chunk that carries content or reasoning also has a non-empty reasoning or reasoning_content field, SIE holds that choice's content instead of stripping it at once. Then:

  • If the held content contains </think>, the text before it is reasoning. It stays hidden, as before this change, and the rest is stripped as usual.
  • If the choice finishes with stop or tool_calls and the content never closed the block, the held content is the answer. It is released and stripped as content that starts outside a block.
  • Any other finish, such as length, keeps the held content hidden, as before this change. At that point SIE cannot tell a cut-off answer from cut-off reasoning.

The same rule runs for a whole message and for a stream, so both return the same content for the same output. A child that never sends a reasoning field is handled exactly as before.

Where it applies. initial_inside_thinking is true on two paths:

  • the MLX chat path with a thinking profile.
  • the CUDA SGLang chat path with a thinking profile and no reasoning parser.

SGLang 0.5.10.post1 fills reasoning_content only when it runs with a reasoning parser (if self.reasoning_parser and request.separate_reasoning: in serving_chat.py). On the second path the child therefore never sends a reasoning field, and its output is unchanged. The CUDA path with a reasoning parser and remote chat start outside the block and are not affected.

Cost. In a stream, the answer of such a response is no longer sent token by token. It arrives in the chunk that carries finish_reason, because until then SIE cannot tell it from reasoning left in content. Reasoning still streams as it arrives when include_reasoning is true. Tool calls are not held.

include_reasoning. The contract from #728 is unchanged. Reasoning is returned only when the request asks for it.

Known limits, all unchanged from before this change:

  • If the model emits no reasoning text, mlx_lm sends no reasoning field. That response looks like a raw-tag response whose block never closes, so its content stays hidden. With include_reasoning: true, the answer is returned as reasoning_content.
  • A choice's mode is fixed at its first chunk that carries content or reasoning. If a child sends content, even whitespace, before its first reasoning field, the choice is treated as raw and the answer stays hidden. mlx_lm 0.31.3 and 0.32.0 start in the reasoning state when the prompt seeds <think>, so their first such chunk is reasoning.
  • An answer that contains a literal </think> next to split reasoning is cut to the text after the marker.

Tests

Two table tests drive the real /v1/chat/completions route and the real sanitizer through a mock child transport. Each case runs with and without include_reasoning. It checks content, reasoning_content, and that the hidden text is absent from the response when reasoning was not requested.

test_mlx_thinking_chat_keeps_the_answer_beside_split_reasoning (whole message):

Case Child message Finish content
reasoning answer and reasoning stop answer
reasoning_content answer and reasoning_content stop answer
raw_tags reasoning</think>answer stop answer
split_and_raw_close reasoning, and content that still closes the block stop answer
split_then_raw_open reasoning, and content ready<think>one word stop ready
split_cut_off reasoning, and content still thinking length ""
no_reasoning_field answer only (known limit) stop ""

test_mlx_thinking_chat_stream_keeps_the_answer_beside_split_reasoning (stream):

Case Child deltas Finish content
reasoning mlx_lm 0.32 shape: reasoning deltas, then content deltas stop answer
reasoning_content_after_role role chunk, reasoning_content deltas, then content stop answer
raw_tags raw tags split across deltas stop answer
split_then_raw_close role chunk, a reasoning-only delta, then content ...</think>answer stop answer
split_then_raw_close_across_deltas the same, with </think> split across two deltas stop answer
split_and_answer_in_one_delta one delta with reasoning and the answer stop answer
answer_before_tool_call reasoning, content, then a tool call tool_calls content
split_cut_off reasoning, then content length ""
no_reasoning_field answer only (known limit) stop ""

Mutation checks, each run against the whole test_openai_local.py module (178 tests). The counts are failing test ids, and each case runs twice.

Mutation Fails
Code before this PR 14, the split cases
Previous head of this PR (75c83cf9) 10: split_cut_off (both tests), split_then_raw_close, split_then_raw_close_across_deltas, split_and_answer_in_one_delta
Release on any finish 4: split_cut_off (both tests)
Release only on stop 2: answer_before_tool_call
Look for </think> in the current delta only 3: split_then_raw_close_across_deltas and test_cuda_chat_stream_sanitizes_split_reasoning_and_closes_upstream
Release without stripping 2: split_then_raw_open
Decide at the role-only chunk 2: reasoning_content_after_role
Never hold 14, the split cases

Validation:

  • mise run lint is clean.
  • mise run typecheck is clean.
  • mise run test -- packages/sie_server/tests/api: 1249 passed, 4 skipped.
  • tools/ci/check_public_tree.py passed.

Live turn on Apple Silicon

Setup:

  • Apple M3 Max with 48 GB, macOS 15.7.7, mlx-lm 0.32.0.
  • mise run serve -- -b sglang serving Qwen/Qwen3.5-4B:thinking (mlx-community/Qwen3.5-4B-4bit).
  • One user turn, "Reply with exactly one word: ready", at temperature 0 with seed 7.
  • Sent with SIEClient.chat_completions and SIEClient.stream_chat_completions.
  • Each run used 147 completion tokens.

Before (base 0e3bdead):

Mode include_reasoning content reasoning_content
buffered false "" absent
stream false "" absent
buffered true "" 562 chars, ending ...eady\n\n\nready
stream true "" 562 chars, ending ...eady\n\n\nready

After (head f34ff02d):

Mode include_reasoning content reasoning_content
buffered false "\n\nready" absent
stream false "\n\nready" absent
buffered true "\n\nready" 555 chars, ending ...se:** ready\n
stream true "\n\nready" 555 chars, ending ...se:** ready\n

In both streams, the answer arrived in the last of 140 choice chunks, the one that carries finish_reason: stop. No earlier chunk carried content.

Summary by CodeRabbit

  • Bug Fixes
    • Improved handling of reasoning in MLX chat completions, preserving answers and exposing reasoning only when requested.
    • Covered reasoning delivered through multiple formats, including streamed responses.

…hat path

mlx_lm returns a thinking model's reasoning in its own reasoning field and
the answer in content, without the closing think tag. The chat sanitizer
started that content inside the prompt-seeded thinking block, never saw
the close, and returned empty content; with include_reasoning the answer
was appended to reasoning_content instead.

A choice now starts outside the thinking block when the child returned
reasoning in its own field and content does not carry it: a stream delta
whose reasoning arrives before any content, or a whole message whose
content never closes the block. Content that still carries a raw block is
stripped as before.
@krisztian-gajdar
krisztian-gajdar requested a review from a team as a code owner October 14, 2026 16:58
@coderabbitai

coderabbitai Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Team
  • Run ID: 84c7e070-081e-4024-9d63-8504d2f4107b
📥 Commits

Reviewing files that changed from the base of the PR and between 75c83cf and f34ff02.

📒 Files selected for processing: 2
  • packages/sie_server/src/sie_server/api/openai_local.py
  • packages/sie_server/tests/api/test_openai_local.py

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 4 remain after this review.

📝 Walkthrough

The local OpenAI chat-payload sanitizer buffers content while an initial thinking block is open. It releases or reprocesses that content when reasoning closes or the choice ends. Blocking and streaming tests cover reasoning fields, raw <think> tags, and conditional exposure of reasoning as reasoning_content.

Changes: MLX reasoning extraction

Layer / File(s) Summary
Thinking-block stripping and validation
packages/sie_server/src/sie_server/api/openai_local.py, packages/sie_server/tests/api/test_openai_local.py
The sanitizer buffers content until the reasoning-closing token or choice termination. For stop and tool_calls finishes without a closing token, it resets the stripper before processing the held content. Tests cover blocking and streaming completions, including split reasoning, truncated output, raw tags, and conditional reasoning_content.

Suggested reviewers: svonava

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to f34ff

The change keeps the visible answer of MLX thinking models instead of returning empty content, and the tests cover both blocking and streaming paths. No merge-blocking risk was identified.

Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 5 functions across 2 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title clearly and concisely describes the main change: preserving visible answers from thinking models on the MLX chat path.
  • Fix all pre-merge checks with AI
✨ Finishing Touches: 💡 1
📝 Generate docstrings: 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

coderabbitai[bot]
coderabbitai Bot previously approved these changes Oct 14, 2026
…es or the choice stops

When the prompt seeds an open thinking block and the child returns
reasoning in its own field, the content of that choice is held instead of
being sent through a stripper that starts outside the block. A close marker
in the held content means the text before it is reasoning, which stays
hidden as before. A choice that finishes with stop or tool_calls without
one releases the held content as the answer. Any other finish, such as
length, keeps it hidden.

This makes a buffered message and the same content streamed agree, and
keeps reasoning private when a child leaves the end of the block in
content after a reasoning-only delta, or is cut off before it.
@krisztian-gajdar

Copy link
Copy Markdown
Contributor Author

Review findings on 75c83cf9, and how f34ff02d handles each one.

The rule changed. When the prompt seeds <think> and the child sends a reasoning field, content is held until </think> appears (the text before it stays hidden) or the choice stops. stop and tool_calls release it as the answer. length keeps it hidden. The PR body describes the rule, where it applies, and its cost: in a stream, the answer arrives in the chunk that carries finish_reason.

The table runs each review input through _sanitize_chat_payload with the MLX path's arguments, without and with include_reasoning. Each cell shows (content, reasoning_content).

Input Base 0e3bdead 75c83cf9 f34ff02d
Stream: role chunk, {"reasoning_content":"private"}, {"content":"private chain</think>Visible answer"}, stop Visible answer / privateprivate chain private chainVisible answer (leak) Visible answer / privateprivate chain
Whole: {"content":"still thinking about","reasoning":"r"}, length "" / rstill thinking about still thinking about (leak) "" / rstill thinking about
Stream: one delta {"reasoning":"The user wants one word.\n","content":"\n\nready"}, stop "" "", answer folded into reasoning "\n\nready" / The user wants one word.\n
Whole: the same output as one message "" "\n\nready" "\n\nready" / The user wants one word.\n
No reasoning field, {"content":"\n\nready"}, stream and whole "" / "\n\nready" same same (known limit, now stated with its include_reasoning result)
Whole: {"content":"Close it with </think> here.","reasoning":"r"} " here." " here." " here." (unchanged, stated)
Stream: {"content":"\n"}, then reasoning, then answer "" "" "" (unchanged, stated)
Stream: reasoning first., content A<think>second, then </think>B B / first.A<think>second AB / first.second B / first.A<think>second (back to base)

Each finding:

  1. Leak after a reasoning-only delta. Fixed. The content is held, and </think> hides what precedes it, whichever delta the marker lands in. Tests: split_then_raw_close and split_then_raw_close_across_deltas.
  2. Buffered cut-off by length. Fixed. A held choice that ends with anything other than stop or tool_calls stays hidden, in a whole message and in a stream. Tests: split_cut_off in both tests.
  3. Stream and whole message disagree. Fixed. Both paths run the same rule, so one delta with reasoning and the answer now keeps the answer. Test: split_and_answer_in_one_delta.
  4. Path outside MLX. Now stated in the body. The CUDA SGLang path with a thinking profile and no reasoning parser runs the same rule. SGLang 0.5.10.post1 fills reasoning_content only with a reasoning parser (if self.reasoning_parser and request.separate_reasoning: in serving_chat.py), so that child never sends the field, and its output is unchanged.
  5. Known limit text. Now stated: with include_reasoning: true, the answer of a response with no reasoning field is returned as reasoning_content. Pinned by no_reasoning_field in both tests.
  6. Decision-boundary tests. Added to the two table tests, with split_then_raw_open and answer_before_tool_call. Eight mutations, including the previous head, each fail at least one case. The body lists them.

Risks: the literal </think> and whitespace-first cases match base and are listed as known limits. The A<think>second case is back to base behaviour.

Validation on f34ff02d: mise run lint and mise run typecheck are clean. mise run test -- packages/sie_server/tests/api gives 1249 passed and 4 skipped. The live turn was re-recorded on this head, with the same content in all four modes. In both streams the answer arrived in the finish chunk.

@krisztian-gajdar
krisztian-gajdar merged commit a5d1076 into main Oct 14, 2026
21 checks passed
@krisztian-gajdar
krisztian-gajdar deleted the fix/mlx-thinking-answer branch October 14, 2026 18:20
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.

1 participant