diff --git a/crates/switchyard-translation/src/codecs/anthropic/buffered.rs b/crates/switchyard-translation/src/codecs/anthropic/buffered.rs index 7a8e79c9..b27aae1c 100644 --- a/crates/switchyard-translation/src/codecs/anthropic/buffered.rs +++ b/crates/switchyard-translation/src/codecs/anthropic/buffered.rs @@ -5,7 +5,7 @@ use serde_json::{Map, Value, json}; -use crate::codecs::common::{is_known_role_name, provider_extensions, text_from_blocks}; +use crate::codecs::common::{provider_extensions, text_from_blocks}; use crate::codecs::openai_chat::{decode_file_source, decode_image_source}; use crate::codecs::{ DecodedRequest, DecodedResponse, EncodedRequest, EncodedResponse, FormatCodec, @@ -21,11 +21,12 @@ use crate::llm::{ 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, + array, boolean, json_string, non_negative_integer, number, object, push_lossy, stable_id, + string, string_enum, string_value, validate_request_capabilities, }; use crate::util::{ - json_string, push_lossy, stable_id, string_value, validate_request_capabilities, + capture_request_preservation, capture_response_preservation, embed_preservation, + exact_preserved_request, exact_preserved_response, }; /// Format codec for Anthropic Messages payloads. @@ -37,18 +38,13 @@ impl FormatCodec for AnthropicMessagesCodec { } fn decode_request(&self, body: &Value, policy: &TranslationPolicy) -> Result { - let body = crate::util::object(body, "$")?; + let body = object(body, "$")?; + validate_anthropic_request(body)?; let mut diagnostics = Vec::new(); let max_output_tokens = body .get("max_tokens") - .map(|value| { - value - .as_u64() - .ok_or_else(|| TranslationError::InvalidValue { - path: "$.max_tokens".to_string(), - message: "expected a non-negative integer".to_string(), - }) - }) + .filter(|value| !value.is_null()) + .map(|value| non_negative_integer(value, "$.max_tokens")) .transpose()?; let mut request = LlmRequest { model: body @@ -101,15 +97,9 @@ 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. let role = match message.get("role").and_then(Value::as_str) { Some("assistant") => Role::Assistant, - None => Role::User, - Some(other) if is_known_role_name(other) => Role::User, + Some("user") | None => Role::User, Some(other) => { return Err(TranslationError::unsupported_role( format!("$.messages[{index}].role"), @@ -360,6 +350,209 @@ impl FormatCodec for AnthropicMessagesCodec { } } +// Validates source fields before normalization can replace malformed values with defaults. +fn validate_anthropic_request(body: &Map) -> Result<()> { + if let Some(value) = body.get("model") { + string(value, "$.model")?; + } + if let Some(value) = body.get("max_tokens") { + non_negative_integer(value, "$.max_tokens")?; + } + if let Some(value) = body.get("stream") { + boolean(value, "$.stream")?; + } + for field in ["temperature", "top_p"] { + if let Some(value) = body.get(field) { + number(value, &format!("$.{field}"))?; + } + } + if let Some(value) = body.get("top_k") { + non_negative_integer(value, "$.top_k")?; + } + if let Some(value) = body.get("system") { + validate_anthropic_system(value)?; + } + if let Some(value) = body.get("messages") { + for (index, message) in array(value, "$.messages")?.iter().enumerate() { + validate_anthropic_message(message, index)?; + } + } + if let Some(value) = body.get("tools") { + validate_anthropic_tools(value)?; + } + if let Some(value) = body.get("tool_choice") { + validate_anthropic_tool_choice(value)?; + } + if let Some(value) = body.get("thinking") { + let thinking = object(value, "$.thinking")?; + if let Some(value) = thinking.get("type") { + string_enum( + value, + "$.thinking.type", + &["enabled", "disabled", "adaptive"], + )?; + } + if let Some(value) = thinking.get("budget_tokens") { + non_negative_integer(value, "$.thinking.budget_tokens")?; + } + } + if let Some(value) = body.get("output_config") { + let output_config = object(value, "$.output_config")?; + if let Some(value) = output_config.get("effort").filter(|value| !value.is_null()) { + string_enum( + value, + "$.output_config.effort", + &["low", "medium", "high", "xhigh", "max"], + )?; + } + if let Some(value) = output_config.get("format").filter(|value| !value.is_null()) { + object(value, "$.output_config.format")?; + } + } + Ok(()) +} + +fn validate_anthropic_system(value: &Value) -> Result<()> { + match value { + Value::String(_) => Ok(()), + Value::Array(blocks) => { + for (index, block) in blocks.iter().enumerate() { + let path = format!("$.system[{index}]"); + let block = object(block, &path)?; + if let Some(value) = block.get("type") { + string_enum(value, &format!("{path}.type"), &["text"])?; + } + if let Some(value) = block.get("text") { + string(value, &format!("{path}.text"))?; + } + } + Ok(()) + } + _ => Err(TranslationError::InvalidType { + path: "$.system".to_string(), + expected: "string or array of text blocks", + }), + } +} + +fn validate_anthropic_message(value: &Value, index: usize) -> Result<()> { + let path = format!("$.messages[{index}]"); + let message = object(value, &path)?; + if let Some(value) = message.get("role") { + string_enum(value, &format!("{path}.role"), &["user", "assistant"])?; + } + if let Some(value) = message.get("content") { + validate_anthropic_content_container(value, &format!("{path}.content"))?; + } + Ok(()) +} + +fn validate_anthropic_content_container(value: &Value, path: &str) -> Result<()> { + match value { + Value::String(_) => Ok(()), + Value::Array(blocks) => { + for (index, block) in blocks.iter().enumerate() { + validate_anthropic_content_block_fields(block, &format!("{path}[{index}]"))?; + } + Ok(()) + } + _ => Err(TranslationError::InvalidType { + path: path.to_string(), + expected: "string or array", + }), + } +} + +fn validate_anthropic_content_block_fields(value: &Value, path: &str) -> Result<()> { + let block = object(value, path)?; + let Some(block_type) = block.get("type") else { + return Ok(()); + }; + let block_type = string(block_type, &format!("{path}.type"))?; + match block_type { + "text" => { + if let Some(value) = block.get("text") { + string(value, &format!("{path}.text"))?; + } + } + "thinking" => { + for field in ["thinking", "signature"] { + if let Some(value) = block.get(field) { + string(value, &format!("{path}.{field}"))?; + } + } + } + "tool_use" => { + for field in ["id", "name"] { + if let Some(value) = block.get(field) { + string(value, &format!("{path}.{field}"))?; + } + } + if let Some(value) = block.get("input") { + object(value, &format!("{path}.input"))?; + } + } + "tool_result" => { + if let Some(value) = block.get("tool_use_id") { + string(value, &format!("{path}.tool_use_id"))?; + } + if let Some(value) = block.get("is_error") { + boolean(value, &format!("{path}.is_error"))?; + } + if let Some(value) = block.get("content") { + validate_anthropic_content_container(value, &format!("{path}.content"))?; + } + } + "image" | "document" => { + if let Some(value) = block.get("source") { + object(value, &format!("{path}.source"))?; + } + } + _ => {} + } + Ok(()) +} + +fn validate_anthropic_tools(value: &Value) -> Result<()> { + for (index, tool) in array(value, "$.tools")?.iter().enumerate() { + let path = format!("$.tools[{index}]"); + let tool = object(tool, &path)?; + if let Some(value) = tool.get("type") { + string(value, &format!("{path}.type"))?; + } + if let Some(value) = tool.get("name") { + string(value, &format!("{path}.name"))?; + } + if let Some(value) = tool.get("description") { + string(value, &format!("{path}.description"))?; + } + if let Some(value) = tool.get("input_schema") { + object(value, &format!("{path}.input_schema"))?; + } + } + Ok(()) +} + +fn validate_anthropic_tool_choice(value: &Value) -> Result<()> { + let choice = object(value, "$.tool_choice")?; + if let Some(value) = choice.get("type") { + let choice_type = string_enum( + value, + "$.tool_choice.type", + &["auto", "any", "tool", "none"], + )?; + if choice_type == "tool" + && let Some(value) = choice.get("name") + { + string(value, "$.tool_choice.name")?; + } + } + if let Some(value) = choice.get("disable_parallel_tool_use") { + boolean(value, "$.tool_choice.disable_parallel_tool_use")?; + } + Ok(()) +} + /// Maps the neutral OpenAI-shaped JSON schema to Anthropic's output format. fn encode_anthropic_output_format( response_format: &Value, diff --git a/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs b/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs index dbe77ca2..623af833 100644 --- a/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs +++ b/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs @@ -21,9 +21,10 @@ use crate::llm::{ }; use crate::policy::{DeterministicIdPolicy, TranslationPolicy}; use crate::util::{ - capture_request_preservation, capture_response_preservation, embed_preservation, - exact_preserved_request, exact_preserved_response, json_string, object, push_lossy, stable_id, - string_value, validate_request_capabilities, + array, boolean, capture_request_preservation, capture_response_preservation, + embed_preservation, exact_preserved_request, exact_preserved_response, json_string, + non_negative_integer, number, object, push_lossy, stable_id, string, string_enum, string_value, + validate_request_capabilities, }; /// Format codec for OpenAI Chat Completions payloads. @@ -36,6 +37,7 @@ impl FormatCodec for OpenAiChatCodec { fn decode_request(&self, body: &Value, policy: &TranslationPolicy) -> Result { let body = object(body, "$")?; + validate_openai_chat_request(body)?; let mut diagnostics = Vec::new(); let mut request = LlmRequest { model: body @@ -387,6 +389,243 @@ impl FormatCodec for OpenAiChatCodec { } } +// Validates source fields before normalization can replace malformed values with defaults. +fn validate_openai_chat_request(body: &Map) -> Result<()> { + if let Some(value) = body.get("model") { + string(value, "$.model")?; + } + if let Some(value) = body.get("stream").filter(|value| !value.is_null()) { + boolean(value, "$.stream")?; + } + for field in ["temperature", "top_p"] { + if let Some(value) = body.get(field).filter(|value| !value.is_null()) { + number(value, &format!("$.{field}"))?; + } + } + for field in ["max_completion_tokens", "max_tokens"] { + if let Some(value) = body.get(field).filter(|value| !value.is_null()) { + non_negative_integer(value, &format!("$.{field}"))?; + } + } + if let Some(value) = body.get("response_format").filter(|value| !value.is_null()) { + object(value, "$.response_format")?; + } + if let Some(value) = body + .get("reasoning_effort") + .filter(|value| !value.is_null()) + { + string_enum( + value, + "$.reasoning_effort", + &["none", "minimal", "low", "medium", "high", "xhigh", "max"], + )?; + } + if let Some(value) = body.get("messages") { + for (index, message) in array(value, "$.messages")?.iter().enumerate() { + validate_openai_chat_message(message, index)?; + } + } + if let Some(value) = body.get("tools").filter(|value| !value.is_null()) { + validate_openai_chat_tools(value)?; + } + if let Some(value) = body.get("tool_choice").filter(|value| !value.is_null()) { + validate_openai_chat_tool_choice(value)?; + } + Ok(()) +} + +fn validate_openai_chat_message(value: &Value, index: usize) -> Result<()> { + let path = format!("$.messages[{index}]"); + let message = object(value, &path)?; + let role = match message.get("role") { + Some(value) => Some(string_enum( + value, + &format!("{path}.role"), + &[ + "system", + "developer", + "user", + "assistant", + "tool", + "function", + ], + )?), + None => None, + }; + if let Some(content) = message.get("content") { + validate_openai_chat_content(content, role, &format!("{path}.content"))?; + } + if let Some(tool_calls) = message.get("tool_calls").filter(|value| !value.is_null()) { + for (call_index, tool_call) in array(tool_calls, &format!("{path}.tool_calls"))? + .iter() + .enumerate() + { + validate_openai_chat_tool_call(tool_call, &format!("{path}.tool_calls[{call_index}]"))?; + } + } + if let Some(tool_call_id) = message.get("tool_call_id").filter(|value| !value.is_null()) { + string(tool_call_id, &format!("{path}.tool_call_id"))?; + } + Ok(()) +} + +fn validate_openai_chat_content(value: &Value, role: Option<&str>, path: &str) -> Result<()> { + match value { + Value::String(_) => Ok(()), + Value::Null if matches!(role, Some("assistant" | "function")) => Ok(()), + Value::Array(blocks) => { + for (index, block) in blocks.iter().enumerate() { + validate_openai_content_block(block, &format!("{path}[{index}]"))?; + } + Ok(()) + } + _ => Err(TranslationError::InvalidType { + path: path.to_string(), + expected: "string or array", + }), + } +} + +fn validate_openai_content_block(value: &Value, path: &str) -> Result<()> { + let block = object(value, path)?; + let Some(block_type) = block.get("type") else { + return Ok(()); + }; + let block_type = string(block_type, &format!("{path}.type"))?; + match block_type { + "text" | "input_text" | "output_text" | "reasoning_text" | "summary_text" => { + if let Some(value) = block.get("text") { + string(value, &format!("{path}.text"))?; + } + } + "refusal" => { + if let Some(value) = block.get("refusal") { + string(value, &format!("{path}.refusal"))?; + } + } + "image_url" => { + if let Some(value) = block.get("image_url") { + let image = object(value, &format!("{path}.image_url"))?; + if let Some(value) = image.get("url") { + string(value, &format!("{path}.image_url.url"))?; + } + if let Some(value) = image.get("detail").filter(|value| !value.is_null()) { + string_enum( + value, + &format!("{path}.image_url.detail"), + &["auto", "low", "high"], + )?; + } + } + } + "file" => { + if let Some(value) = block.get("file") { + let file = object(value, &format!("{path}.file"))?; + for field in ["file_data", "file_id", "filename"] { + if let Some(value) = file.get(field).filter(|value| !value.is_null()) { + string(value, &format!("{path}.file.{field}"))?; + } + } + } + } + _ => {} + } + Ok(()) +} + +fn validate_openai_chat_tool_call(value: &Value, path: &str) -> Result<()> { + let tool_call = object(value, path)?; + if let Some(value) = tool_call.get("id") { + string(value, &format!("{path}.id"))?; + } + let Some(call_type) = tool_call.get("type") else { + return Ok(()); + }; + let call_type = string_enum(call_type, &format!("{path}.type"), &["function", "custom"])?; + if call_type != "function" { + return Ok(()); + } + let Some(function) = tool_call.get("function") else { + return Ok(()); + }; + let function = object(function, &format!("{path}.function"))?; + for field in ["name", "arguments"] { + if let Some(value) = function.get(field) { + string(value, &format!("{path}.function.{field}"))?; + } + } + Ok(()) +} + +fn validate_openai_chat_tools(value: &Value) -> Result<()> { + for (index, tool) in array(value, "$.tools")?.iter().enumerate() { + let path = format!("$.tools[{index}]"); + let tool = object(tool, &path)?; + let Some(tool_type) = tool.get("type") else { + continue; + }; + let tool_type = string(tool_type, &format!("{path}.type"))?; + if tool_type != "function" { + continue; + } + let Some(function) = tool.get("function") else { + continue; + }; + validate_function_definition( + object(function, &format!("{path}.function"))?, + &format!("{path}.function"), + )?; + } + Ok(()) +} + +pub(crate) fn validate_function_definition( + function: &Map, + path: &str, +) -> Result<()> { + if let Some(value) = function.get("name") { + string(value, &format!("{path}.name"))?; + } + if let Some(value) = function.get("description").filter(|value| !value.is_null()) { + string(value, &format!("{path}.description"))?; + } + if let Some(value) = function.get("parameters").filter(|value| !value.is_null()) { + object(value, &format!("{path}.parameters"))?; + } + if let Some(value) = function.get("strict").filter(|value| !value.is_null()) { + boolean(value, &format!("{path}.strict"))?; + } + Ok(()) +} + +fn validate_openai_chat_tool_choice(value: &Value) -> Result<()> { + match value { + Value::String(_) => { + string_enum(value, "$.tool_choice", &["none", "auto", "required"])?; + } + Value::Object(choice) => { + if let Some(value) = choice.get("type") { + let choice_type = string(value, "$.tool_choice.type")?; + if choice_type == "function" + && let Some(function) = choice.get("function") + { + let function = object(function, "$.tool_choice.function")?; + if let Some(name) = function.get("name") { + string(name, "$.tool_choice.function.name")?; + } + } + } + } + _ => { + return Err(TranslationError::InvalidType { + path: "$.tool_choice".to_string(), + expected: "string or object", + }); + } + } + Ok(()) +} + // Pulls OpenAI-compatible reasoning fields into private reasoning IR blocks. fn prepend_openai_reasoning_blocks(content: &mut Vec, object: &Map) { let reasoning = ["reasoning_content", "reasoning"] @@ -411,9 +650,9 @@ fn prepend_openai_reasoning_blocks(content: &mut Vec, object: &Map /// /// Unknown role strings are rejected with [`TranslationError::InvalidValue`] so /// the router returns the same `invalid_value` error the provider would, rather -/// than silently coercing an invalid role to `user`. A missing role and -/// known-but-unmapped roles (e.g. the legacy `function` role) keep their -/// historical mapping to `user`. `path` points at the offending field. +/// than silently coercing an invalid role to `user`. A missing role and the +/// legacy `function` role keep their historical mapping to `user`. `path` +/// points at the offending field. pub(crate) fn role_from_openai(role: Option<&str>, path: &str) -> Result { match role { Some("system") => Ok(Role::System), diff --git a/crates/switchyard-translation/src/codecs/openai_chat/mod.rs b/crates/switchyard-translation/src/codecs/openai_chat/mod.rs index 766f312c..75ca8295 100644 --- a/crates/switchyard-translation/src/codecs/openai_chat/mod.rs +++ b/crates/switchyard-translation/src/codecs/openai_chat/mod.rs @@ -9,4 +9,4 @@ mod stream; pub use buffered::OpenAiChatCodec; pub use stream::OpenAiChatStreamCodec; -pub(crate) use buffered::{decode_file_source, decode_image_source}; +pub(crate) use buffered::{decode_file_source, decode_image_source, validate_function_definition}; diff --git a/crates/switchyard-translation/src/codecs/responses/buffered.rs b/crates/switchyard-translation/src/codecs/responses/buffered.rs index 5d3b9e88..46bf1822 100644 --- a/crates/switchyard-translation/src/codecs/responses/buffered.rs +++ b/crates/switchyard-translation/src/codecs/responses/buffered.rs @@ -7,10 +7,10 @@ use std::collections::HashSet; use serde_json::{Map, Value, json}; -use crate::codecs::common::{ - is_known_role_name, provider_extensions, reasoning_text_from_blocks, text_from_blocks, +use crate::codecs::common::{provider_extensions, reasoning_text_from_blocks, text_from_blocks}; +use crate::codecs::openai_chat::{ + decode_file_source, decode_image_source, validate_function_definition, }; -use crate::codecs::openai_chat::{decode_file_source, decode_image_source}; use crate::codecs::{ DecodedRequest, DecodedResponse, EncodedRequest, EncodedResponse, FormatCodec, }; @@ -18,15 +18,16 @@ use crate::diagnostic::TranslationDiagnostic; use crate::error::{Result, TranslationError}; use crate::format::{FormatId, WireFormat}; use crate::llm::{ - AggLlmResponse, ContentBlock, LlmRequest, MediaSource, Message, OutputParams, - ProviderExtensions, ReasoningParams, ResponseOutput, Role, SamplingParams, StopReason, - ToolCall, ToolChoice, ToolDefinition, ToolResult, Usage, + AggLlmResponse, ContentBlock, FileSource, ImageSource, LlmRequest, MediaSource, Message, + OutputParams, ProviderExtensions, ReasoningParams, ResponseOutput, Role, SamplingParams, + StopReason, ToolCall, ToolChoice, ToolDefinition, ToolResult, Usage, }; use crate::policy::{DeterministicIdPolicy, TranslationPolicy}; use crate::util::{ - capture_request_preservation, capture_response_preservation, embed_preservation, - exact_preserved_request, exact_preserved_response, json_string, push_lossy, stable_id, - string_value, validate_request_capabilities, + array, boolean, capture_request_preservation, capture_response_preservation, + embed_preservation, exact_preserved_request, exact_preserved_response, json_string, + non_negative_integer, number, object, push_lossy, stable_id, string, string_enum, string_value, + validate_request_capabilities, }; /// Format codec for OpenAI Responses payloads. @@ -38,7 +39,8 @@ impl FormatCodec for OpenAiResponsesCodec { } fn decode_request(&self, body: &Value, policy: &TranslationPolicy) -> Result { - let body = crate::util::object(body, "$")?; + let body = object(body, "$")?; + validate_responses_request(body)?; let mut diagnostics = Vec::new(); let mut request = LlmRequest { model: body @@ -301,6 +303,239 @@ impl FormatCodec for OpenAiResponsesCodec { } } +// Validates source fields before normalization can replace malformed values with defaults. +fn validate_responses_request(body: &Map) -> Result<()> { + if let Some(value) = body.get("model") { + string(value, "$.model")?; + } + if let Some(value) = body.get("stream").filter(|value| !value.is_null()) { + boolean(value, "$.stream")?; + } + for field in ["temperature", "top_p"] { + if let Some(value) = body.get(field).filter(|value| !value.is_null()) { + number(value, &format!("$.{field}"))?; + } + } + if let Some(value) = body + .get("max_output_tokens") + .filter(|value| !value.is_null()) + { + non_negative_integer(value, "$.max_output_tokens")?; + } + if let Some(value) = body.get("text").filter(|value| !value.is_null()) { + let text = object(value, "$.text")?; + if let Some(format) = text.get("format").filter(|value| !value.is_null()) { + let format = object(format, "$.text.format")?; + if let Some(value) = format.get("type") { + string(value, "$.text.format.type")?; + } + } + } + if let Some(value) = body.get("reasoning").filter(|value| !value.is_null()) { + let reasoning = object(value, "$.reasoning")?; + if let Some(value) = reasoning.get("effort").filter(|value| !value.is_null()) { + string_enum( + value, + "$.reasoning.effort", + &["none", "minimal", "low", "medium", "high", "xhigh", "max"], + )?; + } + } + if let Some(value) = body.get("instructions").filter(|value| !value.is_null()) { + string(value, "$.instructions")?; + } + if let Some(value) = body.get("input") { + validate_responses_input_container(value, "$.input")?; + } + if let Some(value) = body.get("tools").filter(|value| !value.is_null()) { + validate_responses_tools(value)?; + } + if let Some(value) = body.get("tool_choice").filter(|value| !value.is_null()) { + validate_responses_tool_choice(value)?; + } + Ok(()) +} + +fn validate_responses_input_container(value: &Value, path: &str) -> Result<()> { + match value { + Value::String(_) => Ok(()), + Value::Array(items) => { + for (index, item) in items.iter().enumerate() { + validate_responses_input_item(item, &format!("{path}[{index}]"))?; + } + Ok(()) + } + _ => Err(TranslationError::InvalidType { + path: path.to_string(), + expected: "string or array", + }), + } +} + +fn validate_responses_input_item(value: &Value, path: &str) -> Result<()> { + let item = object(value, path)?; + let item_type = item + .get("type") + .map(|value| string(value, &format!("{path}.type"))) + .transpose()?; + match item_type { + Some("message") | None if item.contains_key("role") || item.contains_key("content") => { + if let Some(value) = item.get("role") { + string_enum( + value, + &format!("{path}.role"), + &["user", "assistant", "system", "developer"], + )?; + } + if let Some(value) = item.get("content") { + validate_responses_content_container(value, &format!("{path}.content"))?; + } + } + Some("function_call") => { + for field in ["call_id", "name", "arguments"] { + if let Some(value) = item.get(field) { + string(value, &format!("{path}.{field}"))?; + } + } + } + Some("function_call_output") => { + if let Some(value) = item.get("call_id") { + string(value, &format!("{path}.call_id"))?; + } + if let Some(value) = item.get("output") { + validate_responses_content_container(value, &format!("{path}.output"))?; + } + } + Some("reasoning") => { + if let Some(value) = item.get("text").filter(|value| !value.is_null()) { + string(value, &format!("{path}.text"))?; + } + for field in ["content", "summary"] { + if let Some(value) = item.get(field).filter(|value| !value.is_null()) { + let blocks = array(value, &format!("{path}.{field}"))?; + for (index, block) in blocks.iter().enumerate() { + validate_responses_content_block( + block, + &format!("{path}.{field}[{index}]"), + )?; + } + } + } + } + _ => {} + } + Ok(()) +} + +fn validate_responses_content_container(value: &Value, path: &str) -> Result<()> { + match value { + Value::String(_) => Ok(()), + Value::Array(blocks) => { + for (index, block) in blocks.iter().enumerate() { + validate_responses_content_block(block, &format!("{path}[{index}]"))?; + } + Ok(()) + } + _ => Err(TranslationError::InvalidType { + path: path.to_string(), + expected: "string or array", + }), + } +} + +fn validate_responses_content_block(value: &Value, path: &str) -> Result<()> { + let block = object(value, path)?; + let Some(block_type) = block.get("type") else { + return Ok(()); + }; + let block_type = string(block_type, &format!("{path}.type"))?; + match block_type { + "input_text" | "output_text" | "text" | "reasoning_text" | "summary_text" => { + if let Some(value) = block.get("text") { + string(value, &format!("{path}.text"))?; + } + } + "refusal" => { + if let Some(value) = block.get("refusal") { + string(value, &format!("{path}.refusal"))?; + } + } + "input_image" => { + if let Some(value) = block.get("image_url").filter(|value| !value.is_null()) { + string(value, &format!("{path}.image_url"))?; + } + if let Some(value) = block.get("file_id").filter(|value| !value.is_null()) { + string(value, &format!("{path}.file_id"))?; + } + if let Some(value) = block.get("detail").filter(|value| !value.is_null()) { + string_enum(value, &format!("{path}.detail"), &["auto", "low", "high"])?; + } + } + "input_file" => { + for field in ["file_data", "file_id", "file_url", "filename"] { + if let Some(value) = block.get(field).filter(|value| !value.is_null()) { + string(value, &format!("{path}.{field}"))?; + } + } + } + _ => {} + } + Ok(()) +} + +fn validate_responses_tools(value: &Value) -> Result<()> { + for (index, tool) in array(value, "$.tools")?.iter().enumerate() { + let path = format!("$.tools[{index}]"); + let tool = object(tool, &path)?; + let tool_type = tool + .get("type") + .map(|value| string(value, &format!("{path}.type"))) + .transpose()?; + if tool_type == Some("function") { + if let Some(function) = tool.get("function") { + validate_function_definition( + object(function, &format!("{path}.function"))?, + &format!("{path}.function"), + )?; + } else { + validate_function_definition(tool, &path)?; + } + } + if let Some(value) = tool.get("id").filter(|value| !value.is_null()) { + string(value, &format!("{path}.id"))?; + } + if let Some(value) = tool.get("inputSchema").filter(|value| !value.is_null()) { + object(value, &format!("{path}.inputSchema"))?; + } + } + Ok(()) +} + +fn validate_responses_tool_choice(value: &Value) -> Result<()> { + match value { + Value::String(_) => { + string_enum(value, "$.tool_choice", &["none", "auto", "required"])?; + } + Value::Object(choice) => { + if let Some(value) = choice.get("type") { + let choice_type = string(value, "$.tool_choice.type")?; + if choice_type == "function" + && let Some(value) = choice.get("name") + { + string(value, "$.tool_choice.name")?; + } + } + } + _ => { + return Err(TranslationError::InvalidType { + path: "$.tool_choice".to_string(), + expected: "string or object", + }); + } + } + Ok(()) +} + // Decodes Responses `input` into ordered normalized messages. fn decode_responses_input( value: &Value, @@ -695,15 +930,14 @@ fn role_from_responses(role: Option<&str>) -> Role { // Unlike `role_from_responses` (used for provider responses), request decoding // rejects an unknown role such as "api" with [`TranslationError::InvalidValue`] // so the router surfaces the same `invalid_value` error the provider would, -// instead of silently coercing it to `user`. A missing role and -// known-but-unmapped roles keep their historical mapping to `user`. +// instead of silently coercing it to `user`. A missing role keeps its +// historical mapping to `user`. fn request_role_from_responses(role: Option<&str>, path: &str) -> Result { match role { Some("assistant") => Ok(Role::Assistant), Some("system") => Ok(Role::System), Some("developer") => Ok(Role::Developer), - None => Ok(Role::User), - Some(other) if is_known_role_name(other) => Ok(Role::User), + Some("user") | None => Ok(Role::User), Some(other) => Err(TranslationError::unsupported_role(path, other)), } } @@ -980,9 +1214,30 @@ fn encode_responses_content( ContentBlock::Refusal { text } => { blocks.push(json!({"type": "refusal", "refusal": text})); } - ContentBlock::Image { source } => { - blocks.push(json!({"type": "input_image", "image_url": source})); - } + ContentBlock::Image { source } => match source { + ImageSource::Url { url, detail } => { + let mut block = json!({"type": "input_image", "image_url": url}); + if let Some(detail) = detail { + block["detail"] = Value::String(detail.clone()); + } + blocks.push(block); + } + ImageSource::Base64 { media_type, data } => { + let media_type = media_type.as_deref().unwrap_or("image/png"); + blocks.push(json!({ + "type": "input_image", + "image_url": format!("data:{media_type};base64,{data}"), + })); + } + ImageSource::Raw(raw) => { + push_lossy( + diagnostics, + policy, + "raw image source encoded as text for Responses", + )?; + blocks.push(json!({"type": "input_text", "text": json_string(raw)})); + } + }, ContentBlock::Audio { source } => blocks.push(match source { MediaSource::Raw(raw) => json!({"type": "input_text", "text": json_string(raw)}), MediaSource::Url { url, media_type } => json!({ @@ -1007,9 +1262,26 @@ fn encode_responses_content( "video": {"media_type": media_type, "data": data}, }), }), - ContentBlock::File { source } => { - blocks.push(json!({"type": "input_file", "file": source})); - } + ContentBlock::File { source } => match source { + FileSource::FileId(file_id) => { + blocks.push(json!({"type": "input_file", "file_id": file_id})); + } + FileSource::FileData { data, filename } => { + let mut block = json!({"type": "input_file", "file_data": data}); + if let Some(filename) = filename { + block["filename"] = Value::String(filename.clone()); + } + blocks.push(block); + } + FileSource::Raw(raw) => { + push_lossy( + diagnostics, + policy, + "raw file source encoded as text for Responses", + )?; + blocks.push(json!({"type": "input_text", "text": json_string(raw)})); + } + }, ContentBlock::Unknown { raw, .. } => { push_lossy( diagnostics, diff --git a/crates/switchyard-translation/src/util.rs b/crates/switchyard-translation/src/util.rs index 9fcf769f..ee625225 100644 --- a/crates/switchyard-translation/src/util.rs +++ b/crates/switchyard-translation/src/util.rs @@ -30,6 +30,69 @@ pub fn object<'a>(value: &'a Value, path: &str) -> Result<&'a Map }) } +pub(crate) fn array<'a>(value: &'a Value, path: &str) -> Result<&'a [Value]> { + value + .as_array() + .map(Vec::as_slice) + .ok_or_else(|| TranslationError::InvalidType { + path: path.to_string(), + expected: "array", + }) +} + +pub(crate) fn string<'a>(value: &'a Value, path: &str) -> Result<&'a str> { + value.as_str().ok_or_else(|| TranslationError::InvalidType { + path: path.to_string(), + expected: "string", + }) +} + +pub(crate) fn boolean(value: &Value, path: &str) -> Result { + value + .as_bool() + .ok_or_else(|| TranslationError::InvalidType { + path: path.to_string(), + expected: "boolean", + }) +} + +pub(crate) fn number(value: &Value, path: &str) -> Result { + value.as_f64().ok_or_else(|| TranslationError::InvalidType { + path: path.to_string(), + expected: "number", + }) +} + +// Distinguishes a wrong JSON type from a numeric value that is not a non-negative integer. +pub(crate) fn non_negative_integer(value: &Value, path: &str) -> Result { + let Value::Number(number) = value else { + return Err(TranslationError::InvalidType { + path: path.to_string(), + expected: "non-negative integer", + }); + }; + number + .as_u64() + .ok_or_else(|| TranslationError::InvalidValue { + path: path.to_string(), + message: "expected a non-negative integer".to_string(), + }) +} + +pub(crate) fn string_enum<'a>(value: &'a Value, path: &str, allowed: &[&str]) -> Result<&'a str> { + let value = string(value, path)?; + if allowed.contains(&value) { + return Ok(value); + } + Err(TranslationError::InvalidValue { + path: path.to_string(), + message: format!( + "unsupported value {value:?}; expected one of {}", + allowed.join(", ") + ), + }) +} + /// Converts JSON scalars to Python-compatible string values where providers do so. pub fn string_value(value: &Value) -> Option { match value { diff --git a/crates/switchyard-translation/tests/lossless_roundtrip.rs b/crates/switchyard-translation/tests/lossless_roundtrip.rs index c9375b48..a8b088f6 100644 --- a/crates/switchyard-translation/tests/lossless_roundtrip.rs +++ b/crates/switchyard-translation/tests/lossless_roundtrip.rs @@ -463,14 +463,12 @@ fn request_fixture(format: WireFormat) -> Value { {"type": "input_text", "text": "Inspect this payload."}, { "type": "input_image", - "image_url": { - "url": "https://example.test/image.png", - "detail": "high" - } + "image_url": "https://example.test/image.png", + "detail": "high" }, { "type": "input_file", - "file": {"file_id": "file_123"} + "file_id": "file_123" }, { "type": "vendor_block", @@ -483,12 +481,12 @@ fn request_fixture(format: WireFormat) -> Value { "type": "function_call", "call_id": "call_lookup", "name": "lookup", - "arguments": {"query": "rust", "limit": 2} + "arguments": "{\"query\":\"rust\",\"limit\":2}" }, { "type": "function_call_output", "call_id": "call_lookup", - "output": {"ok": true, "items": [1, 2]} + "output": "{\"ok\":true,\"items\":[1,2]}" }, { "type": "local_shell_call", diff --git a/crates/switchyard-translation/tests/request_translation.rs b/crates/switchyard-translation/tests/request_translation.rs index bfcf7862..e72e5f9d 100644 --- a/crates/switchyard-translation/tests/request_translation.rs +++ b/crates/switchyard-translation/tests/request_translation.rs @@ -623,9 +623,9 @@ fn responses_function_call_arguments_parse_for_anthropic_tool_use() -> TestResul Ok(()) } -// Verifies malformed Responses arguments still produce object-shaped Anthropic input. +// Verifies non-object JSON arguments still produce object-shaped Anthropic input. #[test] -fn responses_function_call_arguments_wrap_non_object_values_for_anthropic() -> TestResult { +fn responses_function_call_arguments_wrap_non_object_json_for_anthropic() -> TestResult { let engine = TranslationEngine::default(); let body = json!({ "model": "gpt-4", @@ -647,7 +647,7 @@ fn responses_function_call_arguments_wrap_non_object_values_for_anthropic() -> T "type": "function_call", "name": "object_value", "call_id": "call_object", - "arguments": {"already": "object"} + "arguments": "{\"already\":\"object\"}" } ] }); @@ -1181,54 +1181,356 @@ fn malformed_request_fields_are_rejected() { let engine = TranslationEngine::default(); let cases = [ ( - "Anthropic object system", + "Chat model type", + WireFormat::OpenAiChat, + json!({"model": false, "messages": []}), + "InvalidType", + "expected string at $.model", + ), + ( + "Chat stream type", + WireFormat::OpenAiChat, + json!({"model": "gpt", "messages": [], "stream": "yes"}), + "InvalidType", + "expected boolean at $.stream", + ), + ( + "Chat token value", + WireFormat::OpenAiChat, + json!({"model": "gpt", "messages": [], "max_completion_tokens": -1}), + "InvalidValue", + "invalid value at $.max_completion_tokens: expected a non-negative integer", + ), + ( + "Chat reasoning effort value", + WireFormat::OpenAiChat, + json!({"model": "gpt", "messages": [], "reasoning_effort": "extreme"}), + "InvalidValue", + "invalid value at $.reasoning_effort: unsupported value \"extreme\"; expected one of none, minimal, low, medium, high, xhigh, max", + ), + ( + "Chat messages type", + WireFormat::OpenAiChat, + json!({"model": "gpt", "messages": {}}), + "InvalidType", + "expected array at $.messages", + ), + ( + "Chat message type", + WireFormat::OpenAiChat, + json!({"model": "gpt", "messages": [false]}), + "InvalidType", + "expected object at $.messages[0]", + ), + ( + "Chat role type", + WireFormat::OpenAiChat, + json!({"model": "gpt", "messages": [{"role": false}]}), + "InvalidType", + "expected string at $.messages[0].role", + ), + ( + "Chat role value", + WireFormat::OpenAiChat, + json!({"model": "gpt", "messages": [{"role": "api"}]}), + "InvalidValue", + "invalid value at $.messages[0].role: unsupported value \"api\"; expected one of system, developer, user, assistant, tool, function", + ), + ( + "Chat content type", + WireFormat::OpenAiChat, + json!({"model": "gpt", "messages": [{"role": "user", "content": false}]}), + "InvalidType", + "expected string or array at $.messages[0].content", + ), + ( + "Chat content block field", + WireFormat::OpenAiChat, + json!({"model": "gpt", "messages": [{"role": "user", "content": [{"type": "text", "text": false}]}]}), + "InvalidType", + "expected string at $.messages[0].content[0].text", + ), + ( + "Chat tool calls type", + WireFormat::OpenAiChat, + json!({"model": "gpt", "messages": [{"role": "assistant", "tool_calls": {}}]}), + "InvalidType", + "expected array at $.messages[0].tool_calls", + ), + ( + "Chat tool call arguments type", + WireFormat::OpenAiChat, + json!({"model": "gpt", "messages": [{"role": "assistant", "tool_calls": [{"type": "function", "function": {"arguments": {}}}]}]}), + "InvalidType", + "expected string at $.messages[0].tool_calls[0].function.arguments", + ), + ( + "Chat tools type", + WireFormat::OpenAiChat, + json!({"model": "gpt", "messages": [], "tools": {}}), + "InvalidType", + "expected array at $.tools", + ), + ( + "Chat function strict type", + WireFormat::OpenAiChat, + json!({"model": "gpt", "messages": [], "tools": [{"type": "function", "function": {"strict": "yes"}}]}), + "InvalidType", + "expected boolean at $.tools[0].function.strict", + ), + ( + "Chat tool choice type", + WireFormat::OpenAiChat, + json!({"model": "gpt", "messages": [], "tool_choice": false}), + "InvalidType", + "expected string or object at $.tool_choice", + ), + ( + "Responses boolean input", + WireFormat::OpenAiResponses, + json!({"model": "gpt", "input": true}), + "InvalidType", + "expected string or array at $.input", + ), + ( + "Responses null input", + WireFormat::OpenAiResponses, + json!({"model": "gpt", "input": null}), + "InvalidType", + "expected string or array at $.input", + ), + ( + "Responses input item type", + WireFormat::OpenAiResponses, + json!({"model": "gpt", "input": [1]}), + "InvalidType", + "expected object at $.input[0]", + ), + ( + "Responses instructions type", + WireFormat::OpenAiResponses, + json!({"model": "gpt", "instructions": [], "input": "ok"}), + "InvalidType", + "expected string at $.instructions", + ), + ( + "Responses role value", + WireFormat::OpenAiResponses, + json!({"model": "gpt", "input": [{"type": "message", "role": "tool", "content": "ok"}]}), + "InvalidValue", + "invalid value at $.input[0].role: unsupported value \"tool\"; expected one of user, assistant, system, developer", + ), + ( + "Responses content type", + WireFormat::OpenAiResponses, + json!({"model": "gpt", "input": [{"type": "message", "role": "user", "content": {}}]}), + "InvalidType", + "expected string or array at $.input[0].content", + ), + ( + "Responses image URL type", + WireFormat::OpenAiResponses, + json!({"model": "gpt", "input": [{"type": "message", "role": "user", "content": [{"type": "input_image", "image_url": {}}]}]}), + "InvalidType", + "expected string at $.input[0].content[0].image_url", + ), + ( + "Responses function arguments type", + WireFormat::OpenAiResponses, + json!({"model": "gpt", "input": [{"type": "function_call", "arguments": {}}]}), + "InvalidType", + "expected string at $.input[0].arguments", + ), + ( + "Responses function output type", + WireFormat::OpenAiResponses, + json!({"model": "gpt", "input": [{"type": "function_call_output", "output": {}}]}), + "InvalidType", + "expected string or array at $.input[0].output", + ), + ( + "Responses tools type", + WireFormat::OpenAiResponses, + json!({"model": "gpt", "input": "ok", "tools": {}}), + "InvalidType", + "expected array at $.tools", + ), + ( + "Responses custom tool schema type", + WireFormat::OpenAiResponses, + json!({"model": "gpt", "input": "ok", "tools": [{"type": "custom", "inputSchema": []}]}), + "InvalidType", + "expected object at $.tools[0].inputSchema", + ), + ( + "Responses tool choice type", + WireFormat::OpenAiResponses, + json!({"model": "gpt", "input": "ok", "tool_choice": false}), + "InvalidType", + "expected string or object at $.tool_choice", + ), + ( + "Responses token value", + WireFormat::OpenAiResponses, + json!({"model": "gpt", "input": "ok", "max_output_tokens": 1.5}), + "InvalidValue", + "invalid value at $.max_output_tokens: expected a non-negative integer", + ), + ( + "Responses reasoning effort value", + WireFormat::OpenAiResponses, + json!({"model": "gpt", "input": "ok", "reasoning": {"effort": "extreme"}}), + "InvalidValue", + "invalid value at $.reasoning.effort: unsupported value \"extreme\"; expected one of none, minimal, low, medium, high, xhigh, max", + ), + ( + "Anthropic null system", WireFormat::AnthropicMessages, - json!({"model": "claude", "max_tokens": 8, "system": {}, "messages": []}), + json!({"model": "claude", "max_tokens": 8, "system": null, "messages": []}), + "InvalidType", "expected string or array of text blocks at $.system", ), ( - "Anthropic boolean system", + "Anthropic system block type", WireFormat::AnthropicMessages, - json!({"model": "claude", "max_tokens": 8, "system": true, "messages": []}), - "expected string or array of text blocks at $.system", + json!({"model": "claude", "max_tokens": 8, "system": [false], "messages": []}), + "InvalidType", + "expected object at $.system[0]", + ), + ( + "Anthropic system text type", + WireFormat::AnthropicMessages, + json!({"model": "claude", "max_tokens": 8, "system": [{"type": "text", "text": false}], "messages": []}), + "InvalidType", + "expected string at $.system[0].text", ), ( "Anthropic negative max_tokens", WireFormat::AnthropicMessages, json!({"model": "claude", "max_tokens": -1, "messages": []}), + "InvalidValue", "invalid value at $.max_tokens: expected a non-negative integer", ), ( "Anthropic string max_tokens", WireFormat::AnthropicMessages, json!({"model": "claude", "max_tokens": "8", "messages": []}), - "invalid value at $.max_tokens: expected a non-negative integer", + "InvalidType", + "expected non-negative integer at $.max_tokens", ), ( - "Responses boolean input", - WireFormat::OpenAiResponses, - json!({"model": "gpt", "input": true}), - "expected string or array at $.input", + "Anthropic messages type", + WireFormat::AnthropicMessages, + json!({"model": "claude", "max_tokens": 8, "messages": {}}), + "InvalidType", + "expected array at $.messages", ), ( - "Responses null input", - WireFormat::OpenAiResponses, - json!({"model": "gpt", "input": null}), - "expected string or array at $.input", + "Anthropic message type", + WireFormat::AnthropicMessages, + json!({"model": "claude", "max_tokens": 8, "messages": [false]}), + "InvalidType", + "expected object at $.messages[0]", + ), + ( + "Anthropic role value", + WireFormat::AnthropicMessages, + json!({"model": "claude", "max_tokens": 8, "messages": [{"role": "system", "content": "no"}]}), + "InvalidValue", + "invalid value at $.messages[0].role: unsupported value \"system\"; expected one of user, assistant", + ), + ( + "Anthropic content type", + WireFormat::AnthropicMessages, + json!({"model": "claude", "max_tokens": 8, "messages": [{"role": "user", "content": null}]}), + "InvalidType", + "expected string or array at $.messages[0].content", + ), + ( + "Anthropic content block type", + WireFormat::AnthropicMessages, + json!({"model": "claude", "max_tokens": 8, "messages": [{"role": "user", "content": [false]}]}), + "InvalidType", + "expected object at $.messages[0].content[0]", + ), + ( + "Anthropic tool input type", + WireFormat::AnthropicMessages, + json!({"model": "claude", "max_tokens": 8, "messages": [{"role": "assistant", "content": [{"type": "tool_use", "input": []}]}]}), + "InvalidType", + "expected object at $.messages[0].content[0].input", + ), + ( + "Anthropic tool result error type", + WireFormat::AnthropicMessages, + json!({"model": "claude", "max_tokens": 8, "messages": [{"role": "user", "content": [{"type": "tool_result", "is_error": "false"}]}]}), + "InvalidType", + "expected boolean at $.messages[0].content[0].is_error", + ), + ( + "Anthropic tools type", + WireFormat::AnthropicMessages, + json!({"model": "claude", "max_tokens": 8, "messages": [], "tools": {}}), + "InvalidType", + "expected array at $.tools", + ), + ( + "Anthropic tool schema type", + WireFormat::AnthropicMessages, + json!({"model": "claude", "max_tokens": 8, "messages": [], "tools": [{"name": "lookup", "input_schema": []}]}), + "InvalidType", + "expected object at $.tools[0].input_schema", + ), + ( + "Anthropic tool choice type", + WireFormat::AnthropicMessages, + json!({"model": "claude", "max_tokens": 8, "messages": [], "tool_choice": "auto"}), + "InvalidType", + "expected object at $.tool_choice", + ), + ( + "Anthropic thinking type", + WireFormat::AnthropicMessages, + json!({"model": "claude", "max_tokens": 8, "messages": [], "thinking": []}), + "InvalidType", + "expected object at $.thinking", + ), + ( + "Anthropic thinking budget value", + WireFormat::AnthropicMessages, + json!({"model": "claude", "max_tokens": 8, "messages": [], "thinking": {"budget_tokens": -1}}), + "InvalidValue", + "invalid value at $.thinking.budget_tokens: expected a non-negative integer", + ), + ( + "Anthropic output config type", + WireFormat::AnthropicMessages, + json!({"model": "claude", "max_tokens": 8, "messages": [], "output_config": []}), + "InvalidType", + "expected object at $.output_config", + ), + ( + "Anthropic effort value", + WireFormat::AnthropicMessages, + json!({"model": "claude", "max_tokens": 8, "messages": [], "output_config": {"effort": "extreme"}}), + "InvalidValue", + "invalid value at $.output_config.effort: unsupported value \"extreme\"; expected one of low, medium, high, xhigh, max", ), ]; - for (case, format, body, expected) in cases { + for (case, format, body, expected_kind, expected_message) in cases { match engine.decode_request(format, &body, &TranslationPolicy::default()) { Ok(_) => panic!("{case} should be rejected"), - Err(error) => assert_eq!(error.to_string(), expected, "{case}"), + Err(error) => { + assert_eq!(error.kind(), expected_kind, "{case}"); + assert_eq!(error.to_string(), expected_message, "{case}"); + } } } let valid_empty_output = json!({ "model": "claude", "max_tokens": 0, - "system": null, "messages": [] }); if let Err(error) = engine.decode_request( @@ -1236,7 +1538,7 @@ fn malformed_request_fields_are_rejected() { &valid_empty_output, &TranslationPolicy::default(), ) { - panic!("Anthropic null system and zero max_tokens should be accepted: {error}"); + panic!("Anthropic omitted system and zero max_tokens should be accepted: {error}"); } }