Skip to content

[bug] reasoning_content stripped from requests: breaks reasoning-required upstreams (DeepSeek thinking) on multi-turn #449

Description

@DuanZGit

Symptom

Multi-turn chat with a reasoning-required OpenAI-compatible upstream (e.g. DeepSeek's thinking models) fails on the second turn with:

400 invalid_request_error: The `reasoning_content` in the thinking mode must be passed back to the API.

and, in tool-calling flows:

400 invalid_request_error: Messages with role 'tool' must be a response to a preceding message with 'tool_calls'

The first turn works fine; any follow-up turn that carries back the assistant message from turn 1 fails.

Reproduction

  1. Configure a route whose backend is a reasoning/thinking OpenAI-compatible model that requires reasoning_content to be passed back verbatim on subsequent turns (DeepSeek v4 flash/pro "deepseek-reasoner"-style models do this).
  2. Send a first request; the response includes reasoning_content on the assistant message (switchyard's encode_response correctly emits it).
  3. Send a second request whose messages array includes that assistant message with its reasoning_content (i.e. a client that faithfully passes history back).

Expected: second turn succeeds.

Actual: switchyard strips the reasoning field from the request before forwarding, so the upstream rejects the turn with the 400 above.

# turn 1 (ok) — response contains reasoning_content
curl -s http://127.0.0.1:4000/v1/chat/completions \
  -d '{"model":"smart1","messages":[{"role":"user","content":"hi"}],"stream":false}'

# turn 2 (400) — history includes assistant reasoning_content from turn 1
curl -s http://127.0.0.1:4000/v1/chat/completions \
  -d '{"model":"smart1","messages":[{"role":"user","content":"hi"},
       {"role":"assistant","content":"...","reasoning_content":"thinking..."}],"stream":false}'

Expected vs. actual

  • Expected: the assistant message's reasoning_content is forwarded to the upstream so reasoning-required providers can validate the turn.
  • Actual: reasoning_content is silently dropped on the request path; providers that enforce "reasoning_content must be passed back" reject with 400.

Root cause (code pointer)

crates/switchyard-translation/src/codecs/openai_chat/buffered.rs, encode_message_without_tool_results_to_openai:

let content_blocks = message
    .content
    .iter()
    .filter(|block| {
        !matches!(
            block,
            ContentBlock::ToolCall(_) | ContentBlock::Reasoning { .. }
        )
    })

ContentBlock::Reasoning is explicitly filtered out of the encoded request, even though the response encoder (encode_response, same file) re-emits it as reasoning_content. This asymmetry breaks round-tripping for providers that require reasoning to be echoed back (DeepSeek thinking, some vLLM reasoning templates).

Note: prepend_openai_reasoning_blocks merges incoming reasoning into content blocks on the decode side, but there is no corresponding encode-side path that restores a standalone reasoning_content field on assistant messages in the request.

Suggested direction

When encoding a request for an OpenAI-compatible upstream, preserve ContentBlock::Reasoning from assistant messages as a top-level reasoning_content (or reasoning) field instead of dropping it — ideally gated by a policy/extension so providers that reject unknown fields are unaffected. This mirrors the response-side behavior and makes switchyard safe for reasoning-required providers.

Environment

  • Switchyard: main @ 2026-08-14 (recent commits: feat(libsy-llm-client) etc.)
  • Inbound format: Chat Completions
  • Backend: OpenAI-compatible reasoning model (DeepSeek thinking)
  • Route type: deterministic routing with a classifier LLM

Additional context

Workaround used in production: a thin gateway in front of switchyard caches the last assistant reasoning_content per session and re-injects it into outbound requests when the client omits it. This confirms the upstream requirement and that the fix belongs in the translation layer. Happy to prepare a patch + tests if the direction is approved.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions