Skip to content

Studio: forward client reasoning_content to llama-server for opt-in preserve_thinking - #7758

Open
mahille wants to merge 3 commits into
unslothai:mainfrom
mahille:fix/reasoning-content-passthrough
Open

Studio: forward client reasoning_content to llama-server for opt-in preserve_thinking#7758
mahille wants to merge 3 commits into
unslothai:mainfrom
mahille:fix/reasoning-content-passthrough

Conversation

@mahille

@mahille mahille commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #7289. Fixes #5846.

Background

#7289 shipped only the size-gate half of the Qwen3.x thinking fix: Qwen3.5-35B-A3B was misread as a 3B model, so thinking was disabled by default for 35B. The inbound half was narrowed out before merge and is restored here.

On current main, ChatMessage (studio/backend/models/inference.py) has no reasoning_content field, and Pydantic's default extra="ignore" silently drops it at the request boundary. Both llama-server-facing builders (_openai_messages_for_passthrough, _openai_messages_for_gguf_chat) re-serialize via model_dump(exclude_none=True), so the field can't reappear downstream. Result: when an OpenAI-compatible client (opencode, etc.) sends prior-turn reasoning back, the template renders an empty <think> block and the model loses its chain of thought across turns — exactly the "model can't remember what it decided last turn" symptom in #5846, reproducible even with Think + Preserve Think enabled. (LM Studio works because it doesn't drop the field.)

Fix

This is opt-in only — no defaults change:

  • ChatMessage.reasoning_content: Optional[str] — forwarded verbatim to llama-server, where templates that branch on message.reasoning_content render it back into <think>…</think> when the client sets preserve_thinking=true. The launch default preserve_thinking=false (llama_cpp.py) and the per-request opt-in (ChatCompletionRequest.preserve_thinking) are untouched. When the flag is off, templates gate the rendering, so forwarding is inert.
  • Before-validator drops non-string values to None instead of 422: gateways emit structured reasoning (list/dict), and typing the field must not turn previously-ignored payloads into request failures.
  • Reasoning-only assistant turns are kept in _drop_empty_assistant_sentinels and padded with content="" — llama.cpp requires a content or tool_calls key and only reads reasoning_content after that check, so the turn was previously rejected outright.
  • Context-overflow recovery (context_overflow=truncate_middle): preserved traces are clipped before sizing (_clip_reasoning_contents) and counted conservatively (_estimate_messages_tokens), so a huge trace in a protected turn no longer force-evicts the whole conversation middle. Clipping mutates only the retry body, never the client's history.

Why a typed field and not model_config(extra="allow")

Per-message dicts are forwarded to llama-server without any key allow-list (unlike the passthrough body builder, which explicitly forwards only known OpenAI/llama-server fields). extra="allow" on ChatMessage would therefore forward every arbitrary key any client sends straight into template rendering — an unbounded widening of the client→llama-server wire contract. This codebase already has that scar: adding the single typed extra_content field leaked Gemini-only keys toward llama-server and required the explicit scrubber _strip_provider_synthetic_tool_history. The typed field forwards exactly one documented key; structured/None values are normalized away at parse or exclude_none dump.

Repo-wide interaction check (mirrors the review rounds on #7289)

Consumers of inbound message dicts were traced end to end:

  • GGUF passthrough / chat / tool loop (llama-server) — covered by this PR; reasoning_content reaches the template verbatim. Tested.
  • External providers (OpenAI / Anthropic / Gemini / …) — intentionally not forwarded: _build_external_messages and the per-provider translators rebuild dicts field-by-field, remote APIs 400 on unknown keys, and each provider has its own reasoning-replay contract (Anthropic signed thinking blocks, Gemini thought_signature via extra_content). Excluded by design, not deferred.
  • Anthropic /v1/messages and OpenAI /v1/responses endpointsAnthropicMessage still drops the field at parse and both paths rebuild messages field-by-field; restoring there needs their native reasoning models. Deferred follow-ups.
  • Safetensors/MLX standard path_extract_content_parts rebuilds role+content only; forwarding there without plumbing preserve_thinking through that path's template kwargs would put an uncontrolled key on the wire. Deferred follow-up.
  • Persistence / logging — unaffected: inference handlers never persist inbound dumps (the DB has no reasoning column), and the API monitor reads role/content only.
  • Adjacent pre-existing exposure (not introduced here): ChatMessage.content already accepts reasoning-type content parts, which forward toward llama-server unparsed. Out of scope.

Review-history points from #7289 explicitly addressed: structured reasoning → None, not 422 (Codex); reasoning-only turns get content="" (Codex); overflow retries clip reasoning before content (Codex); preserve_thinking default left off (Codex / maintainer). Deviation from the narrowed-out reference implementation: the dead reasoning=/contents= parameterization on _clip_long_contents was not restored — its only call site passed a token target as a per-side character budget (unit mismatch) with reasoning=False; the standalone pre-sizing clip covers the behavior.

Tests

11 new tests across three files: parse-level contract (round-trip, structured → None, absent-not-null), sentinel padding, byte-identical forwarding on both llama-server paths, the _strip_provider_synthetic_tool_history asymmetry lock (scrubs extra_content, keeps reasoning_content), and the overflow clip/sizing behavior.

studio/backend/tests/test_context_overflow_truncation.py
studio/backend/tests/test_tool_message_empty_content.py
studio/backend/tests/test_openai_tool_passthrough.py
studio/backend/tests/test_qwen_thinking_size_gate.py
studio/backend/tests/test_llama_cpp_mtp_detection.py
→ 585 passed

…reserve_thinking

ChatMessage lacked a reasoning_content field, so Pydantic's default
extra="ignore" silently dropped client-sent prior-turn reasoning before
the proxy forwarded messages to llama-server; templates then rendered
empty <think> blocks and multi-turn reasoning collapsed (unslothai#5846). This
restores the inbound half of unslothai#7289 that was narrowed out pre-merge:
typed field (non-string values -> None, not 422), reasoning-only turns
kept with content="" (llama.cpp reads reasoning_content only after its
content/tool_calls key check), and overflow retries clip preserved
traces before sizing. Opt-in only: preserve_thinking launch default
stays False; rendering stays template-gated without it.
@mahille
mahille requested a review from danielhanchen as a code owner August 2, 2026 16:23

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e48656e7c5

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

has_reasoning = bool(m.get("reasoning_content"))
if not has_content and not has_tool_calls and not has_reasoning:
continue
if not has_content and not has_tool_calls:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve reasoning after synthetic tool-call scrubbing

When a prior assistant turn has reasoning_content plus only provider-synthetic builtin tool_calls (the _server_tool / Gemini native_part cases handled by _strip_provider_synthetic_tool_history), this padding path does not run because has_tool_calls is true; the scrubber then removes all of those tool calls and its existing empty-turn check at routes/inference.py:16987 drops the whole message because it still ignores reasoning_content. That loses the preserved reasoning exactly when switching such a history to local GGUF, so the scrubber also needs to treat reasoning_content as content and add the empty content key after removing synthetic calls.

Useful? React with 👍 / 👎.

total_est = sum(_estimate_message_tokens(m) for m in messages)
# Before sizing: see _clip_reasoning_contents for why this cannot wait for a target.
clipped = _clip_reasoning_contents(messages)
total_est = _estimate_messages_tokens(messages)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid dropping history after clipping reasoning

When the upstream overflow was caused mostly by a huge preserved reasoning_content, this new pre-sizing clip can already shrink the retry body below the usable context window, but total_est is then multiplied by a keep_ratio that is still based on the original pre-clip n_prompt_tokens. In that case a modest conversation with a 40k-character trace gets clipped and still has most middle turns dropped even though the clipped prompt estimate would fit, defeating the goal of clipping traces before evicting history; recompute/short-circuit the drop target after the clip instead of applying the stale original overflow ratio.

Useful? React with 👍 / 👎.

"from assistant messages to replay text-part signatures."
),
)
reasoning_content: Optional[str] = Field(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Scrub reasoning_content from safetensors client-tool path

Adding this typed field makes _openai_messages_for_passthrough() dump reasoning_content, but that helper is also reused for the safetensors/MLX client-tool templating path at routes/inference.py:11890-11893, not just for llama-server. In a non-GGUF request with client tools or tool history, prior assistant traces now reach the local template despite this change intending to defer that path, so either that path needs the same intentional preserve-thinking support or it should remove reasoning_content before templating.

Useful? React with 👍 / 👎.

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.

[Bug] Qwen 3.6 35b a3b Preserve Think not working.

1 participant