diff --git a/crates/switchyard-translation/src/codecs/responses/buffered.rs b/crates/switchyard-translation/src/codecs/responses/buffered.rs index 998505913..7dded3af2 100644 --- a/crates/switchyard-translation/src/codecs/responses/buffered.rs +++ b/crates/switchyard-translation/src/codecs/responses/buffered.rs @@ -33,6 +33,9 @@ use crate::util::{ push_lossy, stable_id, string_value, validate_request_capabilities, }; +/// Reasoning controls the OpenAI Responses request body defines. +const RESPONSES_REASONING_KEYS: [&str; 3] = ["effort", "generate_summary", "summary"]; + /// Format codec for OpenAI Responses payloads. pub struct OpenAiResponsesCodec; @@ -256,13 +259,31 @@ impl FormatCodec for OpenAiResponsesCodec { json!({"format": encode_responses_text_format(response_format)}), ); } - let mut reasoning = request - .reasoning - .raw - .as_ref() - .and_then(Value::as_object) - .cloned() - .unwrap_or_default(); + let mut reasoning = Map::new(); + if let Some(raw) = request.reasoning.raw.as_ref().and_then(Value::as_object) { + // `raw` holds the source format's reasoning controls verbatim. Only the keys + // Responses itself defines can be replayed here. Carrying the rest forward + // publishes another provider's spelling under a Responses field name. + let mut dropped: Vec<&str> = Vec::new(); + for (key, value) in raw { + if RESPONSES_REASONING_KEYS.contains(&key.as_str()) { + reasoning.insert(key.clone(), value.clone()); + } else { + dropped.push(key.as_str()); + } + } + if !dropped.is_empty() { + dropped.sort_unstable(); + push_lossy( + &mut diagnostics, + _policy, + format!( + "reasoning controls have no OpenAI Responses representation and were dropped: {}", + dropped.join(", ") + ), + )?; + } + } if let Some(effort) = &request.reasoning.effort { reasoning.insert("effort".to_string(), json!(effort)); } diff --git a/crates/switchyard-translation/tests/request_translation.rs b/crates/switchyard-translation/tests/request_translation.rs index 806e21412..274aabe23 100644 --- a/crates/switchyard-translation/tests/request_translation.rs +++ b/crates/switchyard-translation/tests/request_translation.rs @@ -4048,3 +4048,86 @@ fn responses_stored_tool_outputs_stay_tool_results() -> TestResult { assert_eq!(output["input"], outputs); Ok(()) } + +// Verifies Anthropic thinking controls are not republished under the Responses +// `reasoning` field, and that dropping them is reported. +#[test] +fn anthropic_thinking_controls_are_not_emitted_as_responses_reasoning() -> TestResult { + let engine = TranslationEngine::default(); + let body = json!({ + "model": "claude-opus-5", + "max_tokens": 32000, + "messages": [{"role": "user", "content": "hi"}], + "thinking": {"type": "enabled", "budget_tokens": 10000} + }); + + let output = engine.translate_request( + WireFormat::AnthropicMessages, + WireFormat::OpenAiResponses, + &body, + &TranslationPolicy::default(), + )?; + + assert_eq!(output.body.get("reasoning"), None); + assert!(!output.body.to_string().contains("budget_tokens")); + assert_eq!(output.diagnostics.len(), 1); + assert_eq!(output.diagnostics[0].code, "lossy_conversion"); + assert!( + output.diagnostics[0].message.contains("budget_tokens") + && output.diagnostics[0].message.contains("type") + ); + Ok(()) +} + +// Verifies a caller asking to be told about information loss is told about +// reasoning controls the target cannot express. +#[test] +fn rejecting_lossy_conversion_rejects_untranslatable_reasoning_controls() -> TestResult { + let engine = TranslationEngine::default(); + let policy = TranslationPolicy { + lossy_conversion_policy: LossyConversionPolicy::Reject, + ..Default::default() + }; + let body = json!({ + "model": "claude-opus-5", + "max_tokens": 32000, + "messages": [{"role": "user", "content": "hi"}], + "thinking": {"type": "enabled", "budget_tokens": 10000} + }); + + let result = engine.translate_request( + WireFormat::AnthropicMessages, + WireFormat::OpenAiResponses, + &body, + &policy, + ); + + assert!(result.is_err()); + Ok(()) +} + +// Verifies Responses reasoning controls that the format does define still carry +// through a rebuild without a diagnostic. +#[test] +fn responses_reasoning_summary_still_carries_through_a_rebuild() -> TestResult { + let engine = TranslationEngine::default(); + let policy = TranslationPolicy::default(); + let body = json!({ + "model": "switchyard", + "input": "Fix the parser.", + "reasoning": {"effort": "high", "summary": "auto"} + }); + let mut request = engine + .decode_request(WireFormat::OpenAiResponses, &body, &policy)? + .request; + request.preservation.requests.clear(); + + let output = engine.encode_request(WireFormat::OpenAiResponses, &request, &policy)?; + + assert_eq!( + output.body["reasoning"], + json!({"effort": "high", "summary": "auto"}) + ); + assert!(output.diagnostics.is_empty()); + Ok(()) +}