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 cda3eb82..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. @@ -225,6 +248,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 bd4e7f0a..192ef2e3 100644 --- a/crates/switchyard-components/src/backends/anthropic.rs +++ b/crates/switchyard-components/src/backends/anthropic.rs @@ -8,17 +8,18 @@ use std::env; use std::fmt; use std::sync::Arc; -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 serde_json::Value; use switchyard_translation::{ - normalize_anthropic_tool_use_ids, TranslationEngine, TranslationPolicy, WireFormat, + PreservationPolicy, TranslationEngine, TranslationPolicy, WireFormat, +}; + +use crate::{ + merge_target_extra_body, BackendFormat, BoxResponseStream, ChatRequest, ChatRequestType, + ChatResponse, LlmBackend, LlmTarget, LlmTargetId, ProxyContext, Result, StreamEvent, + SwitchyardError, }; use super::common::{ @@ -40,9 +41,9 @@ pub struct AnthropicNativeBackend { target: LlmTarget, /// HTTP transport, injectable for deterministic tests. transport: Arc, - /// Shared request translator for non-Anthropic inbound payloads. + /// Shared request translator. translation: Arc, - /// Translation policy kept explicit so backend behavior is inspectable. + /// Canonical outbound encoding policy. translation_policy: TranslationPolicy, } @@ -75,32 +76,38 @@ 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 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 translated = 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}" + )) + })?; + 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()); - 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 +289,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-components/tests/adversarial_native_backends.rs b/crates/switchyard-components/tests/adversarial_native_backends.rs index 21c75006..dadbf3b1 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,67 @@ 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": "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?; + 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["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(()) +} + // Responses requests should translate into Anthropic Messages with default max_tokens. #[tokio::test] async fn anthropic_translates_responses_requests_with_default_max_tokens() -> Result<()> { @@ -1004,7 +1071,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 e2deee86..4fb24762 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}; @@ -15,11 +17,11 @@ 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::sanitize_anthropic_tool_use_id; use crate::util::{ capture_request_preservation, capture_response_preservation, embed_preservation, exact_preserved_request, exact_preserved_response, @@ -27,6 +29,7 @@ use crate::util::{ use crate::util::{ json_string, push_lossy, stable_id, string_value, validate_request_capabilities, }; +use crate::util::{mapped_tool_id, sanitize_anthropic_tool_use_id}; /// Format codec for Anthropic Messages payloads. pub struct AnthropicMessagesCodec; @@ -59,6 +62,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 +94,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 +119,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 +148,7 @@ impl FormatCodec for AnthropicMessagesCodec { "top_k", "thinking", "output_config", + "reasoning_effort", "stream", ], ); @@ -160,22 +174,22 @@ impl FormatCodec for AnthropicMessagesCodec { } 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())); } - 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( @@ -188,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( @@ -218,8 +235,14 @@ 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})); } @@ -347,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 @@ -355,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)) @@ -370,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, @@ -420,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") => 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), }], @@ -493,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), - }], + }]), } } @@ -546,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() @@ -574,35 +700,104 @@ 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, 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 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) - }; - Ok(json!({"role": role, "content": content})) + let content = encode_anthropic_content_with_policy( + &message.content, + diagnostics, + policy, + id_map, + used_ids, + )?; + if content.is_empty() { + push_lossy( + diagnostics, + policy, + "Anthropic message omitted after all content blocks were removed", + )?; + return Ok(None); + } + let content = simple_anthropic_text(&content).unwrap_or(Value::Array(content)); + Ok(Some(json!({"role": role, "content": content}))) } // Encodes messages while grouping adjacent tool-result-only messages correctly. @@ -612,11 +807,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; } @@ -630,6 +831,8 @@ fn encode_anthropic_messages( &tool_message.content, diagnostics, policy, + &mut id_map, + &mut used_ids, )?); index += 1; } @@ -655,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. @@ -663,10 +866,17 @@ 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 { match block { + ContentBlock::Unknown { provider, raw } + if provider.as_str() == WireFormat::AnthropicMessages.as_str() => + { + blocks.push(raw.clone()); + } ContentBlock::Unknown { raw, .. } => { push_lossy( diagnostics, @@ -675,15 +885,103 @@ fn encode_anthropic_content_with_policy( )?; blocks.push(json!({"type": "text", "text": json_string(raw)})); } - other => blocks.extend(encode_one_anthropic_block(other)), + 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, + diagnostics, + policy, + 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, + diagnostics: &mut Vec, + policy: &TranslationPolicy, + id_map: &mut BTreeMap, + used_ids: &mut BTreeMap, +) -> 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) => { + 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. fn encode_anthropic_content(content: &[ContentBlock]) -> Vec { let mut blocks = content @@ -699,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, @@ -749,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) => { @@ -763,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 } => { @@ -794,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}) } } @@ -819,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 bc3688b3..94d8c570 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, &[ @@ -333,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", @@ -648,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) @@ -699,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, @@ -765,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", @@ -782,7 +784,7 @@ fn encode_message_without_tool_results_to_openai( .iter() .filter(|block| { !matches!( - block, + block.normalized(), ContentBlock::ToolCall(_) | ContentBlock::Reasoning { .. } ) }) @@ -806,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. @@ -838,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. @@ -850,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 { .. } @@ -871,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) { @@ -922,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 cf7342da..6e3ab56d 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, &[ @@ -136,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, }) @@ -698,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, }); } } @@ -735,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 } @@ -764,6 +767,7 @@ fn push_responses_id_tool( .cloned() .unwrap_or_else(|| json!({})), strict: None, + provider_payload: None, }); true } @@ -829,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())); } } @@ -843,7 +850,7 @@ fn encode_responses_input( .iter() .filter(|block| { !matches!( - block, + block.normalized(), ContentBlock::Reasoning { signature: Some(_), .. @@ -857,7 +864,7 @@ fn encode_responses_input( } if content.iter().any(|block| { matches!( - block, + block.normalized(), ContentBlock::ToolCall(_) | ContentBlock::ToolResult(_) ) }) { @@ -888,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, @@ -930,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 { .. } @@ -941,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})); @@ -986,7 +993,8 @@ fn encode_responses_content( } ContentBlock::Reasoning { .. } | ContentBlock::ToolCall(_) - | ContentBlock::ToolResult(_) => {} + | ContentBlock::ToolResult(_) + | ContentBlock::Provider { .. } => {} } } Ok(Value::Array(blocks)) @@ -1079,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(); @@ -1102,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 5f5f8d0f..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(_) ) }) @@ -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 6b4aec56..1e9e4cca 100644 --- a/crates/switchyard-translation/tests/request_translation.rs +++ b/crates/switchyard-translation/tests/request_translation.rs @@ -5,10 +5,218 @@ 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>; +// Canonical Anthropic encoding normalizes semantics through the typed request IR. +#[test] +fn anthropic_requests_are_normalized_through_ir() -> 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"} + ] + }, + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "only synthetic", "signature": ""} + ] + } + ] + }); + + let policy = TranslationPolicy { + preservation: PreservationPolicy::Disabled, + ..TranslationPolicy::default() + }; + 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"], + "Top-level rules.\n\nSystem rules.\n\nDeveloper rules." + ); + assert!(output.get("reasoning_effort").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"); + 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(()) +} + +// 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 { @@ -949,7 +1157,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 @@ -966,6 +1175,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],