diff --git a/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs b/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs index 4b0394f43..abcca1012 100644 --- a/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs +++ b/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs @@ -887,9 +887,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. @@ -913,9 +911,16 @@ fn encode_openai_message_structured_reasoning( }) .collect::>() .join("\n"); - if !fallback.is_empty() { - message["reasoning"] = Value::String(fallback); + set_openai_reasoning_text(message, fallback); +} + +// Reasoning-required upstreams reject a replayed turn without `reasoning_content`, so both spellings are written. +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 9a1ef635f..9b270af1d 100644 --- a/crates/switchyard-translation/tests/request_translation.rs +++ b/crates/switchyard-translation/tests/request_translation.rs @@ -8,8 +8,8 @@ pub mod common; use pretty_assertions::assert_eq; use serde_json::{Value, json}; use switchyard_translation::{ - FormatId, LossyConversionPolicy, TranslationEngine, TranslationPolicy, WireFormat, - prepare_request_for_target, + FormatId, LossyConversionPolicy, PreservationPolicy, TranslationEngine, TranslationPolicy, + WireFormat, prepare_request_for_target, }; use common::{REASONING_MODEL, normalized_policy, shell_tool_call}; @@ -1212,6 +1212,58 @@ fn responses_reasoning_items_attach_to_tool_call_turn_for_openai_chat() -> TestR Ok(()) } +// Both spellings carry replayed reasoning, and a turn without reasoning gains neither. +#[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"}, + {"role": "assistant", "content": "It is 4."} + ] + }); + + let output = engine + .translate_request( + WireFormat::OpenAiChat, + WireFormat::OpenAiChat, + &body, + &TranslationPolicy { + preservation: PreservationPolicy::Disabled, + ..TranslationPolicy::default() + }, + )? + .body; + + let reasoned = &output["messages"][1]; + assert_eq!( + reasoned["reasoning_content"], + "The user wants 2+2. Call the calculator." + ); + assert_eq!(reasoned["reasoning"], reasoned["reasoning_content"]); + assert_eq!(reasoned["content"], "I'll call the tool."); + assert_eq!(reasoned["tool_calls"][0]["id"], "call_abc123"); + assert_eq!(output["messages"][2]["role"], "tool"); + assert_eq!(output["messages"][2]["tool_call_id"], "call_abc123"); + + let without_reasoning = &output["messages"][3]; + assert!(without_reasoning.get("reasoning").is_none()); + assert!(without_reasoning.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 { @@ -1245,6 +1297,7 @@ fn responses_reasoning_item_merges_into_next_assistant_message_for_openai_chat() { "role": "assistant", "content": "Let me check.", + "reasoning_content": "Reading.", "reasoning": "Reading." } ]) @@ -1335,6 +1388,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(()) }