diff --git a/crates/switchyard-translation/src/codecs/anthropic/buffered.rs b/crates/switchyard-translation/src/codecs/anthropic/buffered.rs index 75f4d6848..7d4b2d2c2 100644 --- a/crates/switchyard-translation/src/codecs/anthropic/buffered.rs +++ b/crates/switchyard-translation/src/codecs/anthropic/buffered.rs @@ -50,6 +50,7 @@ impl FormatCodec for AnthropicMessagesCodec { }) }) .transpose()?; + let response_format = decode_anthropic_output_format(body, &mut diagnostics, policy)?; let mut request = LlmRequest { model: body .get("model") @@ -58,7 +59,7 @@ impl FormatCodec for AnthropicMessagesCodec { .map(ToOwned::to_owned), output: OutputParams { max_output_tokens, - response_format: None, + response_format, }, sampling: SamplingParams { temperature: body.get("temperature").and_then(Value::as_f64), @@ -146,6 +147,7 @@ impl FormatCodec for AnthropicMessagesCodec { "top_k", "thinking", "output_config", + "output_format", "stream", ], ); @@ -360,6 +362,56 @@ impl FormatCodec for AnthropicMessagesCodec { } } +// Reads the current `output_config.format`, or the beta `output_format` it replaced, +// into the neutral OpenAI-shaped response format. +fn decode_anthropic_output_format( + body: &Map, + diagnostics: &mut Vec, + policy: &TranslationPolicy, +) -> Result> { + let Some(format) = body + .get("output_config") + .and_then(Value::as_object) + .and_then(|config| config.get("format")) + .or_else(|| body.get("output_format")) + else { + return Ok(None); + }; + let Some(format) = format.as_object() else { + push_lossy( + diagnostics, + policy, + "Anthropic structured output format is not an object; the requested format was dropped", + )?; + return Ok(None); + }; + if format.get("type").and_then(Value::as_str) != Some("json_schema") { + push_lossy( + diagnostics, + policy, + "Anthropic structured output maps only a json_schema format; the requested format was dropped", + )?; + return Ok(None); + } + // A non-object schema would reach the upstream as a malformed `json_schema.schema`. + let Some(schema) = format.get("schema").filter(|schema| schema.is_object()) else { + push_lossy( + diagnostics, + policy, + "Anthropic structured output requires format.schema to be an object; the requested format was dropped", + )?; + return Ok(None); + }; + Ok(Some(json!({ + "type": "json_schema", + "json_schema": { + // Anthropic identifies the schema by position; the neutral shape needs a name. + "name": "response", + "schema": schema.clone(), + }, + }))) +} + /// 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/tests/request_translation.rs b/crates/switchyard-translation/tests/request_translation.rs index b7c4121a9..580fba3d0 100644 --- a/crates/switchyard-translation/tests/request_translation.rs +++ b/crates/switchyard-translation/tests/request_translation.rs @@ -1403,6 +1403,138 @@ fn openai_request_translates_system_developer_and_reasoning_to_anthropic() -> Te Ok(()) } +// Builds an Anthropic request whose structured output uses the given field shape. +fn anthropic_structured_output_request(output: Value) -> Value { + let mut body = json!({ + "model": "captured-model", + "max_tokens": 64, + "messages": [{"role": "user", "content": "ping"}] + }); + let object = body.as_object_mut().expect("request object"); + for (key, value) in output.as_object().expect("output object") { + object.insert(key.clone(), value.clone()); + } + body +} + +fn city_schema() -> Value { + json!({ + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + "additionalProperties": false + }) +} + +// Anthropic carries structured output in `output_config.format`, with the top-level +// `output_format` as the earlier beta spelling it still accepts. The neutral contract +// is OpenAI-shaped, so an ingress schema has to survive into `response_format` or the +// upstream is never asked for structured output. A shape that cannot be mapped is +// reported rather than forwarded unconstrained, and reasoning effort shares +// `output_config`, so reading the schema must leave it alone. +#[test] +fn anthropic_structured_output_maps_to_openai_response_format() -> TestResult { + let engine = TranslationEngine::default(); + let format = json!({"type": "json_schema", "schema": city_schema()}); + let stale = json!({"type": "json_schema", "schema": {"type": "object"}}); + let cases: Vec<(&str, Value, Option, Option<&str>)> = vec![ + ( + "current field, alongside effort", + json!({"output_config": {"effort": "high", "format": format}}), + Some(city_schema()), + Some("high"), + ), + ( + "legacy beta field", + json!({"output_format": format}), + Some(city_schema()), + None, + ), + ( + "current field wins over legacy", + json!({"output_config": {"format": format}, "output_format": stale}), + Some(city_schema()), + None, + ), + ("no structured output", json!({}), None, None), + ( + "unsupported format type", + json!({"output_config": {"format": {"type": "json_object"}}}), + None, + None, + ), + ( + "schema is not an object", + json!({"output_config": {"format": {"type": "json_schema", "schema": "nope"}}}), + None, + None, + ), + ]; + + for (label, output, expected_schema, expected_effort) in cases { + let translated = engine.translate_request( + WireFormat::AnthropicMessages, + WireFormat::OpenAiChat, + &anthropic_structured_output_request(output.clone()), + &TranslationPolicy::default(), + )?; + let response_format = translated.body.get("response_format"); + + match &expected_schema { + Some(schema) => { + let response_format = response_format.ok_or(label)?; + assert_eq!(response_format["json_schema"]["schema"], *schema, "{label}"); + assert!( + response_format["json_schema"]["name"] + .as_str() + .is_some_and(|name| !name.is_empty()), + "{label}" + ); + } + None => assert!(response_format.is_none(), "{label}"), + } + if let Some(effort) = expected_effort { + assert_eq!(translated.body["reasoning_effort"], effort, "{label}"); + } + + // A format that was present but unmapped is the only case that must report. + let unmapped = expected_schema.is_none() && output.get("output_config").is_some(); + assert_eq!( + translated + .diagnostics + .iter() + .any(|diagnostic| diagnostic.message.contains("Anthropic structured output")), + unmapped, + "{label}" + ); + } + Ok(()) +} + +// Strict callers get an error instead of an unconstrained upstream request. +#[test] +fn anthropic_unmappable_output_format_is_rejected_under_strict_policy() -> TestResult { + let engine = TranslationEngine::default(); + let policy = TranslationPolicy { + lossy_conversion_policy: LossyConversionPolicy::Reject, + ..TranslationPolicy::default() + }; + let body = anthropic_structured_output_request(json!({ + "output_config": {"format": {"type": "json_object"}} + })); + + match engine.translate_request( + WireFormat::AnthropicMessages, + WireFormat::OpenAiChat, + &body, + &policy, + ) { + Ok(_) => panic!("an unmappable output format should be rejected by strict policy"), + Err(error) => assert_eq!(error.kind(), "LossyConversion"), + } + Ok(()) +} + // Verifies Anthropic receives its supported schema subset without mutating the neutral contract. #[test] fn openai_schema_constraints_are_removed_from_anthropic_output_format() -> TestResult {