diff --git a/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs b/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs index d829ca316..ca504be5b 100644 --- a/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs +++ b/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs @@ -881,9 +881,7 @@ fn encode_openai_message_plaintext_reasoning(message: &mut Value, content: &[Con }) .collect::>() .join("\n"); - if !reasoning.is_empty() { - message["reasoning"] = Value::String(reasoning); - } + set_openai_reasoning_text(message, reasoning); } // Adds exact provider details and text that cannot be recovered from those details. @@ -907,9 +905,22 @@ fn encode_openai_message_structured_reasoning( }) .collect::>() .join("\n"); - if !fallback.is_empty() { - message["reasoning"] = Value::String(fallback); + set_openai_reasoning_text(message, fallback); +} + +// Writes replayed reasoning under both OpenAI-compatible spellings. +// +// `reasoning` is what most OpenAI-compatible providers read. Reasoning-required +// upstreams look for `reasoning_content` specifically and reject a follow-up turn +// whose assistant history lacks it, treating the field as present-or-absent rather +// than reading the alias, so both are written. The decode side already accepts +// either spelling. +fn set_openai_reasoning_text(message: &mut Value, reasoning: String) { + if reasoning.is_empty() { + return; } + message["reasoning"] = Value::String(reasoning.clone()); + message["reasoning_content"] = Value::String(reasoning); } // Checks whether any block in a message is a tool result. diff --git a/crates/switchyard-translation/tests/request_translation.rs b/crates/switchyard-translation/tests/request_translation.rs index b7c4121a9..b6eb0bbe3 100644 --- a/crates/switchyard-translation/tests/request_translation.rs +++ b/crates/switchyard-translation/tests/request_translation.rs @@ -8,7 +8,7 @@ pub mod common; use pretty_assertions::assert_eq; use serde_json::{Value, json}; use switchyard_translation::{ - LossyConversionPolicy, TranslationEngine, TranslationPolicy, WireFormat, + LossyConversionPolicy, PreservationPolicy, TranslationEngine, TranslationPolicy, WireFormat, }; use common::{REASONING_MODEL, normalized_policy, shell_tool_call}; @@ -1010,6 +1010,85 @@ fn responses_reasoning_items_attach_to_tool_call_turn_for_openai_chat() -> TestR Ok(()) } +// Reasoning-required upstreams look for `reasoning_content` and treat it as +// present-or-absent, so replaying only `reasoning` fails the next turn. Both +// spellings carry the same text, and tool calls stay alongside them. +#[test] +fn openai_chat_replays_reasoning_under_both_spellings() -> TestResult { + let engine = TranslationEngine::default(); + let body = json!({ + "model": "deepseek-reasoner", + "messages": [ + {"role": "user", "content": "What is 2+2? Use the calculator."}, + { + "role": "assistant", + "content": "I'll call the tool.", + "reasoning_content": "The user wants 2+2. Call the calculator.", + "tool_calls": [{ + "id": "call_abc123", + "type": "function", + "function": {"name": "calculator", "arguments": "{\"a\": 2, \"b\": 2}"} + }] + }, + {"role": "tool", "tool_call_id": "call_abc123", "content": "4"} + ] + }); + + let output = engine + .translate_request( + WireFormat::OpenAiChat, + WireFormat::OpenAiChat, + &body, + &TranslationPolicy { + preservation: PreservationPolicy::Disabled, + ..TranslationPolicy::default() + }, + )? + .body; + + let assistant = &output["messages"][1]; + assert_eq!( + assistant["reasoning_content"], + "The user wants 2+2. Call the calculator." + ); + assert_eq!(assistant["reasoning"], assistant["reasoning_content"]); + assert_eq!(assistant["content"], "I'll call the tool."); + assert_eq!(assistant["tool_calls"][0]["id"], "call_abc123"); + assert_eq!(output["messages"][2]["role"], "tool"); + assert_eq!(output["messages"][2]["tool_call_id"], "call_abc123"); + Ok(()) +} + +// A turn carrying no reasoning must not gain either spelling. +#[test] +fn openai_chat_without_reasoning_sends_neither_spelling() -> TestResult { + let engine = TranslationEngine::default(); + let body = json!({ + "model": "deepseek-reasoner", + "messages": [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "hello"}, + {"role": "user", "content": "continue"} + ] + }); + + let output = engine + .translate_request( + WireFormat::OpenAiChat, + WireFormat::OpenAiChat, + &body, + &TranslationPolicy { + preservation: PreservationPolicy::Disabled, + ..TranslationPolicy::default() + }, + )? + .body; + + assert!(output["messages"][1].get("reasoning").is_none()); + assert!(output["messages"][1].get("reasoning_content").is_none()); + Ok(()) +} + // Verifies a reasoning item merges into the assistant message that follows it. #[test] fn responses_reasoning_item_merges_into_next_assistant_message_for_openai_chat() -> TestResult { @@ -1043,6 +1122,7 @@ fn responses_reasoning_item_merges_into_next_assistant_message_for_openai_chat() { "role": "assistant", "content": "Let me check.", + "reasoning_content": "Reading.", "reasoning": "Reading." } ]) @@ -1133,6 +1213,7 @@ fn openai_chat_encrypted_reasoning_details_retain_fallback() -> TestResult { assert_eq!(output["messages"][0]["reasoning_details"], details); assert_eq!(output["messages"][0]["reasoning"], "fallback text"); + assert_eq!(output["messages"][0]["reasoning_content"], "fallback text"); Ok(()) }