diff --git a/crates/switchyard-translation/src/codecs/anthropic/buffered.rs b/crates/switchyard-translation/src/codecs/anthropic/buffered.rs index 5217ce987..7d3709611 100644 --- a/crates/switchyard-translation/src/codecs/anthropic/buffered.rs +++ b/crates/switchyard-translation/src/codecs/anthropic/buffered.rs @@ -298,22 +298,43 @@ impl FormatCodec for AnthropicMessagesCodec { fn encode_response( &self, response: &AggLlmResponse, - _policy: &TranslationPolicy, + policy: &TranslationPolicy, ) -> Result { if let Some(body) = exact_preserved_response( &response.preservation, WireFormat::AnthropicMessages, - _policy, + policy, ) { return Ok(EncodedResponse { body, diagnostics: Vec::new(), }); } - let output = response.first_output(); - let content = output - .map(|output| encode_anthropic_content(&output.content)) - .unwrap_or_else(|| vec![json!({"type": "text", "text": ""})]); + let mut diagnostics = Vec::new(); + if response.outputs.len() > 1 { + push_lossy( + &mut diagnostics, + policy, + "Anthropic response encoding cannot represent multiple outputs", + )?; + } + let output = response + .outputs + .iter() + .find(|output| { + output.content.iter().any(|block| { + !matches!( + block, + ContentBlock::Unknown { provider, .. } + if provider.as_str() != WireFormat::AnthropicMessages.as_str() + ) + }) + }) + .or_else(|| response.first_output()); + let content = match output { + Some(output) => encode_anthropic_content(&output.content, &mut diagnostics, policy)?, + None => vec![json!({"type": "text", "text": ""})], + }; let body = json!({ "id": response.id.clone().unwrap_or_else(|| "msg_switchyard".to_string()), "type": "message", @@ -328,8 +349,8 @@ impl FormatCodec for AnthropicMessagesCodec { "usage": encode_anthropic_usage(&response.usage), }); Ok(EncodedResponse { - body: embed_preservation(body, &response.preservation, _policy), - diagnostics: Vec::new(), + body: embed_preservation(body, &response.preservation, policy), + diagnostics, }) } } @@ -684,16 +705,31 @@ fn encode_anthropic_content_with_policy( Ok(blocks) } -// Encodes content without producing diagnostics for response paths. -fn encode_anthropic_content(content: &[ContentBlock]) -> Vec { - let mut blocks = content - .iter() - .flat_map(encode_one_anthropic_response_block) - .collect::>(); +// Encodes response content without leaking foreign provider block shapes. +fn encode_anthropic_content( + content: &[ContentBlock], + diagnostics: &mut Vec, + policy: &TranslationPolicy, +) -> Result> { + let mut blocks = Vec::new(); + for block in content { + match block { + ContentBlock::Unknown { provider, .. } + if provider.as_str() != WireFormat::AnthropicMessages.as_str() => + { + push_lossy( + diagnostics, + policy, + "Anthropic response encoding drops foreign unknown content blocks", + )?; + } + other => blocks.extend(encode_one_anthropic_response_block(other)), + } + } if blocks.is_empty() { blocks.push(json!({"type": "text", "text": ""})); } - blocks + Ok(blocks) } // Encodes response content, where synthetic reasoning may be shown to clients. diff --git a/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs b/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs index dbe77ca2a..33902b0e4 100644 --- a/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs +++ b/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs @@ -266,43 +266,43 @@ impl FormatCodec for OpenAiChatCodec { _policy, ), }; - if let Some(choice) = object - .get("choices") - .and_then(Value::as_array) - .and_then(|choices| choices.first()) - .and_then(Value::as_object) - { - let message = choice - .get("message") - .and_then(Value::as_object) - .cloned() - .unwrap_or_default(); - let mut content = decode_openai_content( - message.get("content").unwrap_or(&Value::Null), - WireFormat::OpenAiChat, - &mut Vec::new(), - &TranslationPolicy::default(), - "$.choices[0].message.content", - )?; - prepend_openai_reasoning_blocks(&mut content, &message); - if let Some(tool_calls) = message.get("tool_calls").and_then(Value::as_array) { - for (index, tool_call) in tool_calls.iter().enumerate() { - if let Some(call) = decode_openai_tool_call( - tool_call, - index + 1, - &TranslationPolicy::default(), - )? { - content.push(ContentBlock::ToolCall(call)); + if let Some(choices) = object.get("choices").and_then(Value::as_array) { + for (choice_index, choice) in choices.iter().enumerate() { + let Some(choice) = choice.as_object() else { + continue; + }; + let message = choice + .get("message") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + let mut content = decode_openai_content( + message.get("content").unwrap_or(&Value::Null), + WireFormat::OpenAiChat, + &mut Vec::new(), + &TranslationPolicy::default(), + format!("$.choices[{choice_index}].message.content"), + )?; + prepend_openai_reasoning_blocks(&mut content, &message); + if let Some(tool_calls) = message.get("tool_calls").and_then(Value::as_array) { + for (index, tool_call) in tool_calls.iter().enumerate() { + if let Some(call) = decode_openai_tool_call( + tool_call, + index + 1, + &TranslationPolicy::default(), + )? { + content.push(ContentBlock::ToolCall(call)); + } } } + response.outputs.push(ResponseOutput { + role: Role::Assistant, + content, + stop_reason: Some(map_openai_finish_reason( + choice.get("finish_reason").and_then(Value::as_str), + )), + }); } - response.outputs.push(ResponseOutput { - role: Role::Assistant, - content, - stop_reason: Some(map_openai_finish_reason( - choice.get("finish_reason").and_then(Value::as_str), - )), - }); } Ok(DecodedResponse { @@ -314,17 +314,58 @@ impl FormatCodec for OpenAiChatCodec { fn encode_response( &self, response: &AggLlmResponse, - _policy: &TranslationPolicy, + policy: &TranslationPolicy, ) -> Result { if let Some(body) = - exact_preserved_response(&response.preservation, WireFormat::OpenAiChat, _policy) + exact_preserved_response(&response.preservation, WireFormat::OpenAiChat, policy) { return Ok(EncodedResponse { body, diagnostics: Vec::new(), }); } - let output = response.first_output(); + let mut diagnostics = Vec::new(); + if response.outputs.len() > 1 { + push_lossy( + &mut diagnostics, + policy, + "OpenAI Chat response encoding cannot represent multiple outputs", + )?; + } + let output = response + .outputs + .iter() + .find(|output| { + output.content.iter().any(|block| { + matches!( + block, + ContentBlock::Text { .. } + | ContentBlock::Refusal { .. } + | ContentBlock::Reasoning { .. } + | ContentBlock::ToolCall(_) + ) + }) + }) + .or_else(|| response.first_output()); + if output.is_some_and(|output| { + output.content.iter().any(|block| { + matches!( + block, + ContentBlock::Image { .. } + | ContentBlock::Audio { .. } + | ContentBlock::Video { .. } + | ContentBlock::File { .. } + | ContentBlock::ToolResult(_) + | ContentBlock::Unknown { .. } + ) + }) + }) { + push_lossy( + &mut diagnostics, + policy, + "OpenAI Chat response encoding drops unsupported content blocks", + )?; + } let content = output .map(|output| text_from_blocks(&output.content, "")) .unwrap_or_default(); @@ -381,8 +422,8 @@ impl FormatCodec for OpenAiChatCodec { "usage": encode_openai_usage(&response.usage), }); Ok(EncodedResponse { - body: embed_preservation(body, &response.preservation, _policy), - diagnostics: Vec::new(), + body: embed_preservation(body, &response.preservation, policy), + diagnostics, }) } } diff --git a/crates/switchyard-translation/src/codecs/responses/buffered.rs b/crates/switchyard-translation/src/codecs/responses/buffered.rs index b8aa9a4fd..5c461f1d1 100644 --- a/crates/switchyard-translation/src/codecs/responses/buffered.rs +++ b/crates/switchyard-translation/src/codecs/responses/buffered.rs @@ -232,16 +232,18 @@ impl FormatCodec for OpenAiResponsesCodec { fn encode_response( &self, response: &AggLlmResponse, - _policy: &TranslationPolicy, + policy: &TranslationPolicy, ) -> Result { if let Some(body) = - exact_preserved_response(&response.preservation, WireFormat::OpenAiResponses, _policy) + exact_preserved_response(&response.preservation, WireFormat::OpenAiResponses, policy) { return Ok(EncodedResponse { body, diagnostics: Vec::new(), }); } + let mut diagnostics = Vec::new(); + let output = encode_responses_output(&response.outputs, &mut diagnostics, policy)?; Ok(EncodedResponse { body: embed_preservation( json!({ @@ -250,16 +252,16 @@ impl FormatCodec for OpenAiResponsesCodec { "created_at": 0, "model": response.model.clone().unwrap_or_else(|| "unknown".to_string()), "status": "completed", - "output": encode_responses_output(&response.outputs), + "output": output, "usage": encode_responses_usage(&response.usage), "parallel_tool_calls": true, "tool_choice": "auto", "tools": [], }), &response.preservation, - _policy, + policy, ), - diagnostics: Vec::new(), + diagnostics, }) } } @@ -1063,13 +1065,50 @@ fn decode_responses_output_item( content: decode_responses_reasoning_item(item), stop_reason: None, })), - _ => Ok(None), + _ => Ok(Some(ResponseOutput { + role: Role::Assistant, + // Keep the unrepresentable item in the neutral response so a + // cross-format encoder can diagnose the loss. Exact same-format + // replay comes from response preservation: `ContentBlock::Unknown` + // cannot distinguish this top-level item from an unknown nested + // message-content block. + content: vec![ContentBlock::Unknown { + provider: WireFormat::OpenAiResponses.into(), + raw: Value::Object(item.clone()), + }], + stop_reason: None, + })), } } // Encodes normalized response outputs into Responses output items. -fn encode_responses_output(outputs: &[ResponseOutput]) -> Value { - Value::Array( +fn encode_responses_output( + outputs: &[ResponseOutput], + diagnostics: &mut Vec, + policy: &TranslationPolicy, +) -> Result { + if outputs + .iter() + .flat_map(|output| &output.content) + .any(|block| { + matches!( + block, + ContentBlock::Image { .. } + | ContentBlock::Audio { .. } + | ContentBlock::Video { .. } + | ContentBlock::File { .. } + | ContentBlock::ToolResult(_) + | ContentBlock::Unknown { .. } + ) + }) + { + push_lossy( + diagnostics, + policy, + "Responses response encoding drops unsupported content blocks", + )?; + } + Ok(Value::Array( outputs .iter() .flat_map(|output| { @@ -1112,7 +1151,7 @@ fn encode_responses_output(outputs: &[ResponseOutput]) -> Value { items }) .collect(), - ) + )) } // Encodes private reasoning as a separate Responses output item. diff --git a/crates/switchyard-translation/tests/response_translation.rs b/crates/switchyard-translation/tests/response_translation.rs index e963af555..d66b40675 100644 --- a/crates/switchyard-translation/tests/response_translation.rs +++ b/crates/switchyard-translation/tests/response_translation.rs @@ -5,10 +5,43 @@ use pretty_assertions::assert_eq; use serde_json::json; -use switchyard_translation::{TranslationEngine, TranslationPolicy, WireFormat}; +use switchyard_translation::{ + LossyConversionPolicy, PreservationPolicy, TranslationEngine, TranslationOutput, + TranslationPolicy, WireFormat, +}; type TestResult = std::result::Result<(), Box>; +fn reject_lossy_policy() -> TranslationPolicy { + TranslationPolicy { + lossy_conversion_policy: LossyConversionPolicy::Reject, + ..TranslationPolicy::default() + } +} + +fn translate_lossy_allowed_after_reject( + source: WireFormat, + target: WireFormat, + body: &serde_json::Value, +) -> std::result::Result> { + let engine = TranslationEngine::default(); + let mut policy = reject_lossy_policy(); + let error = engine + .translate_response(source, target, body, &policy) + .expect_err("lossy response translation should be rejected"); + assert_eq!(error.kind(), "LossyConversion"); + + policy.lossy_conversion_policy = LossyConversionPolicy::AllowWithDiagnostics; + let output = engine.translate_response(source, target, body, &policy)?; + assert!( + output + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "lossy_conversion") + ); + Ok(output) +} + // Verifies OpenAI Chat responses map to Anthropic message responses. #[test] fn openai_chat_response_translates_to_anthropic_message() -> TestResult { @@ -482,3 +515,159 @@ fn openai_chat_cache_only_usage_still_emits_reasoning_details() -> TestResult { ); Ok(()) } + +#[test] +fn strict_response_translation_allows_text_and_provider_extensions() -> TestResult { + let engine = TranslationEngine::default(); + let body = json!({ + "id": "msg_test", + "type": "message", + "role": "assistant", + "model": "claude-sonnet", + "content": [{"type": "text", "text": "Hello"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 1, "output_tokens": 1}, + "provider_trace_id": "trace_test" + }); + let output = engine.translate_response( + WireFormat::AnthropicMessages, + WireFormat::OpenAiChat, + &body, + &reject_lossy_policy(), + )?; + + assert_eq!(output.body["choices"][0]["message"]["content"], "Hello"); + assert!(output.diagnostics.is_empty()); + Ok(()) +} + +#[test] +fn foreign_server_tool_use_response_block_obeys_lossy_policy() -> TestResult { + let body = json!({ + "id": "msg_test", + "type": "message", + "role": "assistant", + "model": "claude-sonnet", + "content": [{ + "type": "server_tool_use", + "id": "srvtoolu_1", + "name": "web_search", + "input": {"query": "rust"} + }], + "stop_reason": "end_turn", + "usage": {"input_tokens": 1, "output_tokens": 1} + }); + for target in [WireFormat::OpenAiChat, WireFormat::OpenAiResponses] { + translate_lossy_allowed_after_reject(WireFormat::AnthropicMessages, target, &body)?; + } + Ok(()) +} + +#[test] +fn unknown_responses_output_preserves_the_known_answer() -> TestResult { + let engine = TranslationEngine::default(); + let body = json!({ + "id": "resp_test", + "object": "response", + "model": "gpt-4o", + "status": "completed", + "output": [{ + "type": "web_search_call", + "id": "ws_1", + "status": "completed" + }, { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "Known answer"}] + }], + "usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2} + }); + let same_format = engine.translate_response( + WireFormat::OpenAiResponses, + WireFormat::OpenAiResponses, + &body, + &reject_lossy_policy(), + )?; + assert_eq!(same_format.body, body); + assert!(same_format.diagnostics.is_empty()); + + let mut without_preservation = reject_lossy_policy(); + without_preservation.preservation = PreservationPolicy::Disabled; + let error = engine + .translate_response( + WireFormat::OpenAiResponses, + WireFormat::OpenAiResponses, + &body, + &without_preservation, + ) + .expect_err("unknown output cannot be reconstructed without preservation"); + assert_eq!(error.kind(), "LossyConversion"); + + let output = translate_lossy_allowed_after_reject( + WireFormat::OpenAiResponses, + WireFormat::AnthropicMessages, + &body, + )?; + assert_eq!( + output.body["content"], + json!([{"type": "text", "text": "Known answer"}]) + ); + Ok(()) +} + +#[test] +fn extra_response_outputs_obey_lossy_policy() -> TestResult { + let body = json!({ + "id": "resp_test", + "object": "response", + "model": "gpt-4o", + "status": "completed", + "output": [ + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "first"}] + }, + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "second"}] + } + ], + "usage": {"input_tokens": 1, "output_tokens": 2, "total_tokens": 3} + }); + let output = translate_lossy_allowed_after_reject( + WireFormat::OpenAiResponses, + WireFormat::OpenAiChat, + &body, + )?; + assert_eq!(output.body["choices"][0]["message"]["content"], "first"); + translate_lossy_allowed_after_reject( + WireFormat::OpenAiResponses, + WireFormat::AnthropicMessages, + &body, + )?; + + let chat_body = json!({ + "id": "chatcmpl-test", + "model": "gpt-4o", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "first"}, + "finish_reason": "stop" + }, + { + "index": 1, + "message": {"role": "assistant", "content": "second"}, + "finish_reason": "stop" + } + ] + }); + translate_lossy_allowed_after_reject( + WireFormat::OpenAiChat, + WireFormat::AnthropicMessages, + &chat_body, + )?; + Ok(()) +}