From 9faa2428153cd76f50cd227d09ad4a900c903b33 Mon Sep 17 00:00:00 2001 From: nachiketb Date: Tue, 28 Jul 2026 15:50:16 -0700 Subject: [PATCH 1/4] refactor(translation): own Anthropic request normalization Signed-off-by: nachiketb --- .../src/backends/anthropic.rs | 243 ++---------------- .../src/codecs/anthropic/buffered.rs | 173 ++++++++++++- .../tests/request_translation.rs | 74 ++++++ 3 files changed, 266 insertions(+), 224 deletions(-) diff --git a/crates/switchyard-components/src/backends/anthropic.rs b/crates/switchyard-components/src/backends/anthropic.rs index bd4e7f0a..b90fc964 100644 --- a/crates/switchyard-components/src/backends/anthropic.rs +++ b/crates/switchyard-components/src/backends/anthropic.rs @@ -8,18 +8,17 @@ use std::env; use std::fmt; use std::sync::Arc; +use async_stream::try_stream; +use async_trait::async_trait; +use futures_util::StreamExt; +use serde_json::Value; +use switchyard_translation::{TranslationEngine, TranslationPolicy, WireFormat}; + use crate::{ merge_target_extra_body, BackendFormat, BoxResponseStream, ChatRequest, ChatRequestType, ChatResponse, LlmBackend, LlmTarget, LlmTargetId, ProxyContext, Result, StreamEvent, SwitchyardError, }; -use async_stream::try_stream; -use async_trait::async_trait; -use futures_util::StreamExt; -use serde_json::{json, Map, Value}; -use switchyard_translation::{ - normalize_anthropic_tool_use_ids, TranslationEngine, TranslationPolicy, WireFormat, -}; use super::common::{ build_reqwest_client, decode_sse_frame, drain_next_sse_frame, has_non_whitespace_bytes, @@ -40,7 +39,7 @@ pub struct AnthropicNativeBackend { target: LlmTarget, /// HTTP transport, injectable for deterministic tests. transport: Arc, - /// Shared request translator for non-Anthropic inbound payloads. + /// Shared request translator and Anthropic outbound normalizer. translation: Arc, /// Translation policy kept explicit so backend behavior is inspectable. translation_policy: TranslationPolicy, @@ -80,27 +79,22 @@ impl AnthropicNativeBackend { } fn outbound_body(&self, request: &ChatRequest) -> Result { - let mut body = match request.request_type() { - ChatRequestType::Anthropic => request.body().clone(), - source => { - self.translation - .translate_request( - request_wire_format(source), - WireFormat::AnthropicMessages, - request.body(), - &self.translation_policy, - ) - .map_err(|error| { - SwitchyardError::Backend(format!( - "failed to translate {source:?} request to Anthropic Messages: {error}" - )) - })? - .body - } - }; + let source = request.request_type(); + let mut body = self + .translation + .translate_request( + request_wire_format(source), + WireFormat::AnthropicMessages, + request.body(), + &self.translation_policy, + ) + .map_err(|error| { + SwitchyardError::Backend(format!( + "failed to translate {source:?} request to Anthropic Messages: {error}" + )) + })? + .body; set_json_model(&mut body, self.target.model.as_str()); - strip_anthropic_incompatible_fields(&mut body); - normalize_anthropic_body(&mut body); // Per-target ``extra_body`` merged last; caller wins on key // conflicts (see :func:`merge_target_extra_body`). merge_target_extra_body(&mut body, self.target.extra_body.as_ref()); @@ -282,199 +276,6 @@ fn validate_target_format(target: &LlmTarget) -> Result<()> { } } -// Drop fields accepted by OpenAI-like APIs but rejected by Anthropic Messages. -fn strip_anthropic_incompatible_fields(body: &mut Value) { - if let Value::Object(object) = body { - object.remove("reasoning_effort"); - object.remove("context_management"); - } -} - -// Normalize translated Anthropic payloads before applying target overrides. -fn normalize_anthropic_body(body: &mut Value) { - let Value::Object(object) = body else { - return; - }; - if let Some(messages) = object.remove("messages") { - // AWS documents message-level `role: "system"` as an Opus 4.8-only - // Anthropic dialect: - // https://docs.aws.amazon.com/bedrock/latest/userguide/claude-messages-mid-conversation-system.html - // - // Keep the default conservative for older Bedrock/LiteLLM targets by - // lifting those turns into top-level `system`. - // TODO: add target-level Anthropic dialect support so - // Opus 4.8 can opt into mid-conversation system messages explicitly. - let (messages, system_text) = lift_message_level_system(messages); - append_lifted_system_text(object, system_text); - let messages = normalize_anthropic_tool_use_ids(messages); - object.insert( - "messages".to_string(), - strip_unsigned_thinking_blocks(messages), - ); - } -} - -// Moves Anthropic Opus-4.8-style message-level system turns out of the -// conversation so legacy Anthropic-compatible backends do not reject them. -fn lift_message_level_system(messages: Value) -> (Value, Vec) { - let Value::Array(messages) = messages else { - return (messages, Vec::new()); - }; - - let mut kept_messages = Vec::with_capacity(messages.len()); - let mut system_text = Vec::new(); - for message in messages { - if is_message_level_system(&message) { - if let Some(text) = system_text_from_message(&message) { - system_text.push(text); - } - } else { - kept_messages.push(message); - } - } - - (Value::Array(kept_messages), system_text) -} - -// Treats `system` and OpenAI/Codex `developer` roles as instruction-like -// turns. `developer` is not an Anthropic Opus 4.8 role; lifting it matches the -// existing OpenAI-to-Anthropic translator behavior and prevents malformed -// Anthropic-bound traffic from leaking an invalid role upstream. -fn is_message_level_system(message: &Value) -> bool { - matches!( - message.get("role").and_then(Value::as_str), - Some("system") | Some("developer") - ) -} - -// Extracts text from an invalid message-level system/developer turn. -fn system_text_from_message(message: &Value) -> Option { - message.get("content").and_then(system_text_from_content) -} - -// Converts Anthropic text-ish content into top-level system text. -fn system_text_from_content(content: &Value) -> Option { - match content { - Value::String(text) if !text.is_empty() => Some(text.clone()), - Value::String(_) | Value::Null => None, - Value::Array(blocks) => { - let parts = blocks - .iter() - .filter_map(system_text_from_content_block) - .collect::>(); - (!parts.is_empty()).then(|| parts.join("\n\n")) - } - other => Some(other.to_string()), - } -} - -// Extracts the supported text shape from one structured content block. -fn system_text_from_content_block(block: &Value) -> Option { - match block { - Value::String(text) if !text.is_empty() => Some(text.clone()), - Value::Object(object) => match object.get("type").and_then(Value::as_str) { - Some("text") | Some("input_text") => object - .get("text") - .and_then(Value::as_str) - .filter(|text| !text.is_empty()) - .map(ToOwned::to_owned), - // Message-level system/developer turns are downgraded for legacy Anthropic - // compatibility. Only text-like instruction content is replayed; images and - // other non-text blocks are intentionally not promoted into top-level system. - _ => None, - }, - _ => None, - } -} - -// Appends lifted message-level system text onto any existing Anthropic system field. -fn append_lifted_system_text(object: &mut Map, system_text: Vec) { - if system_text.is_empty() { - return; - } - - let joined = system_text.join("\n\n"); - match object.remove("system") { - None | Some(Value::Null) => { - object.insert("system".to_string(), Value::String(joined)); - } - Some(Value::String(existing)) if existing.is_empty() => { - object.insert("system".to_string(), Value::String(joined)); - } - Some(Value::String(existing)) => { - object.insert( - "system".to_string(), - Value::String(format!("{existing}\n\n{joined}")), - ); - } - Some(Value::Array(mut blocks)) => { - blocks.extend( - system_text - .into_iter() - .map(|text| json!({"type": "text", "text": text})), - ); - object.insert("system".to_string(), Value::Array(blocks)); - } - Some(other) => { - object.insert( - "system".to_string(), - Value::String(format!("{other}\n\n{joined}")), - ); - } - } -} - -// Anthropic requires signed thinking blocks on replay; remove unsigned blocks -// so passthrough and translated requests remain accepted by the API. -fn strip_unsigned_thinking_blocks(messages: Value) -> Value { - match messages { - Value::Array(messages) => Value::Array( - messages - .into_iter() - .map(strip_unsigned_thinking_from_message) - .collect(), - ), - other => other, - } -} - -fn strip_unsigned_thinking_from_message(message: Value) -> Value { - match message { - Value::Object(mut message) => { - let Some(content) = message.remove("content") else { - return Value::Object(message); - }; - let Value::Array(blocks) = content else { - message.insert("content".to_string(), content); - return Value::Object(message); - }; - - let kept = blocks - .into_iter() - .filter(|block| !is_unsigned_thinking_block(block)) - .collect::>(); - let content = if kept.is_empty() { - Value::String(String::new()) - } else { - Value::Array(kept) - }; - message.insert("content".to_string(), content); - Value::Object(message) - } - other => other, - } -} - -fn is_unsigned_thinking_block(block: &Value) -> bool { - if block.get("type").and_then(Value::as_str) != Some("thinking") { - return false; - } - !matches!( - block.get("signature").and_then(Value::as_str), - Some(signature) if !signature.is_empty() - ) -} - fn messages_url(base_url: Option<&str>) -> String { let base_url = base_url .unwrap_or(DEFAULT_ANTHROPIC_BASE_URL) diff --git a/crates/switchyard-translation/src/codecs/anthropic/buffered.rs b/crates/switchyard-translation/src/codecs/anthropic/buffered.rs index e2deee86..b9bad418 100644 --- a/crates/switchyard-translation/src/codecs/anthropic/buffered.rs +++ b/crates/switchyard-translation/src/codecs/anthropic/buffered.rs @@ -19,7 +19,6 @@ use crate::llm::{ SamplingParams, StopReason, ToolCall, ToolChoice, ToolDefinition, ToolResult, Usage, }; use crate::policy::{DeterministicIdPolicy, TranslationPolicy}; -use crate::util::sanitize_anthropic_tool_use_id; use crate::util::{ capture_request_preservation, capture_response_preservation, embed_preservation, exact_preserved_request, exact_preserved_response, @@ -27,6 +26,7 @@ use crate::util::{ use crate::util::{ json_string, push_lossy, stable_id, string_value, validate_request_capabilities, }; +use crate::util::{normalize_anthropic_tool_use_ids, sanitize_anthropic_tool_use_id}; /// Format codec for Anthropic Messages payloads. pub struct AnthropicMessagesCodec; @@ -154,7 +154,7 @@ impl FormatCodec for AnthropicMessagesCodec { exact_preserved_request(&request.preservation, WireFormat::AnthropicMessages, policy) { return Ok(EncodedRequest { - body, + body: normalize_outbound_request(body), diagnostics: Vec::new(), }); } @@ -223,7 +223,11 @@ impl FormatCodec for AnthropicMessagesCodec { body.insert("output_config".to_string(), json!({"effort": effort})); } - let body = embed_preservation(Value::Object(body), &request.preservation, policy); + let body = normalize_outbound_request(embed_preservation( + Value::Object(body), + &request.preservation, + policy, + )); Ok(EncodedRequest { body, diagnostics }) } @@ -334,6 +338,169 @@ impl FormatCodec for AnthropicMessagesCodec { } } +// Makes both translated and exactly preserved requests valid for conservative +// Anthropic Messages endpoints without dropping unknown provider extensions. +fn normalize_outbound_request(mut body: Value) -> Value { + let Value::Object(object) = &mut body else { + return body; + }; + object.remove("reasoning_effort"); + object.remove("context_management"); + + if let Some(messages) = object.remove("messages") { + let (messages, system_text) = lift_message_level_system(messages); + append_lifted_system_text(object, system_text); + let messages = normalize_anthropic_tool_use_ids(messages); + object.insert( + "messages".to_string(), + strip_unsigned_thinking_blocks(messages), + ); + } + body +} + +// Moves message-level system and developer turns into Anthropic's top-level +// system field for compatibility with endpoints that only accept user/assistant. +fn lift_message_level_system(messages: Value) -> (Value, Vec) { + let Value::Array(messages) = messages else { + return (messages, Vec::new()); + }; + + let mut kept_messages = Vec::with_capacity(messages.len()); + let mut system_text = Vec::new(); + for message in messages { + if matches!( + message.get("role").and_then(Value::as_str), + Some("system") | Some("developer") + ) { + if let Some(text) = message.get("content").and_then(system_text_from_content) { + system_text.push(text); + } + } else { + kept_messages.push(message); + } + } + (Value::Array(kept_messages), system_text) +} + +// Converts text-like message content into top-level system text. +fn system_text_from_content(content: &Value) -> Option { + match content { + Value::String(text) if !text.is_empty() => Some(text.clone()), + Value::String(_) | Value::Null => None, + Value::Array(blocks) => { + let parts = blocks + .iter() + .filter_map(system_text_from_content_block) + .collect::>(); + (!parts.is_empty()).then(|| parts.join("\n\n")) + } + other => Some(other.to_string()), + } +} + +fn system_text_from_content_block(block: &Value) -> Option { + match block { + Value::String(text) if !text.is_empty() => Some(text.clone()), + Value::Object(object) + if matches!( + object.get("type").and_then(Value::as_str), + Some("text") | Some("input_text") + ) => + { + object + .get("text") + .and_then(Value::as_str) + .filter(|text| !text.is_empty()) + .map(ToOwned::to_owned) + } + _ => None, + } +} + +// Appends lifted text without changing an existing structured system prompt. +fn append_lifted_system_text(object: &mut Map, system_text: Vec) { + if system_text.is_empty() { + return; + } + + let joined = system_text.join("\n\n"); + match object.remove("system") { + None | Some(Value::Null) => { + object.insert("system".to_string(), Value::String(joined)); + } + Some(Value::String(existing)) if existing.is_empty() => { + object.insert("system".to_string(), Value::String(joined)); + } + Some(Value::String(existing)) => { + object.insert( + "system".to_string(), + Value::String(format!("{existing}\n\n{joined}")), + ); + } + Some(Value::Array(mut blocks)) => { + blocks.extend( + system_text + .into_iter() + .map(|text| json!({"type": "text", "text": text})), + ); + object.insert("system".to_string(), Value::Array(blocks)); + } + Some(other) => { + object.insert( + "system".to_string(), + Value::String(format!("{other}\n\n{joined}")), + ); + } + } +} + +// Anthropic requires signatures when assistant thinking blocks are replayed. +fn strip_unsigned_thinking_blocks(messages: Value) -> Value { + match messages { + Value::Array(messages) => Value::Array( + messages + .into_iter() + .map(strip_unsigned_thinking_from_message) + .collect(), + ), + other => other, + } +} + +fn strip_unsigned_thinking_from_message(message: Value) -> Value { + let Value::Object(mut message) = message else { + return message; + }; + let Some(content) = message.remove("content") else { + return Value::Object(message); + }; + let Value::Array(blocks) = content else { + message.insert("content".to_string(), content); + return Value::Object(message); + }; + + let kept = blocks + .into_iter() + .filter(|block| !is_unsigned_thinking_block(block)) + .collect::>(); + let content = if kept.is_empty() { + Value::String(String::new()) + } else { + Value::Array(kept) + }; + message.insert("content".to_string(), content); + Value::Object(message) +} + +fn is_unsigned_thinking_block(block: &Value) -> bool { + block.get("type").and_then(Value::as_str) == Some("thinking") + && !matches!( + block.get("signature").and_then(Value::as_str), + Some(signature) if !signature.is_empty() + ) +} + // Decodes Anthropic's `system` field into instruction blocks. fn decode_anthropic_system( value: &Value, diff --git a/crates/switchyard-translation/tests/request_translation.rs b/crates/switchyard-translation/tests/request_translation.rs index 6b4aec56..e53e4376 100644 --- a/crates/switchyard-translation/tests/request_translation.rs +++ b/crates/switchyard-translation/tests/request_translation.rs @@ -9,6 +9,80 @@ use switchyard_translation::{TranslationEngine, TranslationPolicy, WireFormat}; type TestResult = std::result::Result<(), Box>; +// Same-format preservation keeps provider extensions while enforcing Anthropic's wire contract. +#[test] +fn anthropic_same_format_requests_are_normalized_before_replay() -> TestResult { + let body = json!({ + "model": "claude", + "system": "Top-level rules.", + "reasoning_effort": "high", + "context_management": {"edits": []}, + "vendor_option": {"preserve": true}, + "messages": [ + { + "role": "system", + "content": [ + {"type": "text", "text": "System rules."}, + {"type": "image", "source": {"type": "url", "url": "https://example.test/a.png"}} + ] + }, + {"role": "developer", "content": "Developer rules."}, + {"role": "user", "content": "Use the tool."}, + { + "role": "assistant", + "content": [{ + "type": "tool_use", + "id": "toolu_01*bad:id", + "name": "lookup", + "input": {} + }] + }, + { + "role": "user", + "content": [{ + "type": "tool_result", + "tool_use_id": "toolu_01*bad:id", + "content": "done" + }] + }, + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "synthetic", "signature": ""}, + {"type": "thinking", "thinking": "real", "signature": "signed"}, + {"type": "text", "text": "visible"} + ] + } + ] + }); + + let output = TranslationEngine::default() + .translate_request( + WireFormat::AnthropicMessages, + WireFormat::AnthropicMessages, + &body, + &TranslationPolicy::default(), + )? + .body; + + assert_eq!( + output["system"], + "Top-level rules.\n\nSystem rules.\n\nDeveloper rules." + ); + assert!(output.get("reasoning_effort").is_none()); + assert!(output.get("context_management").is_none()); + assert_eq!(output["vendor_option"], json!({"preserve": true})); + assert_eq!(output["messages"].as_array().map(Vec::len), Some(4)); + assert_eq!(output["messages"][1]["content"][0]["id"], "toolu_01_bad_id"); + assert_eq!( + output["messages"][2]["content"][0]["tool_use_id"], + "toolu_01_bad_id" + ); + assert_eq!(output["messages"][3]["content"][0]["thinking"], "real"); + assert_eq!(output["messages"][3]["content"][1]["text"], "visible"); + Ok(()) +} + // Verifies Anthropic-only request fields are dropped or mapped for OpenAI Chat. #[test] fn anthropic_request_translates_to_openai_chat_without_anthropic_only_fields() -> TestResult { From db602bcb7d32a43c1c67f3e1209c36bf6004ff23 Mon Sep 17 00:00:00 2001 From: nachiketb Date: Wed, 29 Jul 2026 10:55:45 -0700 Subject: [PATCH 2/4] fix(translation): drop empty Anthropic messages Signed-off-by: nachiketb --- .../tests/adversarial_native_backends.rs | 8 ++++- .../src/codecs/anthropic/buffered.rs | 30 +++++++++++-------- .../tests/request_translation.rs | 6 ++++ 3 files changed, 30 insertions(+), 14 deletions(-) diff --git a/crates/switchyard-components/tests/adversarial_native_backends.rs b/crates/switchyard-components/tests/adversarial_native_backends.rs index 21c75006..e6284da4 100644 --- a/crates/switchyard-components/tests/adversarial_native_backends.rs +++ b/crates/switchyard-components/tests/adversarial_native_backends.rs @@ -1004,7 +1004,13 @@ async fn anthropic_strips_unsigned_thinking_blocks_before_native_call() -> Resul request.body["messages"][1]["content"][0]["thinking"], "real" ); - assert_eq!(request.body["messages"][2]["content"], ""); + assert_eq!( + request.body["messages"] + .as_array() + .ok_or_else(|| SwitchyardError::Other("messages should be an array".to_string()))? + .len(), + 2 + ); Ok(()) } diff --git a/crates/switchyard-translation/src/codecs/anthropic/buffered.rs b/crates/switchyard-translation/src/codecs/anthropic/buffered.rs index b9bad418..12592f5f 100644 --- a/crates/switchyard-translation/src/codecs/anthropic/buffered.rs +++ b/crates/switchyard-translation/src/codecs/anthropic/buffered.rs @@ -345,9 +345,13 @@ fn normalize_outbound_request(mut body: Value) -> Value { return body; }; object.remove("reasoning_effort"); + // Context management is a beta dialect feature. The default codec emits the + // conservative Messages shape; target-specific callers can add it afterward. object.remove("context_management"); if let Some(messages) = object.remove("messages") { + // Message-level system turns are accepted only by newer Anthropic + // dialects, while conservative endpoints require top-level `system`. let (messages, system_text) = lift_message_level_system(messages); append_lifted_system_text(object, system_text); let messages = normalize_anthropic_tool_use_ids(messages); @@ -395,10 +399,11 @@ fn system_text_from_content(content: &Value) -> Option { .collect::>(); (!parts.is_empty()).then(|| parts.join("\n\n")) } - other => Some(other.to_string()), + _ => None, } } +// Only text and input-text blocks can be promoted into a system prompt. fn system_text_from_content_block(block: &Value) -> Option { match block { Value::String(text) if !text.is_empty() => Some(text.clone()), @@ -461,38 +466,37 @@ fn strip_unsigned_thinking_blocks(messages: Value) -> Value { Value::Array(messages) => Value::Array( messages .into_iter() - .map(strip_unsigned_thinking_from_message) + .filter_map(strip_unsigned_thinking_from_message) .collect(), ), other => other, } } -fn strip_unsigned_thinking_from_message(message: Value) -> Value { +fn strip_unsigned_thinking_from_message(message: Value) -> Option { let Value::Object(mut message) = message else { - return message; + return Some(message); }; let Some(content) = message.remove("content") else { - return Value::Object(message); + return Some(Value::Object(message)); }; let Value::Array(blocks) = content else { message.insert("content".to_string(), content); - return Value::Object(message); + return Some(Value::Object(message)); }; let kept = blocks .into_iter() .filter(|block| !is_unsigned_thinking_block(block)) .collect::>(); - let content = if kept.is_empty() { - Value::String(String::new()) - } else { - Value::Array(kept) - }; - message.insert("content".to_string(), content); - Value::Object(message) + if kept.is_empty() { + return None; + } + message.insert("content".to_string(), Value::Array(kept)); + Some(Value::Object(message)) } +// A thinking block is unsigned when its signature is absent or empty. fn is_unsigned_thinking_block(block: &Value) -> bool { block.get("type").and_then(Value::as_str) == Some("thinking") && !matches!( diff --git a/crates/switchyard-translation/tests/request_translation.rs b/crates/switchyard-translation/tests/request_translation.rs index e53e4376..3a48070b 100644 --- a/crates/switchyard-translation/tests/request_translation.rs +++ b/crates/switchyard-translation/tests/request_translation.rs @@ -52,6 +52,12 @@ fn anthropic_same_format_requests_are_normalized_before_replay() -> TestResult { {"type": "thinking", "thinking": "real", "signature": "signed"}, {"type": "text", "text": "visible"} ] + }, + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "only synthetic", "signature": ""} + ] } ] }); From 425e6ba13ae29f67f403fe0592673b71aa974e1b Mon Sep 17 00:00:00 2001 From: nachiketb Date: Wed, 29 Jul 2026 12:36:03 -0700 Subject: [PATCH 3/4] refactor(translation): normalize Anthropic requests through IR Signed-off-by: nachiketb --- crates/protocol/src/llm.rs | 2 + .../src/backends/anthropic.rs | 46 ++- .../tests/adversarial_native_backends.rs | 65 +++- .../src/codecs/anthropic/buffered.rs | 280 ++++++------------ .../src/codecs/openai_chat/buffered.rs | 1 + .../src/codecs/responses/buffered.rs | 1 + crates/switchyard-translation/src/util.rs | 2 +- .../tests/request_translation.rs | 22 +- 8 files changed, 208 insertions(+), 211 deletions(-) diff --git a/crates/protocol/src/llm.rs b/crates/protocol/src/llm.rs index cda3eb82..8fd38b34 100644 --- a/crates/protocol/src/llm.rs +++ b/crates/protocol/src/llm.rs @@ -225,6 +225,8 @@ pub struct LlmRequest { pub output: OutputParams, pub reasoning: ReasoningParams, pub stream: bool, + /// Source wire format recorded by the request decoder. + pub source_format: Option, pub extensions: ProviderExtensions, pub preservation: PreservationMetadata, } diff --git a/crates/switchyard-components/src/backends/anthropic.rs b/crates/switchyard-components/src/backends/anthropic.rs index b90fc964..165c7cd7 100644 --- a/crates/switchyard-components/src/backends/anthropic.rs +++ b/crates/switchyard-components/src/backends/anthropic.rs @@ -12,7 +12,9 @@ use async_stream::try_stream; use async_trait::async_trait; use futures_util::StreamExt; use serde_json::Value; -use switchyard_translation::{TranslationEngine, TranslationPolicy, WireFormat}; +use switchyard_translation::{ + PreservationPolicy, TranslationEngine, TranslationPolicy, WireFormat, +}; use crate::{ merge_target_extra_body, BackendFormat, BoxResponseStream, ChatRequest, ChatRequestType, @@ -39,9 +41,9 @@ pub struct AnthropicNativeBackend { target: LlmTarget, /// HTTP transport, injectable for deterministic tests. transport: Arc, - /// Shared request translator and Anthropic outbound normalizer. + /// Shared request translator. translation: Arc, - /// Translation policy kept explicit so backend behavior is inspectable. + /// Canonical outbound encoding policy. translation_policy: TranslationPolicy, } @@ -74,19 +76,30 @@ impl AnthropicNativeBackend { target, transport, translation: shared_translation_engine(), - translation_policy: TranslationPolicy::default(), + translation_policy: TranslationPolicy { + preservation: PreservationPolicy::Disabled, + ..TranslationPolicy::default() + }, }) } fn outbound_body(&self, request: &ChatRequest) -> Result { let source = request.request_type(); + // Native cache annotations are provider-owned nested extensions that + // the current IR does not represent, so preserve those requests exactly. + let preservation_policy = (source == ChatRequestType::Anthropic + && has_anthropic_cache_control(request.body())) + .then(TranslationPolicy::default); + let policy = preservation_policy + .as_ref() + .unwrap_or(&self.translation_policy); let mut body = self .translation .translate_request( request_wire_format(source), WireFormat::AnthropicMessages, request.body(), - &self.translation_policy, + policy, ) .map_err(|error| { SwitchyardError::Backend(format!( @@ -276,6 +289,29 @@ fn validate_target_format(target: &LlmTarget) -> Result<()> { } } +// Detects native cache annotations that require exact same-format replay. +fn has_anthropic_cache_control(body: &Value) -> bool { + body.get("system").is_some_and(blocks_have_cache_control) + || body + .get("messages") + .and_then(Value::as_array) + .is_some_and(|messages| { + messages.iter().any(|message| { + message + .get("content") + .is_some_and(blocks_have_cache_control) + }) + }) +} + +fn blocks_have_cache_control(value: &Value) -> bool { + value.as_array().is_some_and(|blocks| { + blocks + .iter() + .any(|block| block.get("cache_control").is_some()) + }) +} + fn messages_url(base_url: Option<&str>) -> String { let base_url = base_url .unwrap_or(DEFAULT_ANTHROPIC_BASE_URL) diff --git a/crates/switchyard-components/tests/adversarial_native_backends.rs b/crates/switchyard-components/tests/adversarial_native_backends.rs index e6284da4..a933ce89 100644 --- a/crates/switchyard-components/tests/adversarial_native_backends.rs +++ b/crates/switchyard-components/tests/adversarial_native_backends.rs @@ -669,9 +669,9 @@ fn anthropic_backend_is_anthropic_only() -> Result<()> { Ok(()) } -// Non-streaming Anthropic calls should strip incompatible fields and stamp context. +// Non-streaming Anthropic calls should use canonical fields and stamp context. #[tokio::test] -async fn anthropic_non_streaming_strips_incompatible_fields_and_records_context() -> Result<()> { +async fn anthropic_non_streaming_normalizes_fields_and_records_context() -> Result<()> { let server = OneShotServer::json( 200, json!({ @@ -691,6 +691,7 @@ async fn anthropic_non_streaming_strips_incompatible_fields_and_records_context( "max_tokens": 128, "messages": [{"role": "user", "content": "hello"}], "reasoning_effort": "high", + "thinking": {"type": "enabled", "budget_tokens": 1024}, "context_management": {"strategy": "auto"}, "made_up_beta_field": {"kept": true}, "extra_body": {"caller": "value"}, @@ -732,7 +733,15 @@ async fn anthropic_non_streaming_strips_incompatible_fields_and_records_context( assert_eq!(request.body["model"], "target-claude"); assert_eq!(request.body["messages"][0]["content"], "hello"); assert!(request.body.get("reasoning_effort").is_none()); - assert!(request.body.get("context_management").is_none()); + assert_eq!( + request.body["context_management"], + json!({"strategy": "auto"}) + ); + assert_eq!( + request.body["thinking"], + json!({"type": "enabled", "budget_tokens": 1024}) + ); + assert_eq!(request.body["output_config"], json!({"effort": "high"})); assert_eq!(request.body["made_up_beta_field"], json!({"kept": true})); assert_eq!(request.body["extra_body"], json!({"caller": "value"})); Ok(()) @@ -828,7 +837,7 @@ async fn anthropic_lifts_multiple_interleaved_system_messages_in_order() -> Resu Ok(()) } -// Existing structured Anthropic system prompts should keep their shape when lifted text is added. +// Structured and message-level system prompts should combine through the request IR. #[tokio::test] async fn anthropic_lifts_message_level_system_into_existing_system_blocks() -> Result<()> { let server = OneShotServer::json(200, json!({"id": "msg-test", "content": []}))?; @@ -860,10 +869,7 @@ async fn anthropic_lifts_message_level_system_into_existing_system_blocks() -> R assert_eq!( request.body["system"], - json!([ - {"type": "text", "text": "Existing system."}, - {"type": "text", "text": "Lifted system.\n\nLifted input text."} - ]) + "Existing system.\n\nLifted system.\n\nLifted input text." ); assert_eq!( request.body["messages"], @@ -872,6 +878,49 @@ async fn anthropic_lifts_message_level_system_into_existing_system_blocks() -> R Ok(()) } +// Native Anthropic cache markers should survive the outbound translation boundary. +#[tokio::test] +async fn anthropic_preserves_native_cache_control_blocks() -> Result<()> { + let server = OneShotServer::json(200, json!({"id": "msg-test", "content": []}))?; + let backend = AnthropicNativeBackend::new(anthropic_target(server.base_url().to_string())?)?; + let mut ctx = ProxyContext::new(); + + backend + .call( + &mut ctx, + &ChatRequest::anthropic(json!({ + "model": "client-claude", + "max_tokens": 128, + "system": [{ + "type": "text", + "text": "Stable rules.", + "cache_control": {"type": "ephemeral"} + }], + "messages": [{ + "role": "user", + "content": [{ + "type": "text", + "text": "hello", + "cache_control": {"type": "ephemeral"} + }] + }] + })), + ) + .await?; + let request = server.captured()?; + + assert_eq!( + request.body["system"][0]["cache_control"], + json!({"type": "ephemeral"}) + ); + assert_eq!( + request.body["messages"][0]["content"][0]["cache_control"], + json!({"type": "ephemeral"}) + ); + assert_eq!(request.body["model"], "target-claude"); + Ok(()) +} + // Responses requests should translate into Anthropic Messages with default max_tokens. #[tokio::test] async fn anthropic_translates_responses_requests_with_default_max_tokens() -> Result<()> { diff --git a/crates/switchyard-translation/src/codecs/anthropic/buffered.rs b/crates/switchyard-translation/src/codecs/anthropic/buffered.rs index 12592f5f..200e1a6d 100644 --- a/crates/switchyard-translation/src/codecs/anthropic/buffered.rs +++ b/crates/switchyard-translation/src/codecs/anthropic/buffered.rs @@ -3,6 +3,8 @@ //! Buffered codec for Anthropic Messages request and response JSON. +use std::collections::BTreeMap; + use serde_json::{json, Map, Value}; use crate::codecs::common::{is_known_role_name, provider_extensions, text_from_blocks}; @@ -26,7 +28,7 @@ use crate::util::{ use crate::util::{ json_string, push_lossy, stable_id, string_value, validate_request_capabilities, }; -use crate::util::{normalize_anthropic_tool_use_ids, sanitize_anthropic_tool_use_id}; +use crate::util::{mapped_tool_id, sanitize_anthropic_tool_use_id}; /// Format codec for Anthropic Messages payloads. pub struct AnthropicMessagesCodec; @@ -59,6 +61,7 @@ impl FormatCodec for AnthropicMessagesCodec { .get("output_config") .and_then(Value::as_object) .and_then(|object| object.get("effort")) + .or_else(|| body.get("reasoning_effort")) .and_then(Value::as_str) .map(ToOwned::to_owned), raw: body.get("thinking").cloned(), @@ -90,13 +93,12 @@ impl FormatCodec for AnthropicMessagesCodec { )?; continue; }; - // Request decoding enforces the provider contract: an unknown - // role is rejected rather than coerced to `user`. Anthropic - // Messages only defines `user`/`assistant`, but other known - // role names stay lenient (mapped to `user`) to preserve - // historical cross-format behaviour. + // System-like compatibility roles become typed instructions so + // the Anthropic encoder can place them at the top level. let role = match message.get("role").and_then(Value::as_str) { Some("assistant") => Role::Assistant, + Some("system") => Role::System, + Some("developer") => Role::Developer, None => Role::User, Some(other) if is_known_role_name(other) => Role::User, Some(other) => { @@ -116,11 +118,21 @@ impl FormatCodec for AnthropicMessagesCodec { &mut diagnostics, policy, )?; - request.messages.push(Message { role, content }); + match role { + Role::System | Role::Developer => { + request + .instructions + .push(InstructionBlock { role, content }); + } + Role::User | Role::Assistant | Role::Tool => { + request.messages.push(Message { role, content }); + } + } } } request.tools = decode_anthropic_tools(body.get("tools")); request.tool_choice = body.get("tool_choice").map(decode_anthropic_tool_choice); + request.source_format = Some(WireFormat::AnthropicMessages.into()); request.extensions.fields = provider_extensions( body, &[ @@ -135,6 +147,7 @@ impl FormatCodec for AnthropicMessagesCodec { "top_k", "thinking", "output_config", + "reasoning_effort", "stream", ], ); @@ -154,13 +167,21 @@ impl FormatCodec for AnthropicMessagesCodec { exact_preserved_request(&request.preservation, WireFormat::AnthropicMessages, policy) { return Ok(EncodedRequest { - body: normalize_outbound_request(body), + body, diagnostics: Vec::new(), }); } let mut diagnostics = Vec::new(); validate_request_capabilities(request, &mut diagnostics, policy)?; - let mut body = Map::new(); + let source_is_anthropic = request + .source_format + .as_ref() + .is_some_and(|source| source.as_str() == WireFormat::AnthropicMessages.as_str()); + let mut body = if source_is_anthropic { + request.extensions.fields.clone() + } else { + Map::new() + }; if let Some(model) = &request.model { body.insert("model".to_string(), Value::String(model.clone())); } @@ -218,16 +239,18 @@ impl FormatCodec for AnthropicMessagesCodec { if request.stream { body.insert("stream".to_string(), Value::Bool(true)); } + if source_is_anthropic { + if let Some(thinking) = &request.reasoning.raw { + body.insert("thinking".to_string(), thinking.clone()); + } + } if let Some(effort) = &request.reasoning.effort { - body.insert("thinking".to_string(), json!({"type": "adaptive"})); + body.entry("thinking".to_string()) + .or_insert_with(|| json!({"type": "adaptive"})); body.insert("output_config".to_string(), json!({"effort": effort})); } - let body = normalize_outbound_request(embed_preservation( - Value::Object(body), - &request.preservation, - policy, - )); + let body = embed_preservation(Value::Object(body), &request.preservation, policy); Ok(EncodedRequest { body, diagnostics }) } @@ -338,173 +361,6 @@ impl FormatCodec for AnthropicMessagesCodec { } } -// Makes both translated and exactly preserved requests valid for conservative -// Anthropic Messages endpoints without dropping unknown provider extensions. -fn normalize_outbound_request(mut body: Value) -> Value { - let Value::Object(object) = &mut body else { - return body; - }; - object.remove("reasoning_effort"); - // Context management is a beta dialect feature. The default codec emits the - // conservative Messages shape; target-specific callers can add it afterward. - object.remove("context_management"); - - if let Some(messages) = object.remove("messages") { - // Message-level system turns are accepted only by newer Anthropic - // dialects, while conservative endpoints require top-level `system`. - let (messages, system_text) = lift_message_level_system(messages); - append_lifted_system_text(object, system_text); - let messages = normalize_anthropic_tool_use_ids(messages); - object.insert( - "messages".to_string(), - strip_unsigned_thinking_blocks(messages), - ); - } - body -} - -// Moves message-level system and developer turns into Anthropic's top-level -// system field for compatibility with endpoints that only accept user/assistant. -fn lift_message_level_system(messages: Value) -> (Value, Vec) { - let Value::Array(messages) = messages else { - return (messages, Vec::new()); - }; - - let mut kept_messages = Vec::with_capacity(messages.len()); - let mut system_text = Vec::new(); - for message in messages { - if matches!( - message.get("role").and_then(Value::as_str), - Some("system") | Some("developer") - ) { - if let Some(text) = message.get("content").and_then(system_text_from_content) { - system_text.push(text); - } - } else { - kept_messages.push(message); - } - } - (Value::Array(kept_messages), system_text) -} - -// Converts text-like message content into top-level system text. -fn system_text_from_content(content: &Value) -> Option { - match content { - Value::String(text) if !text.is_empty() => Some(text.clone()), - Value::String(_) | Value::Null => None, - Value::Array(blocks) => { - let parts = blocks - .iter() - .filter_map(system_text_from_content_block) - .collect::>(); - (!parts.is_empty()).then(|| parts.join("\n\n")) - } - _ => None, - } -} - -// Only text and input-text blocks can be promoted into a system prompt. -fn system_text_from_content_block(block: &Value) -> Option { - match block { - Value::String(text) if !text.is_empty() => Some(text.clone()), - Value::Object(object) - if matches!( - object.get("type").and_then(Value::as_str), - Some("text") | Some("input_text") - ) => - { - object - .get("text") - .and_then(Value::as_str) - .filter(|text| !text.is_empty()) - .map(ToOwned::to_owned) - } - _ => None, - } -} - -// Appends lifted text without changing an existing structured system prompt. -fn append_lifted_system_text(object: &mut Map, system_text: Vec) { - if system_text.is_empty() { - return; - } - - let joined = system_text.join("\n\n"); - match object.remove("system") { - None | Some(Value::Null) => { - object.insert("system".to_string(), Value::String(joined)); - } - Some(Value::String(existing)) if existing.is_empty() => { - object.insert("system".to_string(), Value::String(joined)); - } - Some(Value::String(existing)) => { - object.insert( - "system".to_string(), - Value::String(format!("{existing}\n\n{joined}")), - ); - } - Some(Value::Array(mut blocks)) => { - blocks.extend( - system_text - .into_iter() - .map(|text| json!({"type": "text", "text": text})), - ); - object.insert("system".to_string(), Value::Array(blocks)); - } - Some(other) => { - object.insert( - "system".to_string(), - Value::String(format!("{other}\n\n{joined}")), - ); - } - } -} - -// Anthropic requires signatures when assistant thinking blocks are replayed. -fn strip_unsigned_thinking_blocks(messages: Value) -> Value { - match messages { - Value::Array(messages) => Value::Array( - messages - .into_iter() - .filter_map(strip_unsigned_thinking_from_message) - .collect(), - ), - other => other, - } -} - -fn strip_unsigned_thinking_from_message(message: Value) -> Option { - let Value::Object(mut message) = message else { - return Some(message); - }; - let Some(content) = message.remove("content") else { - return Some(Value::Object(message)); - }; - let Value::Array(blocks) = content else { - message.insert("content".to_string(), content); - return Some(Value::Object(message)); - }; - - let kept = blocks - .into_iter() - .filter(|block| !is_unsigned_thinking_block(block)) - .collect::>(); - if kept.is_empty() { - return None; - } - message.insert("content".to_string(), Value::Array(kept)); - Some(Value::Object(message)) -} - -// A thinking block is unsigned when its signature is absent or empty. -fn is_unsigned_thinking_block(block: &Value) -> bool { - block.get("type").and_then(Value::as_str) == Some("thinking") - && !matches!( - block.get("signature").and_then(Value::as_str), - Some(signature) if !signature.is_empty() - ) -} - // Decodes Anthropic's `system` field into instruction blocks. fn decode_anthropic_system( value: &Value, @@ -595,7 +451,7 @@ fn decode_anthropic_content_block( policy: &TranslationPolicy, ) -> Result> { Ok(match block.get("type").and_then(Value::as_str) { - Some("text") => vec![ContentBlock::Text { + Some("text") | Some("input_text") => vec![ContentBlock::Text { text: block .get("text") .and_then(Value::as_str) @@ -750,12 +606,23 @@ fn encode_anthropic_message( message: &Message, diagnostics: &mut Vec, policy: &TranslationPolicy, -) -> Result { + id_map: &mut BTreeMap, + used_ids: &mut BTreeMap, +) -> Result> { let role = match message.role { Role::Assistant => "assistant", Role::User | Role::Tool | Role::System | Role::Developer => "user", }; - let content = encode_anthropic_content_with_policy(&message.content, diagnostics, policy)?; + let content = encode_anthropic_content_with_policy( + &message.content, + diagnostics, + policy, + id_map, + used_ids, + )?; + if content.is_empty() { + return Ok(None); + } let simple_text = content.len() == 1 && content .first() @@ -773,7 +640,7 @@ fn encode_anthropic_message( } else { Value::Array(content) }; - Ok(json!({"role": role, "content": content})) + Ok(Some(json!({"role": role, "content": content}))) } // Encodes messages while grouping adjacent tool-result-only messages correctly. @@ -783,11 +650,17 @@ fn encode_anthropic_messages( policy: &TranslationPolicy, ) -> Result> { let mut encoded = Vec::new(); + let mut id_map = BTreeMap::new(); + let mut used_ids = BTreeMap::new(); let mut index = 0; while let Some(message) = messages.get(index) { if !message_is_tool_result_only(message) { - encoded.push(encode_anthropic_message(message, diagnostics, policy)?); + if let Some(message) = + encode_anthropic_message(message, diagnostics, policy, &mut id_map, &mut used_ids)? + { + encoded.push(message); + } index += 1; continue; } @@ -801,6 +674,8 @@ fn encode_anthropic_messages( &tool_message.content, diagnostics, policy, + &mut id_map, + &mut used_ids, )?); index += 1; } @@ -834,6 +709,8 @@ fn encode_anthropic_content_with_policy( content: &[ContentBlock], diagnostics: &mut Vec, policy: &TranslationPolicy, + id_map: &mut BTreeMap, + used_ids: &mut BTreeMap, ) -> Result> { let mut blocks = Vec::new(); for block in content { @@ -846,15 +723,36 @@ fn encode_anthropic_content_with_policy( )?; blocks.push(json!({"type": "text", "text": json_string(raw)})); } - other => blocks.extend(encode_one_anthropic_block(other)), + other => { + blocks.extend(encode_one_anthropic_request_block(other, id_map, used_ids)); + } } } - if blocks.is_empty() { - blocks.push(json!({"type": "text", "text": ""})); - } Ok(blocks) } +// Encodes request blocks while keeping sanitized tool call/result IDs aligned. +fn encode_one_anthropic_request_block( + block: &ContentBlock, + id_map: &mut BTreeMap, + used_ids: &mut BTreeMap, +) -> Vec { + match block { + ContentBlock::ToolCall(call) => vec![json!({ + "type": "tool_use", + "id": mapped_tool_id(&call.id, id_map, used_ids), + "name": call.name, + "input": anthropic_tool_input(&call.arguments), + })], + ContentBlock::ToolResult(result) => vec![json!({ + "type": "tool_result", + "tool_use_id": mapped_tool_id(&result.tool_call_id, id_map, used_ids), + "content": text_from_blocks(&result.content, " "), + })], + other => encode_one_anthropic_block(other), + } +} + // Encodes content without producing diagnostics for response paths. fn encode_anthropic_content(content: &[ContentBlock]) -> Vec { let mut blocks = content diff --git a/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs b/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs index bc3688b3..d2898718 100644 --- a/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs +++ b/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs @@ -148,6 +148,7 @@ impl FormatCodec for OpenAiChatCodec { request.tools = decode_openai_tools(body.get("tools"), &mut diagnostics, policy)?; request.tool_choice = body.get("tool_choice").map(decode_openai_tool_choice); + request.source_format = Some(WireFormat::OpenAiChat.into()); request.extensions.fields = provider_extensions( body, &[ diff --git a/crates/switchyard-translation/src/codecs/responses/buffered.rs b/crates/switchyard-translation/src/codecs/responses/buffered.rs index cf7342da..e0a79010 100644 --- a/crates/switchyard-translation/src/codecs/responses/buffered.rs +++ b/crates/switchyard-translation/src/codecs/responses/buffered.rs @@ -91,6 +91,7 @@ impl FormatCodec for OpenAiResponsesCodec { request.tool_choice = body .get("tool_choice") .and_then(decode_responses_tool_choice); + request.source_format = Some(WireFormat::OpenAiResponses.into()); request.extensions.fields = provider_extensions( body, &[ diff --git a/crates/switchyard-translation/src/util.rs b/crates/switchyard-translation/src/util.rs index 5f5f8d0f..6e9270c2 100644 --- a/crates/switchyard-translation/src/util.rs +++ b/crates/switchyard-translation/src/util.rs @@ -413,7 +413,7 @@ fn normalize_tool_block( } // Gives colliding raw IDs stable, deterministic suffixes. -fn mapped_tool_id( +pub(crate) fn mapped_tool_id( raw: &str, id_map: &mut BTreeMap, used_ids: &mut BTreeMap, diff --git a/crates/switchyard-translation/tests/request_translation.rs b/crates/switchyard-translation/tests/request_translation.rs index 3a48070b..377c1328 100644 --- a/crates/switchyard-translation/tests/request_translation.rs +++ b/crates/switchyard-translation/tests/request_translation.rs @@ -5,13 +5,15 @@ use pretty_assertions::assert_eq; use serde_json::{json, Value}; -use switchyard_translation::{TranslationEngine, TranslationPolicy, WireFormat}; +use switchyard_translation::{ + PreservationPolicy, TranslationEngine, TranslationPolicy, WireFormat, +}; type TestResult = std::result::Result<(), Box>; -// Same-format preservation keeps provider extensions while enforcing Anthropic's wire contract. +// Canonical Anthropic encoding normalizes semantics through the typed request IR. #[test] -fn anthropic_same_format_requests_are_normalized_before_replay() -> TestResult { +fn anthropic_requests_are_normalized_through_ir() -> TestResult { let body = json!({ "model": "claude", "system": "Top-level rules.", @@ -62,12 +64,16 @@ fn anthropic_same_format_requests_are_normalized_before_replay() -> TestResult { ] }); + let policy = TranslationPolicy { + preservation: PreservationPolicy::Disabled, + ..TranslationPolicy::default() + }; let output = TranslationEngine::default() .translate_request( WireFormat::AnthropicMessages, WireFormat::AnthropicMessages, &body, - &TranslationPolicy::default(), + &policy, )? .body; @@ -76,7 +82,9 @@ fn anthropic_same_format_requests_are_normalized_before_replay() -> TestResult { "Top-level rules.\n\nSystem rules.\n\nDeveloper rules." ); assert!(output.get("reasoning_effort").is_none()); - assert!(output.get("context_management").is_none()); + assert_eq!(output["context_management"], json!({"edits": []})); + assert_eq!(output["thinking"], json!({"type": "adaptive"})); + assert_eq!(output["output_config"], json!({"effort": "high"})); assert_eq!(output["vendor_option"], json!({"preserve": true})); assert_eq!(output["messages"].as_array().map(Vec::len), Some(4)); assert_eq!(output["messages"][1]["content"][0]["id"], "toolu_01_bad_id"); @@ -1029,7 +1037,8 @@ fn openai_request_translates_system_developer_and_reasoning_to_anthropic() -> Te } ], "max_completion_tokens": 512, - "reasoning_effort": "high" + "reasoning_effort": "high", + "openai_extension": {"source_only": true} }); let output = engine @@ -1046,6 +1055,7 @@ fn openai_request_translates_system_developer_and_reasoning_to_anthropic() -> Te assert_eq!(output["max_tokens"], 512); assert_eq!(output["thinking"], json!({"type": "adaptive"})); assert_eq!(output["output_config"], json!({"effort": "high"})); + assert!(output.get("openai_extension").is_none()); assert_eq!(output["messages"][0]["role"], "user"); assert_eq!( output["messages"][0]["content"][0], From 68d0e08ba3928f9eeaa37d5268e15ad3bd359113 Mon Sep 17 00:00:00 2001 From: nachiketb Date: Wed, 29 Jul 2026 13:18:17 -0700 Subject: [PATCH 4/4] fix(translation): preserve Anthropic request extensions Signed-off-by: nachiketb --- crates/protocol/src/lib.rs | 2 +- crates/protocol/src/llm.rs | 25 +- .../src/backends/anthropic.rs | 47 +- .../tests/adversarial_native_backends.rs | 34 +- .../src/codecs/anthropic/buffered.rs | 568 ++++++++++++++---- .../src/codecs/common.rs | 4 +- .../src/codecs/openai_chat/buffered.rs | 20 +- .../src/codecs/responses/buffered.rs | 50 +- crates/switchyard-translation/src/util.rs | 10 +- .../tests/request_translation.rs | 136 ++++- 10 files changed, 677 insertions(+), 219 deletions(-) diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 543f2d0b..d63e6c24 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -80,7 +80,7 @@ pub fn completion_text(response: &AggLlmResponse) -> String { output .content .iter() - .filter_map(|block| match block { + .filter_map(|block| match block.normalized() { ContentBlock::Text { text } => Some(text.as_str()), _ => None, }) diff --git a/crates/protocol/src/llm.rs b/crates/protocol/src/llm.rs index 8fd38b34..5b69af14 100644 --- a/crates/protocol/src/llm.rs +++ b/crates/protocol/src/llm.rs @@ -49,7 +49,7 @@ impl Message { let parts = self .content .iter() - .filter_map(|block| match block { + .filter_map(|block| match block.normalized() { ContentBlock::Text { text } => Some(text.as_str()), ContentBlock::Refusal { text } => Some(text.as_str()), _ => None, @@ -63,6 +63,13 @@ impl Message { } } +/// Provider-owned source JSON retained beside its normalized representation. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ProviderPayload { + pub provider: FormatId, + pub raw: Value, +} + /// Normalized content block variants carried by messages and tool results. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] @@ -95,6 +102,20 @@ pub enum ContentBlock { provider: FormatId, raw: Value, }, + Provider { + payload: ProviderPayload, + normalized: Box, + }, +} + +impl ContentBlock { + /// Returns the provider-neutral block under any preservation wrapper. + pub fn normalized(&self) -> &Self { + match self { + Self::Provider { normalized, .. } => normalized.normalized(), + _ => self, + } + } } /// Image payload forms supported by the conversation model. @@ -162,6 +183,8 @@ pub struct ToolDefinition { pub description: Option, pub parameters: Value, pub strict: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_payload: Option, } /// Normalized tool choice policy. diff --git a/crates/switchyard-components/src/backends/anthropic.rs b/crates/switchyard-components/src/backends/anthropic.rs index 165c7cd7..192ef2e3 100644 --- a/crates/switchyard-components/src/backends/anthropic.rs +++ b/crates/switchyard-components/src/backends/anthropic.rs @@ -85,28 +85,28 @@ impl AnthropicNativeBackend { fn outbound_body(&self, request: &ChatRequest) -> Result { let source = request.request_type(); - // Native cache annotations are provider-owned nested extensions that - // the current IR does not represent, so preserve those requests exactly. - let preservation_policy = (source == ChatRequestType::Anthropic - && has_anthropic_cache_control(request.body())) - .then(TranslationPolicy::default); - let policy = preservation_policy - .as_ref() - .unwrap_or(&self.translation_policy); - let mut body = self + let translated = self .translation .translate_request( request_wire_format(source), WireFormat::AnthropicMessages, request.body(), - policy, + &self.translation_policy, ) .map_err(|error| { SwitchyardError::Backend(format!( "failed to translate {source:?} request to Anthropic Messages: {error}" )) - })? - .body; + })?; + for diagnostic in &translated.diagnostics { + tracing::debug!( + code = %diagnostic.code, + message = %diagnostic.message, + path = ?diagnostic.path, + "Anthropic request translation diagnostic" + ); + } + let mut body = translated.body; set_json_model(&mut body, self.target.model.as_str()); // Per-target ``extra_body`` merged last; caller wins on key // conflicts (see :func:`merge_target_extra_body`). @@ -289,29 +289,6 @@ fn validate_target_format(target: &LlmTarget) -> Result<()> { } } -// Detects native cache annotations that require exact same-format replay. -fn has_anthropic_cache_control(body: &Value) -> bool { - body.get("system").is_some_and(blocks_have_cache_control) - || body - .get("messages") - .and_then(Value::as_array) - .is_some_and(|messages| { - messages.iter().any(|message| { - message - .get("content") - .is_some_and(blocks_have_cache_control) - }) - }) -} - -fn blocks_have_cache_control(value: &Value) -> bool { - value.as_array().is_some_and(|blocks| { - blocks - .iter() - .any(|block| block.get("cache_control").is_some()) - }) -} - fn messages_url(base_url: Option<&str>) -> String { let base_url = base_url .unwrap_or(DEFAULT_ANTHROPIC_BASE_URL) diff --git a/crates/switchyard-components/tests/adversarial_native_backends.rs b/crates/switchyard-components/tests/adversarial_native_backends.rs index a933ce89..dadbf3b1 100644 --- a/crates/switchyard-components/tests/adversarial_native_backends.rs +++ b/crates/switchyard-components/tests/adversarial_native_backends.rs @@ -896,14 +896,24 @@ async fn anthropic_preserves_native_cache_control_blocks() -> Result<()> { "text": "Stable rules.", "cache_control": {"type": "ephemeral"} }], - "messages": [{ - "role": "user", - "content": [{ - "type": "text", - "text": "hello", - "cache_control": {"type": "ephemeral"} - }] - }] + "messages": [ + {"role": "system", "content": "Lifted rules."}, + { + "role": "user", + "content": [{ + "type": "text", + "text": "hello", + "cache_control": {"type": "ephemeral"} + }] + }, + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "synthetic", "signature": ""}, + {"type": "redacted_thinking", "data": "encrypted"} + ] + } + ] })), ) .await?; @@ -917,6 +927,14 @@ async fn anthropic_preserves_native_cache_control_blocks() -> Result<()> { request.body["messages"][0]["content"][0]["cache_control"], json!({"type": "ephemeral"}) ); + assert_eq!( + request.body["system"][1], + json!({"type": "text", "text": "Lifted rules."}) + ); + assert_eq!( + request.body["messages"][1]["content"], + json!([{"type": "redacted_thinking", "data": "encrypted"}]) + ); assert_eq!(request.body["model"], "target-claude"); Ok(()) } diff --git a/crates/switchyard-translation/src/codecs/anthropic/buffered.rs b/crates/switchyard-translation/src/codecs/anthropic/buffered.rs index 200e1a6d..4fb24762 100644 --- a/crates/switchyard-translation/src/codecs/anthropic/buffered.rs +++ b/crates/switchyard-translation/src/codecs/anthropic/buffered.rs @@ -17,8 +17,9 @@ use crate::error::{Result, TranslationError}; use crate::format::{FormatId, WireFormat}; use crate::llm::{ AggLlmResponse, ContentBlock, FileSource, ImageSource, InstructionBlock, LlmRequest, - MediaSource, Message, OutputParams, ProviderExtensions, ReasoningParams, ResponseOutput, Role, - SamplingParams, StopReason, ToolCall, ToolChoice, ToolDefinition, ToolResult, Usage, + MediaSource, Message, OutputParams, ProviderExtensions, ProviderPayload, ReasoningParams, + ResponseOutput, Role, SamplingParams, StopReason, ToolCall, ToolChoice, ToolDefinition, + ToolResult, Usage, }; use crate::policy::{DeterministicIdPolicy, TranslationPolicy}; use crate::util::{ @@ -185,18 +186,10 @@ impl FormatCodec for AnthropicMessagesCodec { if let Some(model) = &request.model { body.insert("model".to_string(), Value::String(model.clone())); } - let system_text = request - .instructions - .iter() - .flat_map(|instruction| instruction.content.iter()) - .filter_map(|block| match block { - ContentBlock::Text { text } | ContentBlock::Refusal { text } => Some(text.as_str()), - _ => None, - }) - .collect::>() - .join("\n\n"); - if !system_text.is_empty() { - body.insert("system".to_string(), Value::String(system_text)); + if let Some(system) = + encode_anthropic_system(&request.instructions, &mut diagnostics, policy)? + { + body.insert("system".to_string(), system); } body.insert( @@ -209,7 +202,10 @@ impl FormatCodec for AnthropicMessagesCodec { ); if !request.tools.is_empty() { - body.insert("tools".to_string(), encode_anthropic_tools(&request.tools)); + body.insert( + "tools".to_string(), + encode_anthropic_tools(&request.tools, source_is_anthropic), + ); } if let Some(choice) = &request.tool_choice { body.insert( @@ -374,7 +370,7 @@ fn decode_anthropic_system( Value::String(_) | Value::Null => Ok(None), Value::Array(blocks) => { let mut content = Vec::new(); - for block in blocks { + for (index, block) in blocks.iter().enumerate() { if let Some(block) = block.as_object() { if block.get("type").and_then(Value::as_str) == Some("text") { let text = block @@ -382,8 +378,24 @@ fn decode_anthropic_system( .and_then(Value::as_str) .unwrap_or_default() .to_string(); - content.push(ContentBlock::Text { text }); + content.push(preserve_anthropic_block( + block, + &["type", "text"], + ContentBlock::Text { text }, + )); + } else { + push_lossy( + diagnostics, + policy, + format!("Anthropic system block at index {index} was not text"), + )?; } + } else { + push_lossy( + diagnostics, + policy, + format!("Anthropic system block at index {index} was not an object"), + )?; } } Ok((!content.is_empty()).then_some(content)) @@ -397,6 +409,56 @@ fn decode_anthropic_system( } } +// Retains provider-owned block fields without hiding the normalized semantics. +fn preserve_anthropic_block( + raw: &Map, + known: &[&str], + normalized: ContentBlock, +) -> ContentBlock { + if provider_extensions(raw, known).is_empty() { + return normalized; + } + ContentBlock::Provider { + payload: ProviderPayload { + provider: WireFormat::AnthropicMessages.into(), + raw: Value::Object(raw.clone()), + }, + normalized: Box::new(normalized), + } +} + +// Decodes Anthropic's nested image source while retaining unrecognized source shapes. +fn decode_anthropic_image_source(source: Option<&Value>) -> ImageSource { + let Some(source) = source else { + return ImageSource::Raw(Value::Null); + }; + let Some(object) = source.as_object() else { + return ImageSource::Raw(source.clone()); + }; + match object.get("type").and_then(Value::as_str) { + Some("url") => object + .get("url") + .and_then(Value::as_str) + .map(|url| ImageSource::Url { + url: url.to_string(), + detail: None, + }) + .unwrap_or_else(|| ImageSource::Raw(source.clone())), + Some("base64") => object + .get("data") + .and_then(Value::as_str) + .map(|data| ImageSource::Base64 { + media_type: object + .get("media_type") + .and_then(Value::as_str) + .map(ToOwned::to_owned), + data: data.to_string(), + }) + .unwrap_or_else(|| ImageSource::Raw(source.clone())), + _ => ImageSource::Raw(source.clone()), + } +} + // Decodes Anthropic message content into normalized content blocks. fn decode_anthropic_content( value: &Value, @@ -447,68 +509,96 @@ fn decode_anthropic_content_block( block: &Map, _role: Role, generated_counter: usize, - _diagnostics: &mut Vec, + diagnostics: &mut Vec, policy: &TranslationPolicy, ) -> Result> { Ok(match block.get("type").and_then(Value::as_str) { - Some("text") | Some("input_text") => vec![ContentBlock::Text { - text: block - .get("text") - .and_then(Value::as_str) - .unwrap_or_default() - .to_string(), - }], - Some("thinking") => vec![ContentBlock::Reasoning { - text: block - .get("thinking") - .and_then(Value::as_str) - .unwrap_or_default() - .to_string(), - signature: block - .get("signature") - .and_then(Value::as_str) - .filter(|signature| !signature.is_empty()) - .map(ToOwned::to_owned), - }], - Some("tool_use") => vec![ContentBlock::ToolCall(ToolCall { - id: block - .get("id") - .and_then(Value::as_str) - .filter(|id| !id.is_empty()) - .map(ToOwned::to_owned) - .unwrap_or_else(|| match &policy.deterministic_ids { - DeterministicIdPolicy::GenerateStable { prefix } => { - stable_id(prefix, generated_counter) - } - DeterministicIdPolicy::Preserve => String::new(), - }), - name: block - .get("name") - .and_then(Value::as_str) - .unwrap_or_default() - .to_string(), - arguments: block.get("input").cloned().unwrap_or_else(|| json!({})), - })], - Some("tool_result") => vec![ContentBlock::ToolResult(ToolResult { - tool_call_id: block - .get("tool_use_id") - .and_then(Value::as_str) - .unwrap_or_default() - .to_string(), - content: decode_tool_result_content(block.get("content").unwrap_or(&Value::Null)), - is_error: block.get("is_error").and_then(Value::as_bool), - })], + Some("text") | Some("input_text") => vec![preserve_anthropic_block( + block, + &["type", "text"], + ContentBlock::Text { + text: block + .get("text") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + }, + )], + Some("thinking") => vec![preserve_anthropic_block( + block, + &["type", "thinking", "signature"], + ContentBlock::Reasoning { + text: block + .get("thinking") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + signature: block + .get("signature") + .and_then(Value::as_str) + .filter(|signature| !signature.is_empty()) + .map(ToOwned::to_owned), + }, + )], + Some("tool_use") => vec![preserve_anthropic_block( + block, + &["type", "id", "name", "input"], + ContentBlock::ToolCall(ToolCall { + id: block + .get("id") + .and_then(Value::as_str) + .filter(|id| !id.is_empty()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| match &policy.deterministic_ids { + DeterministicIdPolicy::GenerateStable { prefix } => { + stable_id(prefix, generated_counter) + } + DeterministicIdPolicy::Preserve => String::new(), + }), + name: block + .get("name") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + arguments: block.get("input").cloned().unwrap_or_else(|| json!({})), + }), + )], + Some("tool_result") => vec![preserve_anthropic_block( + block, + &["type", "tool_use_id", "content", "is_error"], + ContentBlock::ToolResult(ToolResult { + tool_call_id: block + .get("tool_use_id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + content: decode_tool_result_content( + block.get("content").unwrap_or(&Value::Null), + generated_counter, + diagnostics, + policy, + )?, + is_error: block.get("is_error").and_then(Value::as_bool), + }), + )], Some("image") => { - let source = block - .get("source") - .cloned() - .map(ImageSource::Raw) - .unwrap_or_else(|| ImageSource::Raw(Value::Object(block.clone()))); - vec![ContentBlock::Image { source }] + let source = decode_anthropic_image_source(block.get("source")); + vec![preserve_anthropic_block( + block, + &["type", "source"], + ContentBlock::Image { source }, + )] } Some("input_image") | Some("image_url") => decode_image_source(block) .map(|source| vec![ContentBlock::Image { source }]) .unwrap_or_default(), + Some("document") => vec![preserve_anthropic_block( + block, + &["type", "source"], + ContentBlock::File { + source: FileSource::Raw(block.get("source").cloned().unwrap_or(Value::Null)), + }, + )], Some("input_file") | Some("file") => vec![ContentBlock::File { source: decode_file_source(block), }], @@ -520,36 +610,41 @@ fn decode_anthropic_content_block( } // Converts Anthropic tool-result content into text-like IR blocks. -fn decode_tool_result_content(value: &Value) -> Vec { +fn decode_tool_result_content( + value: &Value, + generated_counter: usize, + diagnostics: &mut Vec, + policy: &TranslationPolicy, +) -> Result> { match value { - Value::String(text) => vec![ContentBlock::Text { text: text.clone() }], + Value::String(text) => Ok(vec![ContentBlock::Text { text: text.clone() }]), Value::Array(blocks) => { - let mut text = Vec::new(); - for block in blocks { - if let Some(block) = block.as_object() { - if block.get("type").and_then(Value::as_str) == Some("text") { - text.push( - block - .get("text") - .and_then(Value::as_str) - .unwrap_or_default() - .to_string(), - ); - } else { - text.push(json_string(&Value::Object(block.clone()))); - } - } + let mut content = Vec::new(); + for (index, block) in blocks.iter().enumerate() { + let Some(block) = block.as_object() else { + push_lossy( + diagnostics, + policy, + format!("Anthropic tool-result block at index {index} was not an object"), + )?; + continue; + }; + content.extend(decode_anthropic_content_block( + block, + Role::User, + generated_counter + index, + diagnostics, + policy, + )?); } - vec![ContentBlock::Text { - text: text.join(" "), - }] + Ok(content) } - Value::Null => vec![ContentBlock::Text { + Value::Null => Ok(vec![ContentBlock::Text { text: String::new(), - }], - other => vec![ContentBlock::Text { + }]), + other => Ok(vec![ContentBlock::Text { text: json_string(other), - }], + }]), } } @@ -573,6 +668,10 @@ fn decode_anthropic_tools(value: Option<&Value>) -> Vec { .cloned() .unwrap_or_else(|| json!({})), strict: None, + provider_payload: Some(ProviderPayload { + provider: WireFormat::AnthropicMessages.into(), + raw: Value::Object(tool.clone()), + }), }) }) .collect() @@ -601,6 +700,75 @@ fn decode_anthropic_tool_choice(value: &Value) -> ToolChoice { } } +// Encodes instructions as text unless provider-owned fields require structured blocks. +fn encode_anthropic_system( + instructions: &[InstructionBlock], + diagnostics: &mut Vec, + policy: &TranslationPolicy, +) -> Result> { + let mut blocks = Vec::new(); + let mut needs_structured_blocks = false; + for block in instructions + .iter() + .flat_map(|instruction| instruction.content.iter()) + { + let Some((encoded, preserved)) = encode_anthropic_system_block(block, diagnostics, policy)? + else { + continue; + }; + blocks.push(encoded); + needs_structured_blocks |= preserved; + } + if blocks.is_empty() { + return Ok(None); + } + if needs_structured_blocks { + return Ok(Some(Value::Array(blocks))); + } + let text = blocks + .iter() + .filter_map(|block| block.get("text").and_then(Value::as_str)) + .collect::>() + .join("\n\n"); + Ok((!text.is_empty()).then_some(Value::String(text))) +} + +// Encodes one instruction block and reapplies same-provider fields when present. +fn encode_anthropic_system_block( + block: &ContentBlock, + diagnostics: &mut Vec, + policy: &TranslationPolicy, +) -> Result> { + match block { + ContentBlock::Provider { + payload, + normalized, + } => { + let Some((encoded, preserved)) = + encode_anthropic_system_block(normalized, diagnostics, policy)? + else { + return Ok(None); + }; + if is_anthropic_payload(payload) { + Ok(Some((merge_provider_payload(payload, encoded), true))) + } else { + Ok(Some((encoded, preserved))) + } + } + ContentBlock::Text { text } | ContentBlock::Refusal { text } => { + Ok(Some((json!({"type": "text", "text": text}), false))) + } + _ => { + push_lossy( + diagnostics, + policy, + "non-text instruction block omitted from Anthropic system", + )?; + Ok(None) + } + } +} + // Encodes one normalized message into Anthropic message JSON. fn encode_anthropic_message( message: &Message, @@ -621,25 +789,14 @@ fn encode_anthropic_message( used_ids, )?; if content.is_empty() { + push_lossy( + diagnostics, + policy, + "Anthropic message omitted after all content blocks were removed", + )?; return Ok(None); } - let simple_text = content.len() == 1 - && content - .first() - .and_then(Value::as_object) - .and_then(|object| object.get("type")) - .and_then(Value::as_str) - == Some("text"); - let content = if simple_text { - content - .first() - .and_then(Value::as_object) - .and_then(|object| object.get("text")) - .cloned() - .unwrap_or_else(|| Value::String(String::new())) - } else { - Value::Array(content) - }; + let content = simple_anthropic_text(&content).unwrap_or(Value::Array(content)); Ok(Some(json!({"role": role, "content": content}))) } @@ -701,7 +858,7 @@ fn message_is_tool_result_only(message: &Message) -> bool { && message .content .iter() - .all(|block| matches!(block, ContentBlock::ToolResult(_))) + .all(|block| matches!(block.normalized(), ContentBlock::ToolResult(_))) } // Encodes content while applying lossy-conversion policy to unknown blocks. @@ -715,6 +872,11 @@ fn encode_anthropic_content_with_policy( let mut blocks = Vec::new(); for block in content { match block { + ContentBlock::Unknown { provider, raw } + if provider.as_str() == WireFormat::AnthropicMessages.as_str() => + { + blocks.push(raw.clone()); + } ContentBlock::Unknown { raw, .. } => { push_lossy( diagnostics, @@ -723,8 +885,35 @@ fn encode_anthropic_content_with_policy( )?; blocks.push(json!({"type": "text", "text": json_string(raw)})); } + ContentBlock::Provider { + payload, + normalized, + } => { + let encoded = encode_anthropic_content_with_policy( + std::slice::from_ref(normalized), + diagnostics, + policy, + id_map, + used_ids, + )?; + if is_anthropic_payload(payload) { + blocks.extend( + encoded + .into_iter() + .map(|block| merge_provider_payload(payload, block)), + ); + } else { + blocks.extend(encoded); + } + } other => { - blocks.extend(encode_one_anthropic_request_block(other, id_map, used_ids)); + blocks.extend(encode_one_anthropic_request_block( + other, + diagnostics, + policy, + id_map, + used_ids, + )?); } } } @@ -734,23 +923,63 @@ fn encode_anthropic_content_with_policy( // Encodes request blocks while keeping sanitized tool call/result IDs aligned. fn encode_one_anthropic_request_block( block: &ContentBlock, + diagnostics: &mut Vec, + policy: &TranslationPolicy, id_map: &mut BTreeMap, used_ids: &mut BTreeMap, -) -> Vec { - match block { +) -> Result> { + Ok(match block { ContentBlock::ToolCall(call) => vec![json!({ "type": "tool_use", "id": mapped_tool_id(&call.id, id_map, used_ids), "name": call.name, "input": anthropic_tool_input(&call.arguments), })], - ContentBlock::ToolResult(result) => vec![json!({ - "type": "tool_result", - "tool_use_id": mapped_tool_id(&result.tool_call_id, id_map, used_ids), - "content": text_from_blocks(&result.content, " "), - })], + ContentBlock::ToolResult(result) => { + let mut tool_result = Map::new(); + tool_result.insert("type".to_string(), Value::String("tool_result".to_string())); + tool_result.insert( + "tool_use_id".to_string(), + Value::String(mapped_tool_id(&result.tool_call_id, id_map, used_ids)), + ); + let content = encode_anthropic_content_with_policy( + &result.content, + diagnostics, + policy, + id_map, + used_ids, + )?; + tool_result.insert( + "content".to_string(), + simple_anthropic_text(&content).unwrap_or(Value::Array(content)), + ); + if let Some(is_error) = result.is_error { + tool_result.insert("is_error".to_string(), Value::Bool(is_error)); + } + vec![Value::Object(tool_result)] + } + ContentBlock::Reasoning { signature, .. } + if signature.as_deref().is_none_or(str::is_empty) => + { + push_lossy( + diagnostics, + policy, + "unsigned Anthropic thinking block omitted", + )?; + Vec::new() + } other => encode_one_anthropic_block(other), - } + }) +} + +// Uses Anthropic's compact string form only when no block metadata would be lost. +fn simple_anthropic_text(blocks: &[Value]) -> Option { + let object = blocks.first()?.as_object()?; + (blocks.len() == 1 + && object.len() == 2 + && object.get("type").and_then(Value::as_str) == Some("text")) + .then(|| object.get("text").cloned()) + .flatten() } // Encodes content without producing diagnostics for response paths. @@ -768,6 +997,20 @@ fn encode_anthropic_content(content: &[ContentBlock]) -> Vec { // Encodes response content, where synthetic reasoning may be shown to clients. fn encode_one_anthropic_response_block(block: &ContentBlock) -> Vec { match block { + ContentBlock::Provider { + payload, + normalized, + } => { + let encoded = encode_one_anthropic_response_block(normalized); + if is_anthropic_payload(payload) { + encoded + .into_iter() + .map(|block| merge_provider_payload(payload, block)) + .collect() + } else { + encoded + } + } ContentBlock::Reasoning { text, signature: None, @@ -818,7 +1061,7 @@ fn encode_one_anthropic_block(block: &ContentBlock) -> Vec { "data": data, }, }), - ImageSource::Raw(raw) => raw.clone(), + ImageSource::Raw(raw) => encode_anthropic_raw_image(raw), }], ContentBlock::File { source } => vec![match source { FileSource::FileId(file_id) => { @@ -832,7 +1075,7 @@ fn encode_one_anthropic_block(block: &ContentBlock) -> Vec { "filename": filename, }, }), - FileSource::Raw(raw) => raw.clone(), + FileSource::Raw(raw) => encode_anthropic_raw_document(raw), }], ContentBlock::Audio { source } => vec![match source { MediaSource::Url { url, media_type } => { @@ -863,6 +1106,38 @@ fn encode_one_anthropic_block(block: &ContentBlock) -> Vec { MediaSource::Raw(raw) => raw.clone(), }], ContentBlock::Unknown { raw, .. } => vec![raw.clone()], + ContentBlock::Provider { + payload, + normalized, + } => { + let encoded = encode_one_anthropic_block(normalized); + if is_anthropic_payload(payload) { + encoded + .into_iter() + .map(|block| merge_provider_payload(payload, block)) + .collect() + } else { + encoded + } + } + } +} + +// Restores the outer Anthropic image block around a raw source object. +fn encode_anthropic_raw_image(raw: &Value) -> Value { + if raw.get("type").and_then(Value::as_str) == Some("image") { + raw.clone() + } else { + json!({"type": "image", "source": raw}) + } +} + +// Restores the outer Anthropic document block around a raw source object. +fn encode_anthropic_raw_document(raw: &Value) -> Value { + if raw.get("type").and_then(Value::as_str) == Some("document") { + raw.clone() + } else { + json!({"type": "document", "source": raw}) } } @@ -888,22 +1163,53 @@ fn ensure_anthropic_tool_input_object(arguments: Value) -> Value { } } -// Encodes normalized tool definitions into Anthropic tool JSON. -fn encode_anthropic_tools(tools: &[ToolDefinition]) -> Value { +// Encodes normalized tools while retaining Anthropic-native server-tool fields. +fn encode_anthropic_tools(tools: &[ToolDefinition], source_is_anthropic: bool) -> Value { Value::Array( tools .iter() .map(|tool| { - json!({ - "name": tool.name, - "description": tool.description.clone().unwrap_or_default(), - "input_schema": tool.parameters, - }) + let preserved = source_is_anthropic + .then_some(tool.provider_payload.as_ref()) + .flatten() + .filter(|payload| is_anthropic_payload(payload)) + .and_then(|payload| payload.raw.as_object()) + .cloned(); + let mut encoded = preserved.unwrap_or_default(); + encoded.insert("name".to_string(), Value::String(tool.name.clone())); + if encoded.contains_key("input_schema") || !encoded.contains_key("type") { + encoded.insert("input_schema".to_string(), tool.parameters.clone()); + if let Some(description) = &tool.description { + encoded.insert( + "description".to_string(), + Value::String(description.clone()), + ); + } else { + encoded.remove("description"); + } + } + Value::Object(encoded) }) .collect(), ) } +fn is_anthropic_payload(payload: &ProviderPayload) -> bool { + payload.provider.as_str() == WireFormat::AnthropicMessages.as_str() +} + +// Reapplies provider-owned fields while letting canonical fields win. +fn merge_provider_payload(payload: &ProviderPayload, encoded: Value) -> Value { + match (&payload.raw, encoded) { + (Value::Object(raw), Value::Object(encoded)) => { + let mut merged = raw.clone(); + merged.extend(encoded); + Value::Object(merged) + } + (_, encoded) => encoded, + } +} + // Encodes normalized tool choice into Anthropic tool-choice JSON. fn encode_anthropic_tool_choice(choice: &ToolChoice) -> Value { match choice { diff --git a/crates/switchyard-translation/src/codecs/common.rs b/crates/switchyard-translation/src/codecs/common.rs index ffd8e34c..2410ede8 100644 --- a/crates/switchyard-translation/src/codecs/common.rs +++ b/crates/switchyard-translation/src/codecs/common.rs @@ -19,7 +19,7 @@ pub(crate) fn is_known_role_name(name: &str) -> bool { pub(crate) fn text_from_blocks(content: &[ContentBlock], separator: &str) -> String { content .iter() - .filter_map(|block| match block { + .filter_map(|block| match block.normalized() { ContentBlock::Text { text } => Some(text.as_str()), ContentBlock::Refusal { text } => Some(text.as_str()), ContentBlock::Unknown { raw, .. } => raw.as_str(), @@ -33,7 +33,7 @@ pub(crate) fn text_from_blocks(content: &[ContentBlock], separator: &str) -> Str pub(crate) fn reasoning_text_from_blocks(content: &[ContentBlock], separator: &str) -> String { content .iter() - .filter_map(|block| match block { + .filter_map(|block| match block.normalized() { ContentBlock::Reasoning { text, .. } => Some(text.as_str()), _ => None, }) diff --git a/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs b/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs index d2898718..94d8c570 100644 --- a/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs +++ b/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs @@ -334,7 +334,7 @@ impl FormatCodec for OpenAiChatCodec { output .content .iter() - .filter_map(|block| match block { + .filter_map(|block| match block.normalized() { ContentBlock::ToolCall(call) => Some(json!({ "id": call.id, "type": "function", @@ -649,6 +649,7 @@ pub(crate) fn decode_openai_tools( .cloned() .unwrap_or_else(|| json!({})), strict: function.get("strict").and_then(Value::as_bool), + provider_payload: None, }); } Ok(definitions) @@ -700,7 +701,7 @@ fn encode_message_with_tool_results_to_openai( let mut pending_content = Vec::new(); for block in &message.content { - if let ContentBlock::ToolResult(result) = block { + if let ContentBlock::ToolResult(result) = block.normalized() { push_pending_openai_message( &mut out, message.role, @@ -766,7 +767,7 @@ fn encode_message_without_tool_results_to_openai( let tool_calls = message .content .iter() - .filter_map(|block| match block { + .filter_map(|block| match block.normalized() { ContentBlock::ToolCall(call) => Some(json!({ "id": call.id, "type": "function", @@ -783,7 +784,7 @@ fn encode_message_without_tool_results_to_openai( .iter() .filter(|block| { !matches!( - block, + block.normalized(), ContentBlock::ToolCall(_) | ContentBlock::Reasoning { .. } ) }) @@ -807,7 +808,7 @@ fn message_has_tool_results(message: &Message) -> bool { message .content .iter() - .any(|block| matches!(block, ContentBlock::ToolResult(_))) + .any(|block| matches!(block.normalized(), ContentBlock::ToolResult(_))) } // Copies Chat-compatible extension fields preserved in the IR. @@ -839,7 +840,7 @@ fn copy_openai_chat_request_extensions( // Detects placeholder empty text generated while decoding assistant tool calls. fn is_empty_text_only(content: &[ContentBlock]) -> bool { - matches!(content, [ContentBlock::Text { text }] if text.is_empty()) + matches!(content, [block] if matches!(block.normalized(), ContentBlock::Text { text } if text.is_empty())) } /// Encodes normalized content into OpenAI Chat content JSON. @@ -851,7 +852,7 @@ pub(crate) fn encode_openai_content( ) -> Result { let has_non_text = content.iter().any(|block| { matches!( - block, + block.normalized(), ContentBlock::Image { .. } | ContentBlock::Audio { .. } | ContentBlock::Video { .. } @@ -872,7 +873,7 @@ pub(crate) fn encode_openai_content( } let mut blocks = Vec::new(); for block in content { - match block { + match block.normalized() { ContentBlock::Text { text } => blocks.push(json!({"type": "text", "text": text})), ContentBlock::Refusal { text } => blocks.push(json!({"type": "text", "text": text})), ContentBlock::Image { source } => match openai_image_part(source) { @@ -923,7 +924,8 @@ pub(crate) fn encode_openai_content( } ContentBlock::Reasoning { .. } | ContentBlock::ToolCall(_) - | ContentBlock::ToolResult(_) => {} + | ContentBlock::ToolResult(_) + | ContentBlock::Provider { .. } => {} } } Ok(Value::Array(blocks)) diff --git a/crates/switchyard-translation/src/codecs/responses/buffered.rs b/crates/switchyard-translation/src/codecs/responses/buffered.rs index e0a79010..6e3ab56d 100644 --- a/crates/switchyard-translation/src/codecs/responses/buffered.rs +++ b/crates/switchyard-translation/src/codecs/responses/buffered.rs @@ -137,7 +137,7 @@ impl FormatCodec for OpenAiResponsesCodec { .instructions .iter() .flat_map(|instruction| instruction.content.iter()) - .filter_map(|block| match block { + .filter_map(|block| match block.normalized() { ContentBlock::Text { text } | ContentBlock::Refusal { text } => Some(text.as_str()), _ => None, }) @@ -699,6 +699,7 @@ fn decode_responses_tools(value: Option<&Value>) -> Vec { .cloned() .unwrap_or_else(|| json!({})), strict: function.get("strict").and_then(Value::as_bool), + provider_payload: None, }); } } @@ -736,6 +737,7 @@ fn push_responses_function_tool( .map(ToOwned::to_owned), parameters: tool.get("parameters").cloned().unwrap_or_else(|| json!({})), strict: tool.get("strict").and_then(Value::as_bool), + provider_payload: None, }); true } @@ -765,6 +767,7 @@ fn push_responses_id_tool( .cloned() .unwrap_or_else(|| json!({})), strict: None, + provider_payload: None, }); true } @@ -830,9 +833,12 @@ fn encode_responses_input( if messages.len() == 1 && matches!(messages[0].role, Role::User) && messages[0].content.len() == 1 - && matches!(messages[0].content[0], ContentBlock::Text { .. }) + && matches!( + messages[0].content[0].normalized(), + ContentBlock::Text { .. } + ) { - if let ContentBlock::Text { text } = &messages[0].content[0] { + if let ContentBlock::Text { text } = messages[0].content[0].normalized() { return Ok(Value::String(text.clone())); } } @@ -844,7 +850,7 @@ fn encode_responses_input( .iter() .filter(|block| { !matches!( - block, + block.normalized(), ContentBlock::Reasoning { signature: Some(_), .. @@ -858,7 +864,7 @@ fn encode_responses_input( } if content.iter().any(|block| { matches!( - block, + block.normalized(), ContentBlock::ToolCall(_) | ContentBlock::ToolResult(_) ) }) { @@ -889,7 +895,7 @@ fn encode_responses_input( // Encodes IR blocks that Responses represents as top-level input items. fn encode_responses_special_input(block: &ContentBlock) -> Option { - match block { + match block.normalized() { ContentBlock::Reasoning { text, signature: None, @@ -931,7 +937,7 @@ fn encode_responses_content( ) -> Result { let has_non_text = content.iter().any(|block| { !matches!( - block, + block.normalized(), ContentBlock::Text { .. } | ContentBlock::Refusal { .. } | ContentBlock::Reasoning { .. } @@ -942,7 +948,7 @@ fn encode_responses_content( } let mut blocks = Vec::new(); for block in content { - match block { + match block.normalized() { ContentBlock::Text { text } => blocks.push(json!({"type": "input_text", "text": text})), ContentBlock::Refusal { text } => { blocks.push(json!({"type": "refusal", "refusal": text})); @@ -987,7 +993,8 @@ fn encode_responses_content( } ContentBlock::Reasoning { .. } | ContentBlock::ToolCall(_) - | ContentBlock::ToolResult(_) => {} + | ContentBlock::ToolResult(_) + | ContentBlock::Provider { .. } => {} } } Ok(Value::Array(blocks)) @@ -1080,7 +1087,7 @@ fn encode_responses_output(outputs: &[ResponseOutput]) -> Value { let has_tool_calls = output .content .iter() - .any(|block| matches!(block, ContentBlock::ToolCall(_))); + .any(|block| matches!(block.normalized(), ContentBlock::ToolCall(_))); let text = text_from_blocks(&output.content, ""); let reasoning = reasoning_text_from_blocks(&output.content, "\n"); let mut items = Vec::new(); @@ -1103,15 +1110,20 @@ fn encode_responses_output(outputs: &[ResponseOutput]) -> Value { })); } - items.extend(output.content.iter().filter_map(|block| match block { - ContentBlock::ToolCall(call) => Some(json!({ - "type": "function_call", - "call_id": call.id, - "name": call.name, - "arguments": json_string_python_style(&call.arguments), - })), - _ => None, - })); + items.extend( + output + .content + .iter() + .filter_map(|block| match block.normalized() { + ContentBlock::ToolCall(call) => Some(json!({ + "type": "function_call", + "call_id": call.id, + "name": call.name, + "arguments": json_string_python_style(&call.arguments), + })), + _ => None, + }), + ); items }) diff --git a/crates/switchyard-translation/src/util.rs b/crates/switchyard-translation/src/util.rs index 6e9270c2..9cade089 100644 --- a/crates/switchyard-translation/src/util.rs +++ b/crates/switchyard-translation/src/util.rs @@ -137,7 +137,7 @@ pub fn validate_request_capabilities( } if policy.target_capabilities.supports_images == Some(false) && messages_have_block(&request.messages, |block| { - matches!(block, ContentBlock::Image { .. }) + matches!(block.normalized(), ContentBlock::Image { .. }) }) { push_lossy( @@ -148,7 +148,7 @@ pub fn validate_request_capabilities( } if policy.target_capabilities.supports_audio == Some(false) && messages_have_block(&request.messages, |block| { - matches!(block, ContentBlock::Audio { .. }) + matches!(block.normalized(), ContentBlock::Audio { .. }) }) { push_lossy( @@ -159,7 +159,7 @@ pub fn validate_request_capabilities( } if policy.target_capabilities.supports_video == Some(false) && messages_have_block(&request.messages, |block| { - matches!(block, ContentBlock::Video { .. }) + matches!(block.normalized(), ContentBlock::Video { .. }) }) { push_lossy( @@ -170,7 +170,7 @@ pub fn validate_request_capabilities( } if policy.target_capabilities.supports_files == Some(false) && messages_have_block(&request.messages, |block| { - matches!(block, ContentBlock::File { .. }) + matches!(block.normalized(), ContentBlock::File { .. }) }) { push_lossy( @@ -207,7 +207,7 @@ pub fn validate_request_capabilities( fn messages_have_tools(messages: &[Message]) -> bool { messages_have_block(messages, |block| { matches!( - block, + block.normalized(), ContentBlock::ToolCall(_) | ContentBlock::ToolResult(_) ) }) diff --git a/crates/switchyard-translation/tests/request_translation.rs b/crates/switchyard-translation/tests/request_translation.rs index 377c1328..1e9e4cca 100644 --- a/crates/switchyard-translation/tests/request_translation.rs +++ b/crates/switchyard-translation/tests/request_translation.rs @@ -68,14 +68,17 @@ fn anthropic_requests_are_normalized_through_ir() -> TestResult { preservation: PreservationPolicy::Disabled, ..TranslationPolicy::default() }; - let output = TranslationEngine::default() - .translate_request( - WireFormat::AnthropicMessages, - WireFormat::AnthropicMessages, - &body, - &policy, - )? - .body; + let translated = TranslationEngine::default().translate_request( + WireFormat::AnthropicMessages, + WireFormat::AnthropicMessages, + &body, + &policy, + )?; + assert!(translated + .diagnostics + .iter() + .any(|diagnostic| diagnostic.message.contains("unsigned Anthropic thinking"))); + let output = translated.body; assert_eq!( output["system"], @@ -97,6 +100,123 @@ fn anthropic_requests_are_normalized_through_ir() -> TestResult { Ok(()) } +// Provider-owned nested fields must survive canonical IR normalization. +#[test] +fn anthropic_canonical_ir_preserves_native_nested_features() -> TestResult { + let body = json!({ + "model": "claude", + "system": [{ + "type": "text", + "text": "Stable rules.", + "cache_control": {"type": "ephemeral"} + }], + "messages": [ + {"role": "system", "content": "Lifted rules."}, + { + "role": "user", + "content": [{ + "type": "text", + "text": "Use the tool.", + "cache_control": {"type": "ephemeral"} + }] + }, + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "synthetic", "signature": ""}, + {"type": "redacted_thinking", "data": "encrypted"}, + { + "type": "tool_use", + "id": "toolu_bad:id", + "name": "lookup", + "input": {}, + "cache_control": {"type": "ephemeral"} + } + ] + }, + { + "role": "user", + "content": [{ + "type": "tool_result", + "tool_use_id": "toolu_bad:id", + "is_error": false, + "cache_control": {"type": "ephemeral"}, + "content": [ + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "abc" + } + }, + { + "type": "document", + "source": { + "type": "base64", + "media_type": "application/pdf", + "data": "pdf" + } + }, + {"type": "search_result", "title": "Result", "source": "https://example.test"} + ] + }] + } + ], + "tools": [{ + "type": "web_search_20250305", + "name": "web_search", + "max_uses": 5, + "cache_control": {"type": "ephemeral"} + }], + "max_tokens": 1024 + }); + let policy = TranslationPolicy { + preservation: PreservationPolicy::Disabled, + ..TranslationPolicy::default() + }; + + let output = TranslationEngine::default() + .translate_request( + WireFormat::AnthropicMessages, + WireFormat::AnthropicMessages, + &body, + &policy, + )? + .body; + + assert_eq!(output["system"][0], body["system"][0]); + assert_eq!( + output["system"][1], + json!({"type": "text", "text": "Lifted rules."}) + ); + assert_eq!( + output["messages"][0]["content"][0]["cache_control"], + json!({"type": "ephemeral"}) + ); + assert_eq!( + output["messages"][1]["content"][0], + json!({"type": "redacted_thinking", "data": "encrypted"}) + ); + assert_eq!(output["messages"][1]["content"][1]["id"], "toolu_bad_id"); + assert_eq!( + output["messages"][1]["content"][1]["cache_control"], + json!({"type": "ephemeral"}) + ); + assert_eq!( + output["messages"][2]["content"][0]["tool_use_id"], + "toolu_bad_id" + ); + assert_eq!( + output["messages"][2]["content"][0]["content"], + body["messages"][3]["content"][0]["content"] + ); + assert_eq!(output["messages"][2]["content"][0]["is_error"], false); + assert_eq!(output["tools"][0], body["tools"][0]); + assert!(output["tools"][0].get("input_schema").is_none()); + Ok(()) +} + // Verifies Anthropic-only request fields are dropped or mapped for OpenAI Chat. #[test] fn anthropic_request_translates_to_openai_chat_without_anthropic_only_fields() -> TestResult {