From 4af42218bcac3efe9b2120a0a944ac621f80d1fb Mon Sep 17 00:00:00 2001 From: Anthony Casagrande Date: Sat, 19 Sep 2026 17:55:06 -0700 Subject: [PATCH] fix(translation): enforce reasoning source boundaries Signed-off-by: Anthony Casagrande --- .../libsy-llm-client/tests/observability.rs | 1 + .../src/algorithms/advisor_gate/tests.rs | 1 + crates/libsy/src/algorithms/llm_class.rs | 2 + crates/libsy/src/algorithms/util/affinity.rs | 1 + crates/libsy/src/algorithms/util/llm_judge.rs | 1 + crates/protocol/src/llm.rs | 19 +- crates/protocol/src/stream.rs | 3 + crates/switchyard-server/src/capabilities.rs | 32 +- .../src/codecs/anthropic/buffered.rs | 196 +++++- .../src/codecs/common.rs | 23 + .../src/codecs/openai_chat/buffered.rs | 18 +- .../src/codecs/responses/buffered.rs | 155 ++++- .../tests/request_translation.rs | 650 +++++++++++++++++- .../tests/response_translation.rs | 33 +- 14 files changed, 1066 insertions(+), 69 deletions(-) diff --git a/crates/libsy-llm-client/tests/observability.rs b/crates/libsy-llm-client/tests/observability.rs index 8150564f9..90ae64186 100644 --- a/crates/libsy-llm-client/tests/observability.rs +++ b/crates/libsy-llm-client/tests/observability.rs @@ -725,6 +725,7 @@ async fn affinity_warns_once_when_request_has_no_usable_identity() -> switchyard text: "provider reasoning".to_string(), signature: None, details: Vec::new(), + provenance: None, }], }], ..LlmRequest::default() diff --git a/crates/libsy/src/algorithms/advisor_gate/tests.rs b/crates/libsy/src/algorithms/advisor_gate/tests.rs index 72d39615b..237d4fe94 100644 --- a/crates/libsy/src/algorithms/advisor_gate/tests.rs +++ b/crates/libsy/src/algorithms/advisor_gate/tests.rs @@ -127,6 +127,7 @@ fn reasoning_only_turn() -> Response { text: "thinking about it".to_string(), signature: None, details: Vec::new(), + provenance: None, }], url_citations: Vec::new(), stop_reason: None, diff --git a/crates/libsy/src/algorithms/llm_class.rs b/crates/libsy/src/algorithms/llm_class.rs index 12d9241d0..4dc591223 100644 --- a/crates/libsy/src/algorithms/llm_class.rs +++ b/crates/libsy/src/algorithms/llm_class.rs @@ -1576,6 +1576,7 @@ mod tests { text: "private chain of thought".to_string(), signature: None, details: Vec::new(), + provenance: None, }, ContentBlock::Text { text: "visible answer".to_string(), @@ -1590,6 +1591,7 @@ mod tests { text: "reasoning-only turn".to_string(), signature: None, details: Vec::new(), + provenance: None, }], }, Message::text(Role::User, "follow-up"), diff --git a/crates/libsy/src/algorithms/util/affinity.rs b/crates/libsy/src/algorithms/util/affinity.rs index 698f0a9dd..9fa9fab3b 100644 --- a/crates/libsy/src/algorithms/util/affinity.rs +++ b/crates/libsy/src/algorithms/util/affinity.rs @@ -598,6 +598,7 @@ mod tests { text: "Internal provider reasoning.".to_string(), signature: Some("provider-signature".to_string()), details: Vec::new(), + provenance: None, }, ], }); diff --git a/crates/libsy/src/algorithms/util/llm_judge.rs b/crates/libsy/src/algorithms/util/llm_judge.rs index 4798178e9..e435f91ad 100644 --- a/crates/libsy/src/algorithms/util/llm_judge.rs +++ b/crates/libsy/src/algorithms/util/llm_judge.rs @@ -477,6 +477,7 @@ mod tests { text: r#"{"ok":false}"#.to_string(), signature: None, details: Vec::new(), + provenance: None, }, ); } diff --git a/crates/protocol/src/llm.rs b/crates/protocol/src/llm.rs index 7968faa85..583e9f97b 100644 --- a/crates/protocol/src/llm.rs +++ b/crates/protocol/src/llm.rs @@ -8,7 +8,7 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; -use crate::format::FormatId; +use crate::format::{FormatId, WireFormat}; /// Actor role normalized across provider APIs. #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] @@ -91,6 +91,9 @@ pub enum ContentBlock { /// "reasoning.encrypted", "data": "..." }` object, replayed without modification. #[serde(default, skip_serializing_if = "Vec::is_empty")] details: Vec, + /// Wire format that a trusted codec decoded for private reasoning metadata. + #[serde(skip)] + provenance: Option, }, /// Image content. Image { @@ -270,8 +273,18 @@ pub struct OutputParams { pub struct ReasoningParams { /// Requested reasoning effort or level. pub effort: Option, - /// Provider reasoning controls without a normalized field. - pub raw: Option, + /// Raw reasoning controls keyed by the wire format that defined them. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub raw_by_format: BTreeMap, +} + +impl ReasoningParams { + /// Returns raw controls owned by one built-in wire format without allocating a lookup key. + pub fn raw_for(&self, format: WireFormat) -> Option<&Value> { + self.raw_by_format + .iter() + .find_map(|(source, value)| (source.as_str() == format.as_str()).then_some(value)) + } } /// Provider-specific fields that do not have first-class conversation fields. diff --git a/crates/protocol/src/stream.rs b/crates/protocol/src/stream.rs index c59a66f77..1e03a1fde 100644 --- a/crates/protocol/src/stream.rs +++ b/crates/protocol/src/stream.rs @@ -464,6 +464,7 @@ impl ResponseAccumulator { .into_iter() .filter(|detail| !is_reasoning_id_announcement(detail)) .collect(), + provenance: None, }); } if !self.text.is_empty() { @@ -685,6 +686,7 @@ mod tests { text: "think".to_string(), signature: None, details: Vec::new(), + provenance: None, }, ContentBlock::Text { text: "answer".to_string(), @@ -737,6 +739,7 @@ mod tests { text: "fallback reasoning".to_string(), signature: None, details: details.clone(), + provenance: None, }], url_citations: Vec::new(), stop_reason: Some(StopReason::EndTurn), diff --git a/crates/switchyard-server/src/capabilities.rs b/crates/switchyard-server/src/capabilities.rs index 6ac5379d5..189684346 100644 --- a/crates/switchyard-server/src/capabilities.rs +++ b/crates/switchyard-server/src/capabilities.rs @@ -18,9 +18,9 @@ pub(crate) fn unsupported_capability( && (request.reasoning.effort.is_some() || request .reasoning - .raw - .as_ref() - .is_some_and(|value| !value.is_null()) + .raw_by_format + .values() + .any(|value| !value.is_null()) || ["reasoning", "reasoning_effort", "thinking"] .iter() .any(|key| body.get(key).is_some_and(|value| !value.is_null())) @@ -131,8 +131,11 @@ fn unsupported_content( #[cfg(test)] mod tests { + use std::collections::BTreeMap; + use super::*; use serde_json::json; + use switchyard_protocol::ReasoningParams; use switchyard_translation::WireFormat::{AnthropicMessages, OpenAiChat, OpenAiResponses}; use switchyard_translation::{WireFormat, decode_request, encode_request}; @@ -231,6 +234,29 @@ mod tests { Ok(()) } + #[test] + fn rejects_source_qualified_reasoning_controls() { + let request = LlmRequest { + reasoning: ReasoningParams { + raw_by_format: BTreeMap::from([( + OpenAiResponses.into(), + json!({"summary": "auto"}), + )]), + ..Default::default() + }, + ..Default::default() + }; + let capabilities = ModelCapabilities { + reasoning: Some(false), + ..Default::default() + }; + + assert_eq!( + unsupported_capability(capabilities, &request, &json!({})), + Some("reasoning") + ); + } + // Decoded file-ID images and preserved computer screenshots both require vision. #[test] fn rejects_file_id_images_and_computer_screenshots() -> TestResult { diff --git a/crates/switchyard-translation/src/codecs/anthropic/buffered.rs b/crates/switchyard-translation/src/codecs/anthropic/buffered.rs index 67f03ebc4..1a51aee9b 100644 --- a/crates/switchyard-translation/src/codecs/anthropic/buffered.rs +++ b/crates/switchyard-translation/src/codecs/anthropic/buffered.rs @@ -3,10 +3,13 @@ //! Buffered codec for Anthropic Messages request and response JSON. +use std::collections::BTreeMap; + use serde_json::{Map, Value, json}; use crate::codecs::common::{ - ANTHROPIC_REQUEST_KEY, is_known_role_name, provider_extensions, text_from_blocks, + ANTHROPIC_REQUEST_KEY, decode_reasoning_effort, is_known_role_name, provider_extensions, + text_from_blocks, }; use crate::codecs::openai_chat::{decode_file_source, decode_image_source}; use crate::codecs::{ @@ -35,6 +38,9 @@ use base64::{Engine as _, engine::general_purpose::STANDARD}; /// Format codec for Anthropic Messages payloads. pub struct AnthropicMessagesCodec; +const ANTHROPIC_REDACTED_THINKING: &str = "anthropic.redacted_thinking"; +const ANTHROPIC_SIGNED_THINKING: &str = "anthropic.signed_thinking"; + impl FormatCodec for AnthropicMessagesCodec { fn format(&self) -> FormatId { WireFormat::AnthropicMessages.into() @@ -55,6 +61,13 @@ impl FormatCodec for AnthropicMessagesCodec { }) .transpose()?; let response_format = decode_anthropic_output_format(body, &mut diagnostics, policy)?; + let effort = decode_reasoning_effort( + body.get("output_config") + .and_then(Value::as_object) + .and_then(|output_config| output_config.get("effort")), + &mut diagnostics, + policy, + )?; let mut request = LlmRequest { model: body .get("model") @@ -71,13 +84,21 @@ impl FormatCodec for AnthropicMessagesCodec { top_k: body.get("top_k").and_then(Value::as_i64), }, reasoning: ReasoningParams { - effort: body - .get("output_config") - .and_then(Value::as_object) - .and_then(|object| object.get("effort")) - .and_then(Value::as_str) - .map(ToOwned::to_owned), - raw: body.get("thinking").cloned(), + effort, + raw_by_format: if body.get("thinking").is_some() + || body + .get("output_config") + .and_then(Value::as_object) + .and_then(|output_config| output_config.get("effort")) + .is_some() + { + BTreeMap::from([( + WireFormat::AnthropicMessages.into(), + body.get("thinking").cloned().unwrap_or(Value::Null), + )]) + } else { + BTreeMap::new() + }, }, stream: body.get("stream").and_then(Value::as_bool).unwrap_or(false), preservation: capture_request_preservation( @@ -308,8 +329,23 @@ impl FormatCodec for AnthropicMessagesCodec { if request.stream { body.insert("stream".to_string(), Value::Bool(true)); } - if let Some(effort) = &request.reasoning.effort { - body.insert("thinking".to_string(), json!({"type": "adaptive"})); + if let Some(thinking) = request.reasoning.raw_for(WireFormat::AnthropicMessages) + && !thinking.is_null() + { + body.insert("thinking".to_string(), thinking.clone()); + } + let native_effort = request + .reasoning + .raw_for(WireFormat::AnthropicMessages) + .is_some(); + let effort = match request.reasoning.effort.as_deref() { + Some(effort) if native_effort => Some(effort), + Some(effort) => foreign_anthropic_effort(effort, &mut diagnostics, policy)?, + None => None, + }; + if let Some(effort) = effort { + body.entry("thinking".to_string()) + .or_insert_with(|| json!({"type": "adaptive"})); body.insert("output_config".to_string(), json!({"effort": effort})); } if let Some(response_format) = &request.output.response_format @@ -447,6 +483,35 @@ impl FormatCodec for AnthropicMessagesCodec { } } +fn foreign_anthropic_effort( + effort: &str, + diagnostics: &mut Vec, + policy: &TranslationPolicy, +) -> Result> { + let effort = effort.trim(); + let mapped = if effort.eq_ignore_ascii_case("low") { + Some("low") + } else if effort.eq_ignore_ascii_case("medium") { + Some("medium") + } else if effort.eq_ignore_ascii_case("high") { + Some("high") + } else if effort.eq_ignore_ascii_case("xhigh") { + Some("xhigh") + } else if effort.eq_ignore_ascii_case("max") { + Some("max") + } else { + None + }; + if mapped.is_none() { + push_lossy( + diagnostics, + policy, + "Anthropic does not support the requested reasoning effort; reasoning effort was omitted", + )?; + } + Ok(mapped) +} + // Reads the current `output_config.format`, or the beta `output_format` it replaced, // into the neutral OpenAI-shaped response format. fn decode_anthropic_output_format( @@ -614,7 +679,7 @@ fn decode_anthropic_content( )?; continue; }; - content.extend(decode_anthropic_content_block( + content.extend(decode_anthropic_request_content_block( block, role, generated_counter + index, @@ -635,6 +700,47 @@ fn decode_anthropic_content( } } +fn decode_anthropic_request_content_block( + block: &Map, + role: Role, + generated_counter: usize, + diagnostics: &mut Vec, + policy: &TranslationPolicy, +) -> Result> { + match block.get("type").and_then(Value::as_str) { + Some("redacted_thinking") => Ok(vec![ContentBlock::Reasoning { + text: String::new(), + signature: None, + details: vec![json!({ + "type": ANTHROPIC_REDACTED_THINKING, + "data": block.get("data").cloned().unwrap_or(Value::Null), + })], + provenance: Some(WireFormat::AnthropicMessages.into()), + }]), + Some("thinking") => { + let mut content = decode_anthropic_content_block( + block, + role, + generated_counter, + diagnostics, + policy, + )?; + if let Some(ContentBlock::Reasoning { + signature: Some(_), + details, + provenance, + .. + }) = content.first_mut() + { + details.push(json!({"type": ANTHROPIC_SIGNED_THINKING})); + *provenance = Some(WireFormat::AnthropicMessages.into()); + } + Ok(content) + } + _ => decode_anthropic_content_block(block, role, generated_counter, diagnostics, policy), + } +} + // Decodes one Anthropic content block into one or more IR blocks. fn decode_anthropic_content_block( block: &Map, @@ -663,6 +769,7 @@ fn decode_anthropic_content_block( .filter(|signature| !signature.is_empty()) .map(ToOwned::to_owned), details: Vec::new(), + provenance: None, }], Some("tool_use") => vec![ContentBlock::ToolCall(ToolCall { id: block @@ -898,6 +1005,48 @@ fn encode_anthropic_content_with_policy( for block in content { crate::codecs::openai_media::validate_media(block, WireFormat::AnthropicMessages)?; match block { + ContentBlock::Reasoning { + signature: Some(_), + details, + provenance, + .. + } if is_anthropic_reasoning_provenance(provenance) + && has_anthropic_signed_thinking(details) => + { + blocks.extend(encode_one_anthropic_block(block)?); + } + ContentBlock::Reasoning { + signature: Some(_), .. + } => { + push_lossy( + diagnostics, + policy, + "Anthropic cannot replay unqualified signed reasoning; private reasoning was omitted", + )?; + } + ContentBlock::Reasoning { + details, + provenance, + .. + } if is_anthropic_reasoning_provenance(provenance) => { + if let Some(redacted) = anthropic_redacted_thinking_block(details) { + blocks.push(redacted); + } else { + blocks.extend(encode_one_anthropic_block(block)?); + } + } + ContentBlock::Reasoning { details, .. } + if anthropic_redacted_thinking_block(details).is_some() => + { + push_lossy( + diagnostics, + policy, + "Anthropic cannot replay unqualified redacted thinking; opaque reasoning was omitted", + )?; + } + ContentBlock::Reasoning { .. } => { + blocks.extend(encode_one_anthropic_block(block)?); + } ContentBlock::Unknown { provider, raw } => { reject_responses_builtin_tool_item(provider, raw, WireFormat::AnthropicMessages)?; push_lossy( @@ -916,6 +1065,31 @@ fn encode_anthropic_content_with_policy( Ok(blocks) } +fn anthropic_redacted_thinking_block(details: &[Value]) -> Option { + details.iter().find_map(|detail| { + (detail.get("type").and_then(Value::as_str) == Some(ANTHROPIC_REDACTED_THINKING)).then( + || { + json!({ + "type": "redacted_thinking", + "data": detail.get("data").cloned().unwrap_or(Value::Null), + }) + }, + ) + }) +} + +fn has_anthropic_signed_thinking(details: &[Value]) -> bool { + details + .iter() + .any(|detail| detail.get("type").and_then(Value::as_str) == Some(ANTHROPIC_SIGNED_THINKING)) +} + +fn is_anthropic_reasoning_provenance(provenance: &Option) -> bool { + provenance + .as_ref() + .is_some_and(|source| source.as_str() == WireFormat::AnthropicMessages.as_str()) +} + fn encode_anthropic_file(source: &FileSource) -> Result { match source { FileSource::FileData { data, filename } => { diff --git a/crates/switchyard-translation/src/codecs/common.rs b/crates/switchyard-translation/src/codecs/common.rs index e5b9b7dbe..e6b03626d 100644 --- a/crates/switchyard-translation/src/codecs/common.rs +++ b/crates/switchyard-translation/src/codecs/common.rs @@ -5,12 +5,35 @@ use serde_json::{Map, Value}; +use crate::diagnostic::TranslationDiagnostic; use crate::error::{Result, TranslationError}; use crate::llm::{ContentBlock, LlmRequest, ToolChoice, ToolDefinition}; +use crate::policy::TranslationPolicy; +use crate::util::push_lossy; // Internal provenance survives mutations that invalidate exact request replay. pub(crate) const ANTHROPIC_REQUEST_KEY: &str = "switchyard_anthropic_request"; +/// Decodes a provider reasoning effort without exposing malformed caller data in diagnostics. +pub(crate) fn decode_reasoning_effort( + value: Option<&Value>, + diagnostics: &mut Vec, + policy: &TranslationPolicy, +) -> Result> { + match value { + None => Ok(None), + Some(Value::String(effort)) => Ok(Some(effort.clone())), + Some(_) => { + push_lossy( + diagnostics, + policy, + "Reasoning effort must be a string; reasoning effort was omitted", + )?; + Ok(None) + } + } +} + /// Converts an OpenAI allowed-tools policy to a restricted function list and mode. pub(crate) fn allowed_function_tools( request: &LlmRequest, diff --git a/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs b/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs index 968b6f93b..cf0adad3c 100644 --- a/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs +++ b/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs @@ -6,8 +6,8 @@ use serde_json::{Map, Value, json}; use crate::codecs::common::{ - first_nonempty_string, is_known_role_name, provider_extensions, reasoning_text_from_blocks, - reasoning_text_from_details, text_from_blocks, + decode_reasoning_effort, first_nonempty_string, is_known_role_name, provider_extensions, + reasoning_text_from_blocks, reasoning_text_from_details, text_from_blocks, }; use crate::codecs::openai_media::{ ImagePayload, file_payload, file_source_text, image_payload, image_source_text, @@ -41,6 +41,8 @@ impl FormatCodec for OpenAiChatCodec { fn decode_request(&self, body: &Value, policy: &TranslationPolicy) -> Result { let body = object(body, "$")?; let mut diagnostics = Vec::new(); + let effort = + decode_reasoning_effort(body.get("reasoning_effort"), &mut diagnostics, policy)?; let mut request = LlmRequest { model: body .get("model") @@ -61,11 +63,8 @@ impl FormatCodec for OpenAiChatCodec { response_format: body.get("response_format").cloned(), }, reasoning: ReasoningParams { - effort: body - .get("reasoning_effort") - .and_then(Value::as_str) - .map(ToOwned::to_owned), - raw: None, + effort, + raw_by_format: Default::default(), }, preservation: capture_request_preservation( WireFormat::OpenAiChat, @@ -466,6 +465,7 @@ fn prepend_openai_reasoning_blocks(content: &mut Vec, object: &Map text, signature, details: details.clone(), + provenance: None, }, ); return; @@ -478,6 +478,7 @@ fn prepend_openai_reasoning_blocks(content: &mut Vec, object: &Map text: text.to_string(), signature: None, details: Vec::new(), + provenance: None, }, ); } @@ -492,6 +493,9 @@ fn reasoning_details_from_blocks(content: &[ContentBlock]) -> Vec { _ => None, }) .flatten() + .filter(|detail| { + detail.get("type").and_then(Value::as_str) != Some("anthropic.signed_thinking") + }) .cloned() .collect() } diff --git a/crates/switchyard-translation/src/codecs/responses/buffered.rs b/crates/switchyard-translation/src/codecs/responses/buffered.rs index 998505913..1329d8b62 100644 --- a/crates/switchyard-translation/src/codecs/responses/buffered.rs +++ b/crates/switchyard-translation/src/codecs/responses/buffered.rs @@ -3,13 +3,14 @@ //! Buffered codec for OpenAI Responses request and response JSON. -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use serde_json::{Map, Value, json}; use crate::codecs::common::{ - collect_responses_reasoning_text, encrypted_reasoning_data, encrypted_reasoning_item_id, - is_known_role_name, provider_extensions, reasoning_text_from_blocks, text_from_blocks, + collect_responses_reasoning_text, decode_reasoning_effort, encrypted_reasoning_data, + encrypted_reasoning_item_id, is_known_role_name, provider_extensions, + reasoning_text_from_blocks, text_from_blocks, }; use crate::codecs::openai_chat::{decode_file_source, decode_image_source}; use crate::codecs::openai_media::{ @@ -49,6 +50,14 @@ impl FormatCodec for OpenAiResponsesCodec { let sanitized = strip_codex_compaction_markers(body); let body = sanitized.as_ref().unwrap_or(body); let mut diagnostics = Vec::new(); + let effort_value = body.get("reasoning").and_then(|value| value.get("effort")); + let effort = decode_reasoning_effort(effort_value, &mut diagnostics, policy)?; + let mut raw_reasoning = body.get("reasoning").cloned(); + if effort_value.is_some_and(|value| !value.is_string()) + && let Some(object) = raw_reasoning.as_mut().and_then(Value::as_object_mut) + { + object.remove("effort"); + } let mut request = LlmRequest { model: body .get("model") @@ -60,13 +69,12 @@ impl FormatCodec for OpenAiResponsesCodec { response_format: decode_responses_text_format(body.get("text")), }, reasoning: ReasoningParams { - effort: body - .get("reasoning") - .and_then(Value::as_object) - .and_then(|object| object.get("effort")) - .and_then(Value::as_str) - .map(ToOwned::to_owned), - raw: body.get("reasoning").cloned(), + effort, + raw_by_format: raw_reasoning + .map(|reasoning| { + BTreeMap::from([(WireFormat::OpenAiResponses.into(), reasoning)]) + }) + .unwrap_or_default(), }, sampling: SamplingParams { temperature: body.get("temperature").and_then(Value::as_f64), @@ -256,14 +264,25 @@ impl FormatCodec for OpenAiResponsesCodec { json!({"format": encode_responses_text_format(response_format)}), ); } - let mut reasoning = request + let native_reasoning = request .reasoning - .raw - .as_ref() + .raw_for(WireFormat::OpenAiResponses) .and_then(Value::as_object) - .cloned() - .unwrap_or_default(); - if let Some(effort) = &request.reasoning.effort { + .cloned(); + let has_native_reasoning = native_reasoning.is_some(); + let mut reasoning = native_reasoning.unwrap_or_default(); + if has_native_reasoning { + match request.reasoning.effort.as_deref() { + Some(effort) => { + reasoning.insert("effort".to_string(), json!(effort)); + } + None => { + reasoning.remove("effort"); + } + } + } else if let Some(effort) = request.reasoning.effort.as_deref() + && let Some(effort) = foreign_responses_effort(effort, &mut diagnostics, _policy)? + { reasoning.insert("effort".to_string(), json!(effort)); } if !reasoning.is_empty() { @@ -438,6 +457,35 @@ impl FormatCodec for OpenAiResponsesCodec { } } +fn foreign_responses_effort( + effort: &str, + diagnostics: &mut Vec, + policy: &TranslationPolicy, +) -> Result> { + let effort = effort.trim(); + let mapped = if effort.eq_ignore_ascii_case("minimal") { + Some("minimal") + } else if effort.eq_ignore_ascii_case("low") { + Some("low") + } else if effort.eq_ignore_ascii_case("medium") { + Some("medium") + } else if effort.eq_ignore_ascii_case("high") { + Some("high") + } else if effort.eq_ignore_ascii_case("xhigh") || effort.eq_ignore_ascii_case("max") { + Some("xhigh") + } else { + None + }; + if mapped.is_none() { + push_lossy( + diagnostics, + policy, + "Responses does not support the requested reasoning effort; reasoning effort was omitted", + )?; + } + Ok(mapped) +} + /// Decodes Responses `input` into ordered normalized messages and inline /// instruction blocks. /// @@ -852,7 +900,8 @@ fn decode_responses_reasoning_item(item: &Map) -> Vec) -> Vec Vec { .to_string(), signature: None, details: Vec::new(), + provenance: None, }); } Some("input_image") => { @@ -1274,21 +1325,7 @@ fn encode_responses_input( encoded.push(raw.clone()); continue; } - // Anthropic-signed thinking cannot be sent as Responses input. - let content = message - .content - .iter() - .filter(|block| { - !matches!( - block, - ContentBlock::Reasoning { - signature: Some(_), - .. - } - ) - }) - .cloned() - .collect::>(); + let content = &message.content; if content.is_empty() { continue; } @@ -1299,7 +1336,7 @@ fn encode_responses_input( ) }) { let mut visible_content = Vec::new(); - for block in &content { + for block in content { if let Some(item) = encode_responses_special_input( block, namespaces, @@ -1337,7 +1374,7 @@ fn encode_responses_input( let mut visible_content = Vec::new(); let mut emitted_special = false; let mut omitted_reasoning = false; - for block in &content { + for block in content { if let Some(item) = encode_responses_special_input( block, namespaces, @@ -1437,11 +1474,36 @@ fn encode_responses_special_input( diagnostics: &mut Vec, policy: &TranslationPolicy, ) -> Result> { + if let ContentBlock::Reasoning { + signature, + details, + provenance, + .. + } = block + { + let message = match anthropic_reasoning_provenance(provenance, details) { + Some(AnthropicReasoningProvenance::Signed) => Some( + "Responses cannot replay Anthropic signed thinking; private reasoning was omitted", + ), + Some(AnthropicReasoningProvenance::Redacted) => Some( + "Responses cannot replay Anthropic redacted thinking; opaque reasoning was omitted", + ), + None if signature.is_some() => { + Some("Responses cannot replay signed reasoning; private reasoning was omitted") + } + None => None, + }; + if let Some(message) = message { + push_lossy(diagnostics, policy, message)?; + return Ok(None); + } + } Ok(match block { ContentBlock::Reasoning { text, signature: None, details, + .. } => encode_responses_reasoning_input(text, details), ContentBlock::ToolCall(call) if custom_tools.contains(&call.name) => { // A freeform tool call replays as `custom_tool_call` with its raw input. @@ -1502,6 +1564,30 @@ fn encode_responses_special_input( }) } +enum AnthropicReasoningProvenance { + Signed, + Redacted, +} + +fn anthropic_reasoning_provenance( + provenance: &Option, + details: &[Value], +) -> Option { + (provenance + .as_ref() + .is_some_and(|source| source.as_str() == WireFormat::AnthropicMessages.as_str())) + .then(|| { + details + .iter() + .find_map(|detail| match detail.get("type").and_then(Value::as_str) { + Some("anthropic.signed_thinking") => Some(AnthropicReasoningProvenance::Signed), + Some("anthropic.redacted_thinking") => Some(AnthropicReasoningProvenance::Redacted), + _ => None, + }) + }) + .flatten() +} + // Encodes reasoning in the shape accepted for Responses input history. Response // output items may carry `content`, but replayed input items must avoid it. fn encode_responses_reasoning_input(text: &str, details: &[Value]) -> Option { @@ -1895,6 +1981,7 @@ fn encode_responses_output(outputs: &[ResponseOutput]) -> Value { text, signature, details, + .. } = block { let encrypted = signature diff --git a/crates/switchyard-translation/tests/request_translation.rs b/crates/switchyard-translation/tests/request_translation.rs index 806e21412..5202aae28 100644 --- a/crates/switchyard-translation/tests/request_translation.rs +++ b/crates/switchyard-translation/tests/request_translation.rs @@ -410,6 +410,363 @@ fn responses_reasoning_survives_rebuild() -> TestResult { Ok(()) } +#[test] +fn anthropic_thinking_controls_are_source_qualified() -> TestResult { + let engine = TranslationEngine::default(); + let policy = normalized_policy(); + let body = json!({ + "model": "claude-sonnet", + "messages": [{"role": "user", "content": "Inspect."}], + "max_tokens": 128, + "thinking": {"type": "enabled", "budget_tokens": 4096}, + "output_config": {"effort": "high"} + }); + let decoded = engine.decode_request(WireFormat::AnthropicMessages, &body, &policy)?; + assert_eq!( + decoded + .request + .reasoning + .raw_for(WireFormat::AnthropicMessages), + Some(&json!({"type": "enabled", "budget_tokens": 4096})) + ); + assert!( + decoded + .request + .reasoning + .raw_for(WireFormat::OpenAiResponses) + .is_none() + ); + + let translated = engine.translate_request( + WireFormat::AnthropicMessages, + WireFormat::OpenAiResponses, + &body, + &policy, + )?; + assert_eq!(translated.body["reasoning"], json!({"effort": "high"})); + assert!(translated.body["reasoning"].get("type").is_none()); + assert!(translated.body["reasoning"].get("budget_tokens").is_none()); + Ok(()) +} + +#[test] +fn responses_reasoning_controls_do_not_leak_to_anthropic() -> TestResult { + let translated = TranslationEngine::default().translate_request( + WireFormat::OpenAiResponses, + WireFormat::AnthropicMessages, + &json!({ + "model": "gpt-5", + "input": "Inspect.", + "reasoning": { + "effort": "high", + "summary": "auto", + "future_responses_control": "responses-only" + } + }), + &normalized_policy(), + )?; + assert_eq!(translated.body["thinking"], json!({"type": "adaptive"})); + assert_eq!(translated.body["output_config"], json!({"effort": "high"})); + let serialized = translated.body.to_string(); + assert!(!serialized.contains("summary")); + assert!(!serialized.contains("future_responses_control")); + assert!(!serialized.contains("responses-only")); + Ok(()) +} + +#[test] +fn foreign_anthropic_effort_maps_only_to_safe_responses_values() -> TestResult { + let engine = TranslationEngine::default(); + for (source, expected) in [ + ("minimal", Some("minimal")), + (" low ", Some("low")), + ("MEDIUM", Some("medium")), + ("high", Some("high")), + ("xhigh", Some("xhigh")), + ("max", Some("xhigh")), + ( + "ultra-super-secret-value-that-must-not-appear-in-diagnostics", + None, + ), + (" ", None), + ] { + let translated = engine.translate_request( + WireFormat::AnthropicMessages, + WireFormat::OpenAiResponses, + &json!({ + "model": "claude-sonnet", + "messages": [{"role": "user", "content": "Inspect."}], + "max_tokens": 128, + "output_config": {"effort": source} + }), + &normalized_policy(), + )?; + assert_eq!( + translated + .body + .pointer("/reasoning/effort") + .and_then(Value::as_str), + expected, + "source effort {source:?}" + ); + assert!( + translated + .diagnostics + .iter() + .all(|diagnostic| { !diagnostic.message.contains("ultra-super-secret") }) + ); + assert_eq!( + translated + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "lossy_conversion"), + expected.is_none(), + "source effort {source:?}" + ); + } + Ok(()) +} + +#[test] +fn unsupported_anthropic_effort_obeys_reject_policy() { + let engine = TranslationEngine::default(); + let policy = TranslationPolicy { + lossy_conversion_policy: LossyConversionPolicy::Reject, + ..normalized_policy() + }; + let error = engine + .translate_request( + WireFormat::AnthropicMessages, + WireFormat::OpenAiResponses, + &json!({ + "model": "claude-sonnet", + "messages": [{"role": "user", "content": "Inspect."}], + "max_tokens": 128, + "output_config": {"effort": "ultra"} + }), + &policy, + ) + .expect_err("unsupported effort must honor reject policy"); + assert!(error.to_string().contains("reasoning effort")); +} + +#[test] +fn native_responses_effort_rebuild_is_not_cross_format_validated() -> TestResult { + let engine = TranslationEngine::default(); + for effort in ["none", "max", "minimal"] { + for lossy_conversion_policy in [ + LossyConversionPolicy::AllowWithDiagnostics, + LossyConversionPolicy::Reject, + ] { + let policy = TranslationPolicy { + preservation: switchyard_translation::PreservationPolicy::Disabled, + lossy_conversion_policy, + ..TranslationPolicy::default() + }; + let translated = engine.translate_request( + WireFormat::OpenAiResponses, + WireFormat::OpenAiResponses, + &json!({ + "model": "gpt-5", + "input": "Inspect.", + "reasoning": {"effort": effort, "summary": "auto"} + }), + &policy, + )?; + assert_eq!(translated.body["reasoning"]["effort"], effort); + assert_eq!(translated.body["reasoning"]["summary"], "auto"); + assert!(translated.diagnostics.is_empty()); + } + } + Ok(()) +} + +#[test] +fn normalized_effort_overlays_and_clears_native_responses_raw_base() -> TestResult { + let engine = TranslationEngine::default(); + let policy = normalized_policy(); + let body = json!({ + "model": "gpt-5", + "input": "Inspect.", + "reasoning": { + "effort": "high", + "summary": "auto", + "future_responses_control": true + } + }); + let mut request = engine + .decode_request(WireFormat::OpenAiResponses, &body, &policy)? + .request; + + request.reasoning.effort = Some("max".to_string()); + let mutated = engine.encode_request(WireFormat::OpenAiResponses, &request, &policy)?; + assert_eq!( + mutated.body["reasoning"], + json!({ + "effort": "max", + "summary": "auto", + "future_responses_control": true + }) + ); + + request.reasoning.effort = None; + let cleared = engine.encode_request(WireFormat::OpenAiResponses, &request, &policy)?; + assert_eq!( + cleared.body["reasoning"], + json!({"summary": "auto", "future_responses_control": true}) + ); + Ok(()) +} + +#[test] +fn responses_effort_maps_safely_to_anthropic() -> TestResult { + let engine = TranslationEngine::default(); + for (source, expected) in [ + ("low", Some("low")), + ("medium", Some("medium")), + ("high", Some("high")), + ("xhigh", Some("xhigh")), + ("max", Some("max")), + ("none", None), + ("minimal", None), + ] { + let translated = engine.translate_request( + WireFormat::OpenAiResponses, + WireFormat::AnthropicMessages, + &json!({"model": "gpt-5", "input": "Inspect.", "reasoning": {"effort": source}}), + &normalized_policy(), + )?; + assert_eq!( + translated + .body + .pointer("/output_config/effort") + .and_then(Value::as_str), + expected + ); + assert_eq!( + translated + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "lossy_conversion"), + expected.is_none() + ); + } + Ok(()) +} + +#[test] +fn chat_effort_uses_anthropic_target_validation() -> TestResult { + let engine = TranslationEngine::default(); + for (source, expected) in [ + ("high", Some("high")), + ("xhigh", Some("xhigh")), + ("none", None), + ("minimal", None), + ] { + let translated = engine.translate_request( + WireFormat::OpenAiChat, + WireFormat::AnthropicMessages, + &json!({ + "model": "gpt-5", + "messages": [{"role": "user", "content": "Inspect."}], + "reasoning_effort": source + }), + &normalized_policy(), + )?; + assert_eq!( + translated + .body + .pointer("/output_config/effort") + .and_then(Value::as_str), + expected + ); + } + Ok(()) +} + +#[test] +fn explicit_non_string_effort_warns_or_rejects_without_echoing_input() -> TestResult { + let engine = TranslationEngine::default(); + let body = json!({ + "model": "claude-sonnet", + "messages": [{"role": "user", "content": "Inspect."}], + "max_tokens": 128, + "output_config": {"effort": {"secret": "never-echo-this"}} + }); + let translated = engine.translate_request( + WireFormat::AnthropicMessages, + WireFormat::OpenAiResponses, + &body, + &normalized_policy(), + )?; + assert!(translated.body.get("reasoning").is_none()); + assert!(translated.diagnostics.iter().any(|diagnostic| { + diagnostic.message == "Reasoning effort must be a string; reasoning effort was omitted" + })); + assert!( + translated + .diagnostics + .iter() + .all(|diagnostic| !diagnostic.message.contains("never-echo-this")) + ); + + let native_responses = engine.decode_request( + WireFormat::OpenAiResponses, + &json!({ + "model": "gpt-5", + "input": "Inspect.", + "reasoning": {"effort": ["never-echo-this"], "summary": "auto"} + }), + &normalized_policy(), + )?; + assert!(native_responses.diagnostics.iter().any(|diagnostic| { + diagnostic.message == "Reasoning effort must be a string; reasoning effort was omitted" + })); + assert!( + native_responses + .diagnostics + .iter() + .all(|diagnostic| !diagnostic.message.contains("never-echo-this")) + ); + assert!( + native_responses + .request + .reasoning + .raw_for(WireFormat::OpenAiResponses) + .and_then(|value| value.get("effort")) + .is_none() + ); + + let reject = TranslationPolicy { + lossy_conversion_policy: LossyConversionPolicy::Reject, + ..normalized_policy() + }; + assert!( + engine + .translate_request( + WireFormat::AnthropicMessages, + WireFormat::OpenAiResponses, + &body, + &reject, + ) + .is_err() + ); + assert!( + engine + .decode_request( + WireFormat::OpenAiResponses, + &json!({ + "model": "gpt-5", + "input": "Inspect.", + "reasoning": {"effort": 7} + }), + &reject, + ) + .is_err() + ); + Ok(()) +} + #[test] fn anthropic_target_prompt_preserves_native_request_fields() -> TestResult { let engine = TranslationEngine::default(); @@ -871,6 +1228,273 @@ fn anthropic_thinking_blocks_do_not_leak_into_openai_chat_messages() -> TestResu Ok(()) } +#[test] +fn anthropic_redacted_thinking_is_private_metadata() -> TestResult { + let engine = TranslationEngine::default(); + let policy = normalized_policy(); + let body = json!({ + "model": "claude-sonnet", + "messages": [ + {"role": "user", "content": "Continue."}, + {"role": "assistant", "content": [ + {"type": "redacted_thinking", "data": "opaque-secret"}, + {"type": "text", "text": "Visible answer."} + ]} + ], + "max_tokens": 128 + }); + let mut decoded = engine.decode_request(WireFormat::AnthropicMessages, &body, &policy)?; + assert!(matches!( + &decoded.request.messages[1].content[0], + ContentBlock::Reasoning { + text, + signature: None, + details, + provenance: Some(source), + } + if text.is_empty() && details == &vec![json!({ + "type": "anthropic.redacted_thinking", + "data": "opaque-secret" + })] && source.as_str() == WireFormat::AnthropicMessages.as_str() + )); + decoded.request.preservation.requests.clear(); + let replayed = + engine.encode_request(WireFormat::AnthropicMessages, &decoded.request, &policy)?; + assert_eq!( + replayed.body["messages"][1]["content"][0], + json!({"type": "redacted_thinking", "data": "opaque-secret"}) + ); + Ok(()) +} + +#[test] +fn anthropic_redacted_thinking_never_becomes_responses_text() -> TestResult { + let engine = TranslationEngine::default(); + let body = json!({ + "model": "claude-sonnet", + "messages": [ + {"role": "user", "content": "Continue."}, + {"role": "assistant", "content": [ + {"type": "redacted_thinking", "data": "opaque-secret"}, + {"type": "text", "text": "Visible answer."} + ]} + ], + "max_tokens": 128 + }); + let translated = engine.translate_request( + WireFormat::AnthropicMessages, + WireFormat::OpenAiResponses, + &body, + &normalized_policy(), + )?; + assert!(!translated.body.to_string().contains("opaque-secret")); + assert!(translated.body.to_string().contains("Visible answer.")); + assert!(translated.diagnostics.iter().any(|diagnostic| { + diagnostic.code == "lossy_conversion" + && diagnostic.message.contains("redacted thinking") + && !diagnostic.message.contains("opaque-secret") + })); + + let reject = TranslationPolicy { + lossy_conversion_policy: LossyConversionPolicy::Reject, + ..normalized_policy() + }; + assert!( + engine + .translate_request( + WireFormat::AnthropicMessages, + WireFormat::OpenAiResponses, + &body, + &reject, + ) + .is_err() + ); + Ok(()) +} + +#[test] +fn anthropic_signed_thinking_carries_provider_provenance() -> TestResult { + let engine = TranslationEngine::default(); + let policy = normalized_policy(); + let mut decoded = engine.decode_request( + WireFormat::AnthropicMessages, + &json!({ + "model": "claude-sonnet", + "messages": [{"role": "assistant", "content": [{ + "type": "thinking", + "thinking": "private", + "signature": "opaque-signature-7f3a" + }]}], + "max_tokens": 128 + }), + &policy, + )?; + assert!(matches!( + &decoded.request.messages[0].content[0], + ContentBlock::Reasoning { + signature: Some(signature), + details, + provenance: Some(source), + .. + } + if signature == "opaque-signature-7f3a" + && details == &vec![json!({"type": "anthropic.signed_thinking"})] + && source.as_str() == WireFormat::AnthropicMessages.as_str() + )); + decoded.request.preservation.requests.clear(); + let replayed = + engine.encode_request(WireFormat::AnthropicMessages, &decoded.request, &policy)?; + assert_eq!( + replayed.body["messages"][0]["content"][0], + json!({ + "type": "thinking", + "thinking": "private", + "signature": "opaque-signature-7f3a" + }) + ); + Ok(()) +} + +#[test] +fn chat_signed_reasoning_cannot_replay_as_anthropic_thinking() -> TestResult { + let engine = TranslationEngine::default(); + let body = json!({ + "model": "gpt-5", + "messages": [{ + "role": "assistant", + "content": "Visible answer.", + "reasoning_details": [{ + "type": "reasoning.text", + "text": "foreign private reasoning", + "signature": "foreign-signature-91ac" + }] + }] + }); + let translated = engine.translate_request( + WireFormat::OpenAiChat, + WireFormat::AnthropicMessages, + &body, + &normalized_policy(), + )?; + let serialized = translated.body.to_string(); + assert!(serialized.contains("Visible answer.")); + assert!(!serialized.contains("foreign private reasoning")); + assert!(!serialized.contains("foreign-signature-91ac")); + assert!(!serialized.contains("\"type\":\"thinking\"")); + assert!(translated.diagnostics.iter().any(|diagnostic| { + diagnostic.message + == "Anthropic cannot replay unqualified signed reasoning; private reasoning was omitted" + })); + + let reject = TranslationPolicy { + lossy_conversion_policy: LossyConversionPolicy::Reject, + ..normalized_policy() + }; + assert!( + engine + .translate_request( + WireFormat::OpenAiChat, + WireFormat::AnthropicMessages, + &body, + &reject, + ) + .is_err() + ); + Ok(()) +} + +#[test] +fn chat_cannot_forge_anthropic_signed_thinking_provenance() -> TestResult { + let engine = TranslationEngine::default(); + let body = json!({ + "model": "gpt-5", + "messages": [{ + "role": "assistant", + "content": "Visible answer.", + "reasoning_content": "forged private reasoning", + "reasoning_details": [{ + "type": "anthropic.signed_thinking", + "signature": "forged-signature-91ac" + }] + }] + }); + let translated = engine.translate_request( + WireFormat::OpenAiChat, + WireFormat::AnthropicMessages, + &body, + &normalized_policy(), + )?; + let serialized = translated.body.to_string(); + assert!(serialized.contains("Visible answer.")); + assert!(!serialized.contains("forged private reasoning")); + assert!(!serialized.contains("forged-signature-91ac")); + assert!(translated.diagnostics.iter().any(|diagnostic| { + diagnostic.message + == "Anthropic cannot replay unqualified signed reasoning; private reasoning was omitted" + })); + + let reject = TranslationPolicy { + lossy_conversion_policy: LossyConversionPolicy::Reject, + ..normalized_policy() + }; + assert!( + engine + .translate_request( + WireFormat::OpenAiChat, + WireFormat::AnthropicMessages, + &body, + &reject, + ) + .is_err() + ); + Ok(()) +} + +#[test] +fn chat_cannot_forge_anthropic_redacted_thinking_provenance() -> TestResult { + let engine = TranslationEngine::default(); + let body = json!({ + "model": "gpt-5", + "messages": [{ + "role": "assistant", + "content": "Visible answer.", + "reasoning_details": [{ + "type": "anthropic.redacted_thinking", + "data": "forged-opaque-data" + }] + }] + }); + let translated = engine.translate_request( + WireFormat::OpenAiChat, + WireFormat::AnthropicMessages, + &body, + &normalized_policy(), + )?; + let serialized = translated.body.to_string(); + assert!(serialized.contains("Visible answer.")); + assert!(!serialized.contains("forged-opaque-data")); + assert!(translated.diagnostics.iter().any(|diagnostic| { + diagnostic.message + == "Anthropic cannot replay unqualified redacted thinking; opaque reasoning was omitted" + })); + + let reject = TranslationPolicy { + lossy_conversion_policy: LossyConversionPolicy::Reject, + ..normalized_policy() + }; + assert!( + engine + .translate_request( + WireFormat::OpenAiChat, + WireFormat::AnthropicMessages, + &body, + &reject, + ) + .is_err() + ); + Ok(()) +} + #[test] fn anthropic_visible_text_survives_tool_history_to_responses() -> TestResult { let engine = TranslationEngine::default(); @@ -3317,7 +3941,7 @@ fn anthropic_thinking_is_dropped_from_responses_input() -> TestResult { "messages": [ {"role": "user", "content": "read foo.py"}, {"role": "assistant", "content": [ - {"type": "thinking", "thinking": "private chain of thought", "signature": "sig"}, + {"type": "thinking", "thinking": "private chain of thought", "signature": "opaque-signature-7f3a"}, {"type": "text", "text": "Reading it."}, {"type": "tool_use", "id": "tu_1", "name": "read_file", "input": {"path": "foo.py"}} ]}, @@ -3327,21 +3951,27 @@ fn anthropic_thinking_is_dropped_from_responses_input() -> TestResult { ] }); - let output = engine - .translate_request( - WireFormat::AnthropicMessages, - WireFormat::OpenAiResponses, - &body, - &TranslationPolicy::default(), - )? - .body; + let translated = engine.translate_request( + WireFormat::AnthropicMessages, + WireFormat::OpenAiResponses, + &body, + &TranslationPolicy::default(), + )?; + let output = &translated.body; let input = output["input"] .as_array() .ok_or("Responses input should be an array")?; assert!(input.iter().all(|item| item["type"] != "reasoning")); - assert!(!json_contains_content_type(&output, "reasoning_text")); + assert!(!json_contains_content_type(output, "reasoning_text")); assert!(!output.to_string().contains("private chain of thought")); + assert!(translated.diagnostics.iter().any(|diagnostic| { + diagnostic.code == "lossy_conversion" + && diagnostic.message.contains("signed thinking") + && !diagnostic.message.contains("private chain of thought") + && !diagnostic.message.contains("opaque-signature-7f3a") + })); + assert!(!output.to_string().contains("opaque-signature-7f3a")); assert!(input.iter().any(|item| item["type"] == "function_call")); assert!( input diff --git a/crates/switchyard-translation/tests/response_translation.rs b/crates/switchyard-translation/tests/response_translation.rs index 4280fa3f1..e657deef3 100644 --- a/crates/switchyard-translation/tests/response_translation.rs +++ b/crates/switchyard-translation/tests/response_translation.rs @@ -8,7 +8,7 @@ pub mod common; use pretty_assertions::assert_eq; use serde_json::json; use switchyard_translation::{ - PreservationPolicy, TranslationEngine, TranslationPolicy, WireFormat, + ContentBlock, PreservationPolicy, TranslationEngine, TranslationPolicy, WireFormat, }; use common::{ @@ -629,6 +629,37 @@ fn anthropic_thinking_response_translates_to_openai_reasoning_content() -> TestR Ok(()) } +#[test] +fn anthropic_redacted_response_round_trips_without_becoming_unsigned_reasoning() -> TestResult { + let engine = TranslationEngine::default(); + let policy = normalized_policy(); + let body = json!({ + "id": "msg_redacted", + "type": "message", + "role": "assistant", + "model": "claude-sonnet", + "content": [ + {"type": "redacted_thinking", "data": "opaque-response-data"}, + {"type": "text", "text": "Visible answer."} + ], + "stop_reason": "end_turn", + "usage": {"input_tokens": 1, "output_tokens": 1} + }); + let decoded = engine.decode_response(WireFormat::AnthropicMessages, &body, &policy)?; + assert!(!matches!( + &decoded.response.outputs[0].content[0], + ContentBlock::Reasoning { + signature: None, + .. + } + )); + let replayed = + engine.encode_response(WireFormat::AnthropicMessages, &decoded.response, &policy)?; + assert_eq!(replayed.body["content"][0], body["content"][0]); + assert!(!replayed.body.to_string().contains("input_text")); + Ok(()) +} + // Verifies OpenAI reasoning_content becomes a separate Responses reasoning item. #[test] fn openai_reasoning_response_translates_to_responses_reasoning_item() -> TestResult {