Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions crates/switchyard-translation/src/codecs/anthropic/buffered.rs
Original file line number Diff line number Diff line change
Expand Up @@ -908,9 +908,13 @@ fn encode_one_anthropic_block(block: &ContentBlock) -> Vec<Value> {
})]
}
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": {
Expand Down Expand Up @@ -990,6 +994,22 @@ fn encode_one_anthropic_tool_result_block(block: &ContentBlock) -> Vec<Value> {
}
}

// Splits `data:<media type>[;<parameter>...];base64,<payload>`, 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 {
Expand Down
54 changes: 54 additions & 0 deletions crates/switchyard-translation/tests/request_translation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading