diff --git a/crates/switchyard-translation/src/codecs/responses/buffered.rs b/crates/switchyard-translation/src/codecs/responses/buffered.rs index 10fab3684..014368ad3 100644 --- a/crates/switchyard-translation/src/codecs/responses/buffered.rs +++ b/crates/switchyard-translation/src/codecs/responses/buffered.rs @@ -19,9 +19,9 @@ use crate::diagnostic::TranslationDiagnostic; use crate::error::{Result, TranslationError}; use crate::format::{FormatId, WireFormat}; use crate::llm::{ - AggLlmResponse, ContentBlock, InstructionBlock, LlmRequest, MediaSource, Message, OutputParams, - ProviderExtensions, ReasoningParams, ResponseOutput, Role, SamplingParams, StopReason, - ToolCall, ToolChoice, ToolDefinition, ToolResult, Usage, + AggLlmResponse, ContentBlock, ImageSource, InstructionBlock, LlmRequest, MediaSource, Message, + OutputParams, ProviderExtensions, ReasoningParams, ResponseOutput, Role, SamplingParams, + StopReason, ToolCall, ToolChoice, ToolDefinition, ToolResult, Usage, }; use crate::policy::{DeterministicIdPolicy, TranslationPolicy}; use crate::util::{ @@ -1137,6 +1137,53 @@ fn encode_responses_reasoning_input(text: &str, details: &[Value]) -> Option Option { + match source { + ImageSource::Url { url, .. } => Some(url.clone()), + ImageSource::Base64 { media_type, data } => Some(format!( + "data:{};base64,{}", + media_type.as_deref().unwrap_or("application/octet-stream"), + data + )), + ImageSource::Raw(raw) => { + let object = raw.as_object(); + let object = if object + .and_then(|object| object.get("type")) + .and_then(Value::as_str) + == Some("image") + { + object + .and_then(|object| object.get("source")) + .and_then(Value::as_object) + } else { + object + }; + let object = object?; + if let Some(url) = object.get("url").and_then(Value::as_str) { + return Some(url.to_string()); + } + if let Some(url) = object.get("image_url").and_then(Value::as_str) { + return Some(url.to_string()); + } + if let Some(url) = object + .get("image_url") + .and_then(Value::as_object) + .and_then(|image_url| image_url.get("url")) + .and_then(Value::as_str) + { + return Some(url.to_string()); + } + let data = object.get("data").and_then(Value::as_str)?; + let media_type = object + .get("media_type") + .and_then(Value::as_str) + .unwrap_or("application/octet-stream"); + Some(format!("data:{media_type};base64,{data}")) + } + } +} + // Maps normalized roles back to Responses role strings. fn role_to_responses(role: Role) -> &'static str { match role { @@ -1172,7 +1219,15 @@ fn encode_responses_content( blocks.push(json!({"type": "refusal", "refusal": text})); } ContentBlock::Image { source } => { - blocks.push(json!({"type": "input_image", "image_url": source})); + if let Some(image_url) = response_image_url(source) { + blocks.push(json!({"type": "input_image", "image_url": image_url})); + } else { + push_lossy( + diagnostics, + policy, + "Responses codec could not map image content", + )?; + } } ContentBlock::Audio { source } => blocks.push(match source { MediaSource::Raw(raw) => json!({"type": "input_text", "text": json_string(raw)}), diff --git a/crates/switchyard-translation/tests/request_translation.rs b/crates/switchyard-translation/tests/request_translation.rs index d7169c8a0..5c7eb7127 100644 --- a/crates/switchyard-translation/tests/request_translation.rs +++ b/crates/switchyard-translation/tests/request_translation.rs @@ -355,6 +355,89 @@ fn anthropic_unknown_content_does_not_leak_into_responses_request_blocks() -> Te Ok(()) } +// Verifies Anthropic base64 images become the scalar data URL Responses requires. +#[test] +fn anthropic_base64_image_becomes_responses_data_url() -> TestResult { + let engine = TranslationEngine::default(); + let body = json!({ + "model": "claude-sonnet-4-20250514", + "messages": [{ + "role": "user", + "content": [ + {"type": "text", "text": "Describe this image."}, + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "aGVsbG8=" + } + } + ] + }], + "max_tokens": 1024 + }); + + let output = engine + .translate_request( + WireFormat::AnthropicMessages, + WireFormat::OpenAiResponses, + &body, + &TranslationPolicy::default(), + )? + .body; + + assert_eq!( + output["input"][0]["content"][1], + json!({ + "type": "input_image", + "image_url": "data:image/png;base64,aGVsbG8=" + }) + ); + Ok(()) +} + +// Verifies Anthropic URL images become the scalar URL Responses requires. +#[test] +fn anthropic_url_image_becomes_responses_image_url() -> TestResult { + let engine = TranslationEngine::default(); + let body = json!({ + "model": "claude-sonnet-4-20250514", + "messages": [{ + "role": "user", + "content": [ + {"type": "text", "text": "Describe this image."}, + { + "type": "image", + "source": { + "type": "url", + "url": "https://example.test/image.png" + } + } + ] + }], + "max_tokens": 1024 + }); + + let output = engine + .translate_request( + WireFormat::AnthropicMessages, + WireFormat::OpenAiResponses, + &body, + &TranslationPolicy::default(), + )? + .body; + + assert_eq!( + output["input"][0]["content"][1], + json!({ + "type": "input_image", + "image_url": "https://example.test/image.png" + }) + ); + Ok(()) +} + // Verifies Anthropic mixed tool-result and text content splits into valid OpenAI messages. #[test] fn anthropic_tool_result_followup_text_splits_to_openai_messages() -> TestResult {