diff --git a/crates/switchyard-translation/src/codecs/anthropic/buffered.rs b/crates/switchyard-translation/src/codecs/anthropic/buffered.rs index 87dc58b6c..e02d7ccae 100644 --- a/crates/switchyard-translation/src/codecs/anthropic/buffered.rs +++ b/crates/switchyard-translation/src/codecs/anthropic/buffered.rs @@ -908,9 +908,13 @@ fn encode_one_anthropic_block(block: &ContentBlock) -> Vec { })] } ContentBlock::Image { source } => vec![match source { - ImageSource::Url { url, .. } => { - json!({"type": "image", "source": {"type": "url", "url": url}}) - } + ImageSource::Url { url, .. } => match split_base64_data_uri(url) { + Some((media_type, data)) => json!({ + "type": "image", + "source": {"type": "base64", "media_type": media_type, "data": data}, + }), + None => json!({"type": "image", "source": {"type": "url", "url": url}}), + }, ImageSource::Base64 { media_type, data } => json!({ "type": "image", "source": { @@ -990,6 +994,22 @@ fn encode_one_anthropic_tool_result_block(block: &ContentBlock) -> Vec { } } +// Splits `data:[;...];base64,`, the form OpenAI-compatible +// clients use for inline images. A percent-encoded payload has to be decoded before it is +// base64, and a data URI with no media type leaves Anthropic's `media_type` with nothing to +// carry, so both keep the handling they had before and are relayed as-is. +fn split_base64_data_uri(url: &str) -> Option<(&str, &str)> { + let (metadata, data) = url.strip_prefix("data:")?.split_once(',')?; + let parameters = metadata.strip_suffix(";base64")?; + let media_type = parameters + .split_once(';') + .map_or(parameters, |(media_type, _)| media_type); + if media_type.is_empty() || data.contains('%') { + return None; + } + Some((media_type, data)) +} + // Anthropic requires `tool_use.input` to be object-shaped, while OpenAI and // Responses commonly carry function-call arguments as JSON strings. fn anthropic_tool_input(arguments: &Value) -> Value { diff --git a/crates/switchyard-translation/tests/request_translation.rs b/crates/switchyard-translation/tests/request_translation.rs index e689e5b21..98f750b11 100644 --- a/crates/switchyard-translation/tests/request_translation.rs +++ b/crates/switchyard-translation/tests/request_translation.rs @@ -1581,6 +1581,60 @@ fn anthropic_unmappable_output_format_is_rejected_under_strict_policy() -> TestR Ok(()) } +// Verifies inline `data:` images become Anthropic base64 sources, since Anthropic's URL +// source rejects anything that is not an http(s) link. A percent-encoded payload is not raw +// base64, so it keeps the URL source it had before rather than reaching Anthropic mislabelled. +#[test] +fn openai_data_uri_images_translate_to_anthropic_sources() -> TestResult { + let engine = TranslationEngine::default(); + let cases = [ + ( + "base64 data URI", + json!({"url": "data:image/png;base64,aW1hZ2U=", "detail": "high"}), + json!({"type": "base64", "media_type": "image/png", "data": "aW1hZ2U="}), + ), + ( + "data URI carrying extra parameters", + json!({"url": "data:image/jpeg;charset=binary;base64,aW1hZ2U="}), + json!({"type": "base64", "media_type": "image/jpeg", "data": "aW1hZ2U="}), + ), + ( + "percent-encoded data URI", + json!({"url": "data:image/png;base64,YQ%3D%3D"}), + json!({"type": "url", "url": "data:image/png;base64,YQ%3D%3D"}), + ), + ]; + + for (case, image_url, expected_source) in cases { + let body = json!({ + "model": "claude-sonnet-4-20250514", + "messages": [{ + "role": "user", + "content": [ + {"type": "text", "text": "What color is this?"}, + {"type": "image_url", "image_url": image_url} + ] + }] + }); + + let output = engine + .translate_request( + WireFormat::OpenAiChat, + WireFormat::AnthropicMessages, + &body, + &TranslationPolicy::default(), + )? + .body; + + assert_eq!( + output["messages"][0]["content"][1], + json!({"type": "image", "source": expected_source}), + "{case}" + ); + } + Ok(()) +} + // Verifies Anthropic receives its supported schema subset without mutating the neutral contract. #[test] fn openai_schema_constraints_are_removed_from_anthropic_output_format() -> TestResult {