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
- 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).
- Send a first request; the response includes
reasoning_content on the assistant message (switchyard's encode_response correctly emits it).
- 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.
Symptom
Multi-turn chat with a reasoning-required OpenAI-compatible upstream (e.g. DeepSeek's thinking models) fails on the second turn with:
and, in tool-calling flows:
The first turn works fine; any follow-up turn that carries back the assistant message from turn 1 fails.
Reproduction
reasoning_contentto be passed back verbatim on subsequent turns (DeepSeek v4 flash/pro "deepseek-reasoner"-style models do this).reasoning_contenton the assistant message (switchyard'sencode_responsecorrectly emits it).messagesarray includes that assistant message with itsreasoning_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.
Expected vs. actual
reasoning_contentis forwarded to the upstream so reasoning-required providers can validate the turn.reasoning_contentis 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:ContentBlock::Reasoningis explicitly filtered out of the encoded request, even though the response encoder (encode_response, same file) re-emits it asreasoning_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_blocksmerges incoming reasoning into content blocks on the decode side, but there is no corresponding encode-side path that restores a standalonereasoning_contentfield on assistant messages in the request.Suggested direction
When encoding a request for an OpenAI-compatible upstream, preserve
ContentBlock::Reasoningfrom assistant messages as a top-levelreasoning_content(orreasoning) 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
feat(libsy-llm-client)etc.)Additional context
Workaround used in production: a thin gateway in front of switchyard caches the last assistant
reasoning_contentper 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.