From 05cd10b9a55cab6089501c6857dd239c7c6bc553 Mon Sep 17 00:00:00 2001 From: Elias Shieh <32212900+ting-hong-shieh@users.noreply.github.com> Date: Mon, 17 Aug 2026 06:12:27 +0800 Subject: [PATCH 1/6] fix(translation): decode Anthropic structured output into requests The Anthropic codec encoded a neutral response format as `output_config.format` but hard-coded `response_format: None` when decoding a request, so a schema arriving on `/v1/messages` never reached the neutral IR and was absent from the forwarded upstream body. Callers received prose where they had asked for JSON, with no diagnostic. Read the schema back on decode, mirroring `encode_anthropic_output_format`. `output_config.format` is Anthropic's current field and wins; the top-level `output_format` is the earlier beta spelling that Anthropic still accepts during its transition period, so it is read as a fallback and added to the known-field list instead of being copied into provider extensions. The neutral contract is OpenAI-shaped and requires a schema name that Anthropic never sends, so requests decoded this way share one. Closes #452 Signed-off-by: Elias Shieh <32212900+ting-hong-shieh@users.noreply.github.com> --- .../src/codecs/anthropic/buffered.rs | 34 ++++- .../tests/request_translation.rs | 125 ++++++++++++++++++ 2 files changed, 158 insertions(+), 1 deletion(-) diff --git a/crates/switchyard-translation/src/codecs/anthropic/buffered.rs b/crates/switchyard-translation/src/codecs/anthropic/buffered.rs index 75f4d6848..aa4f50086 100644 --- a/crates/switchyard-translation/src/codecs/anthropic/buffered.rs +++ b/crates/switchyard-translation/src/codecs/anthropic/buffered.rs @@ -28,6 +28,11 @@ use crate::util::{ json_string, push_lossy, stable_id, string_value, validate_request_capabilities, }; +// Schema name applied when converting Anthropic structured output to the neutral +// contract. Anthropic identifies the schema only by position, while the neutral +// OpenAI shape requires a name, so requests that arrive this way share one. +const ANTHROPIC_STRUCTURED_OUTPUT_SCHEMA_NAME: &str = "response"; + /// Format codec for Anthropic Messages payloads. pub struct AnthropicMessagesCodec; @@ -58,7 +63,7 @@ impl FormatCodec for AnthropicMessagesCodec { .map(ToOwned::to_owned), output: OutputParams { max_output_tokens, - response_format: None, + response_format: decode_anthropic_output_format(body), }, sampling: SamplingParams { temperature: body.get("temperature").and_then(Value::as_f64), @@ -146,6 +151,7 @@ impl FormatCodec for AnthropicMessagesCodec { "top_k", "thinking", "output_config", + "output_format", "stream", ], ); @@ -360,6 +366,32 @@ impl FormatCodec for AnthropicMessagesCodec { } } +// Reads Anthropic's structured-output schema into the neutral response format. +// +// `output_config.format` is the current field; the top-level `output_format` is +// the earlier beta spelling that Anthropic still accepts, so both are read and +// the current one wins. The neutral contract is OpenAI-shaped and requires a +// schema name that Anthropic never sends, so one is supplied here. +fn decode_anthropic_output_format(body: &Map) -> Option { + let format = body + .get("output_config") + .and_then(Value::as_object) + .and_then(|config| config.get("format")) + .or_else(|| body.get("output_format")) + .and_then(Value::as_object)?; + if format.get("type").and_then(Value::as_str) != Some("json_schema") { + return None; + } + let schema = format.get("schema")?; + Some(json!({ + "type": "json_schema", + "json_schema": { + "name": ANTHROPIC_STRUCTURED_OUTPUT_SCHEMA_NAME, + "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..0e2129ac6 100644 --- a/crates/switchyard-translation/tests/request_translation.rs +++ b/crates/switchyard-translation/tests/request_translation.rs @@ -1403,6 +1403,131 @@ 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"}, "ok": {"type": "boolean"}}, + "required": ["city", "ok"], + "additionalProperties": false + }) +} + +// Anthropic carries structured output in `output_config.format`; 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. +#[test] +fn anthropic_output_config_format_reaches_openai_response_format() -> TestResult { + let engine = TranslationEngine::default(); + let body = anthropic_structured_output_request(json!({ + "output_config": {"format": {"type": "json_schema", "schema": city_schema()}} + })); + + let output = engine + .translate_request( + WireFormat::AnthropicMessages, + WireFormat::OpenAiChat, + &body, + &TranslationPolicy::default(), + )? + .body; + + assert_eq!(output["response_format"]["type"], "json_schema"); + assert_eq!( + output["response_format"]["json_schema"]["schema"], + city_schema() + ); + assert!( + output["response_format"]["json_schema"]["name"] + .as_str() + .is_some_and(|name| !name.is_empty()) + ); + Ok(()) +} + +// `output_format` is the earlier beta spelling that Anthropic still accepts, so a +// client sending it must not silently lose the schema either. +#[test] +fn anthropic_legacy_output_format_reaches_openai_response_format() -> TestResult { + let engine = TranslationEngine::default(); + let body = anthropic_structured_output_request(json!({ + "output_format": {"type": "json_schema", "schema": city_schema()} + })); + + let output = engine + .translate_request( + WireFormat::AnthropicMessages, + WireFormat::OpenAiChat, + &body, + &TranslationPolicy::default(), + )? + .body; + + assert_eq!( + output["response_format"]["json_schema"]["schema"], + city_schema() + ); + Ok(()) +} + +// The current field wins so a client migrating off the beta spelling cannot end up +// sending the stale schema. +#[test] +fn anthropic_output_config_format_wins_over_legacy_output_format() -> TestResult { + let engine = TranslationEngine::default(); + let body = anthropic_structured_output_request(json!({ + "output_config": {"format": {"type": "json_schema", "schema": city_schema()}}, + "output_format": {"type": "json_schema", "schema": {"type": "object", "properties": {"stale": {"type": "string"}}}} + })); + + let output = engine + .translate_request( + WireFormat::AnthropicMessages, + WireFormat::OpenAiChat, + &body, + &TranslationPolicy::default(), + )? + .body; + + assert_eq!( + output["response_format"]["json_schema"]["schema"], + city_schema() + ); + Ok(()) +} + +// A request without structured output must not gain a response format. +#[test] +fn anthropic_request_without_structured_output_sends_no_response_format() -> TestResult { + let engine = TranslationEngine::default(); + let body = anthropic_structured_output_request(json!({})); + + let output = engine + .translate_request( + WireFormat::AnthropicMessages, + WireFormat::OpenAiChat, + &body, + &TranslationPolicy::default(), + )? + .body; + + assert!(output.get("response_format").is_none()); + 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 { From 8ae44aa8857f63daca15bc515cffcbd00ced2300 Mon Sep 17 00:00:00 2001 From: Elias Shieh <32212900+ting-hong-shieh@users.noreply.github.com> Date: Mon, 17 Aug 2026 06:28:32 +0800 Subject: [PATCH 2/6] fix(translation): report unmappable Anthropic output formats Decoding returned `None` for a structured-output format it could not map, so a caller that asked for constrained output got an unconstrained upstream request with no diagnostic. The audio, video, and unknown-block arms of the codecs already call `push_lossy` for exactly this reason. Report the drop instead: a format that is not an object, a format whose type is not `json_schema`, and a `json_schema` format with no `schema` each produce a diagnostic, so `LossyConversionPolicy::Reject` fails the translation rather than silently forwarding a request that cannot honor the contract. Signed-off-by: Elias Shieh <32212900+ting-hong-shieh@users.noreply.github.com> --- .../src/codecs/anthropic/buffered.rs | 47 +++++++++-- .../tests/request_translation.rs | 80 +++++++++++++++++++ 2 files changed, 119 insertions(+), 8 deletions(-) diff --git a/crates/switchyard-translation/src/codecs/anthropic/buffered.rs b/crates/switchyard-translation/src/codecs/anthropic/buffered.rs index aa4f50086..6db03ca14 100644 --- a/crates/switchyard-translation/src/codecs/anthropic/buffered.rs +++ b/crates/switchyard-translation/src/codecs/anthropic/buffered.rs @@ -55,6 +55,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") @@ -63,7 +64,7 @@ impl FormatCodec for AnthropicMessagesCodec { .map(ToOwned::to_owned), output: OutputParams { max_output_tokens, - response_format: decode_anthropic_output_format(body), + response_format, }, sampling: SamplingParams { temperature: body.get("temperature").and_then(Value::as_f64), @@ -372,24 +373,54 @@ impl FormatCodec for AnthropicMessagesCodec { // the earlier beta spelling that Anthropic still accepts, so both are read and // the current one wins. The neutral contract is OpenAI-shaped and requires a // schema name that Anthropic never sends, so one is supplied here. -fn decode_anthropic_output_format(body: &Map) -> Option { - let format = body +// +// A format that cannot be mapped is reported rather than dropped in silence: the +// caller asked for constrained output and would otherwise receive prose with no +// indication that the constraint never reached the upstream. +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")) - .and_then(Value::as_object)?; + 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") { - return None; + push_lossy( + diagnostics, + policy, + "Anthropic structured output maps only a json_schema format; the requested format was dropped", + )?; + return Ok(None); } - let schema = format.get("schema")?; - Some(json!({ + let Some(schema) = format.get("schema") else { + push_lossy( + diagnostics, + policy, + "Anthropic structured output requires format.schema; the requested format was dropped", + )?; + return Ok(None); + }; + Ok(Some(json!({ "type": "json_schema", "json_schema": { "name": ANTHROPIC_STRUCTURED_OUTPUT_SCHEMA_NAME, "schema": schema.clone(), }, - })) + }))) } /// Maps the neutral OpenAI-shaped JSON schema to Anthropic's output format. diff --git a/crates/switchyard-translation/tests/request_translation.rs b/crates/switchyard-translation/tests/request_translation.rs index 0e2129ac6..89ac176d7 100644 --- a/crates/switchyard-translation/tests/request_translation.rs +++ b/crates/switchyard-translation/tests/request_translation.rs @@ -1509,6 +1509,86 @@ fn anthropic_output_config_format_wins_over_legacy_output_format() -> TestResult Ok(()) } +// A format that cannot be mapped must be reported; the caller asked for constrained +// output and would otherwise get prose with no indication the constraint was lost. +#[test] +fn anthropic_unmappable_output_format_is_reported() -> TestResult { + let engine = TranslationEngine::default(); + for format in [ + json!({"type": "json_object"}), + json!({"type": "json_schema"}), + json!("not-an-object"), + ] { + let body = anthropic_structured_output_request(json!({ + "output_config": {"format": format} + })); + + let translated = engine.translate_request( + WireFormat::AnthropicMessages, + WireFormat::OpenAiChat, + &body, + &TranslationPolicy::default(), + )?; + + assert!(translated.body.get("response_format").is_none()); + assert!( + translated + .diagnostics + .iter() + .any(|diagnostic| diagnostic.message.contains("Anthropic structured output")), + "expected a diagnostic for {body}" + ); + } + 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"}} + })); + + let error = match engine.translate_request( + WireFormat::AnthropicMessages, + WireFormat::OpenAiChat, + &body, + &policy, + ) { + Ok(_) => panic!("an unmappable output format should be rejected by strict policy"), + Err(error) => error, + }; + + assert_eq!(error.kind(), "LossyConversion"); + Ok(()) +} + +// Reasoning effort shares `output_config`, so reading the schema must not disturb it. +#[test] +fn anthropic_output_config_effort_survives_without_a_response_format() -> TestResult { + let engine = TranslationEngine::default(); + let body = anthropic_structured_output_request(json!({ + "output_config": {"effort": "high"} + })); + + let translated = engine.translate_request( + WireFormat::AnthropicMessages, + WireFormat::OpenAiChat, + &body, + &TranslationPolicy::default(), + )?; + + assert_eq!(translated.body["reasoning_effort"], "high"); + assert!(translated.body.get("response_format").is_none()); + assert!(translated.diagnostics.is_empty()); + Ok(()) +} + // A request without structured output must not gain a response format. #[test] fn anthropic_request_without_structured_output_sends_no_response_format() -> TestResult { From 9268d2c16c5b677e2a94a5c4478e008ecbb6f8bf Mon Sep 17 00:00:00 2001 From: Elias Shieh <32212900+ting-hong-shieh@users.noreply.github.com> Date: Tue, 18 Aug 2026 06:35:54 +0800 Subject: [PATCH 3/6] fix(translation): require an object Anthropic output schema `format.schema` was read by presence alone, so a string, number, array, or null was copied verbatim into the neutral contract and forwarded upstream as a malformed `json_schema.schema`, with no diagnostic. The surrounding checks already validate the format object and its type. Refuse a non-object schema through the same `push_lossy` path as the other unmappable shapes, and cover the four scalar and array cases. Signed-off-by: Elias Shieh <32212900+ting-hong-shieh@users.noreply.github.com> --- .../src/codecs/anthropic/buffered.rs | 7 +++++-- crates/switchyard-translation/tests/request_translation.rs | 4 ++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/switchyard-translation/src/codecs/anthropic/buffered.rs b/crates/switchyard-translation/src/codecs/anthropic/buffered.rs index 6db03ca14..279b17c36 100644 --- a/crates/switchyard-translation/src/codecs/anthropic/buffered.rs +++ b/crates/switchyard-translation/src/codecs/anthropic/buffered.rs @@ -406,11 +406,14 @@ fn decode_anthropic_output_format( )?; return Ok(None); } - let Some(schema) = format.get("schema") else { + // A non-object schema would be forwarded verbatim into the neutral contract and + // reach the upstream as a malformed `json_schema.schema`, so it is refused here + // rather than handed on. + let Some(schema) = format.get("schema").filter(|schema| schema.is_object()) else { push_lossy( diagnostics, policy, - "Anthropic structured output requires format.schema; the requested format was dropped", + "Anthropic structured output requires format.schema to be an object; the requested format was dropped", )?; return Ok(None); }; diff --git a/crates/switchyard-translation/tests/request_translation.rs b/crates/switchyard-translation/tests/request_translation.rs index 89ac176d7..a11622183 100644 --- a/crates/switchyard-translation/tests/request_translation.rs +++ b/crates/switchyard-translation/tests/request_translation.rs @@ -1517,6 +1517,10 @@ fn anthropic_unmappable_output_format_is_reported() -> TestResult { for format in [ json!({"type": "json_object"}), json!({"type": "json_schema"}), + json!({"type": "json_schema", "schema": "not-a-schema"}), + json!({"type": "json_schema", "schema": 42}), + json!({"type": "json_schema", "schema": [1, 2]}), + json!({"type": "json_schema", "schema": null}), json!("not-an-object"), ] { let body = anthropic_structured_output_request(json!({ From ff3c2e35236b169e137255916b9dfdd4ae1ad1cf Mon Sep 17 00:00:00 2001 From: Elias Shieh <32212900+ting-hong-shieh@users.noreply.github.com> Date: Tue, 18 Aug 2026 07:13:29 +0800 Subject: [PATCH 4/6] test(translation): fold Anthropic structured-output cases into one table Seven near-identical tests each rebuilt a request, translated it, and asserted on `response_format`, which made the block long without covering more behavior. Drive the mapping cases from one labelled table instead: the current field, the legacy spelling, the current field winning over the legacy one, no structured output, an effort-only config, and the six shapes that cannot be mapped. Each row carries the schema it expects and whether a diagnostic is required, and every assertion names its row so a failure still identifies the case. The two behaviors that are not a mapping assertion stay separate: strict policy turning a drop into an error, and reasoning effort surviving alongside a schema in the same `output_config`. Signed-off-by: Elias Shieh <32212900+ting-hong-shieh@users.noreply.github.com> --- .../tests/request_translation.rs | 236 ++++++++---------- 1 file changed, 108 insertions(+), 128 deletions(-) diff --git a/crates/switchyard-translation/tests/request_translation.rs b/crates/switchyard-translation/tests/request_translation.rs index a11622183..5f2626211 100644 --- a/crates/switchyard-translation/tests/request_translation.rs +++ b/crates/switchyard-translation/tests/request_translation.rs @@ -1426,71 +1426,127 @@ fn city_schema() -> Value { }) } -// Anthropic carries structured output in `output_config.format`; the neutral contract +// 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. +// upstream is never asked for structured output. A shape that cannot be mapped is +// reported rather than forwarded unconstrained. #[test] -fn anthropic_output_config_format_reaches_openai_response_format() -> TestResult { +fn anthropic_structured_output_maps_to_openai_response_format() -> TestResult { let engine = TranslationEngine::default(); - let body = anthropic_structured_output_request(json!({ - "output_config": {"format": {"type": "json_schema", "schema": city_schema()}} - })); - - let output = engine - .translate_request( - WireFormat::AnthropicMessages, - WireFormat::OpenAiChat, - &body, - &TranslationPolicy::default(), - )? - .body; - - assert_eq!(output["response_format"]["type"], "json_schema"); - assert_eq!( - output["response_format"]["json_schema"]["schema"], - city_schema() - ); - assert!( - output["response_format"]["json_schema"]["name"] - .as_str() - .is_some_and(|name| !name.is_empty()) - ); - Ok(()) -} - -// `output_format` is the earlier beta spelling that Anthropic still accepts, so a -// client sending it must not silently lose the schema either. -#[test] -fn anthropic_legacy_output_format_reaches_openai_response_format() -> TestResult { - let engine = TranslationEngine::default(); - let body = anthropic_structured_output_request(json!({ - "output_format": {"type": "json_schema", "schema": city_schema()} - })); + let stale = json!({"type": "object", "properties": {"stale": {"type": "string"}}}); + let cases: Vec<(&str, Value, Option, bool)> = vec![ + ( + "current field", + json!({"output_config": {"format": {"type": "json_schema", "schema": city_schema()}}}), + Some(city_schema()), + false, + ), + ( + "legacy beta field", + json!({"output_format": {"type": "json_schema", "schema": city_schema()}}), + Some(city_schema()), + false, + ), + ( + "current field wins over legacy", + json!({ + "output_config": {"format": {"type": "json_schema", "schema": city_schema()}}, + "output_format": {"type": "json_schema", "schema": stale} + }), + Some(city_schema()), + false, + ), + ("no structured output", json!({}), None, false), + ( + "reasoning effort shares output_config", + json!({"output_config": {"effort": "high"}}), + None, + false, + ), + ( + "unsupported format type", + json!({"output_config": {"format": {"type": "json_object"}}}), + None, + true, + ), + ( + "missing schema", + json!({"output_config": {"format": {"type": "json_schema"}}}), + None, + true, + ), + ( + "string schema", + json!({"output_config": {"format": {"type": "json_schema", "schema": "nope"}}}), + None, + true, + ), + ( + "numeric schema", + json!({"output_config": {"format": {"type": "json_schema", "schema": 42}}}), + None, + true, + ), + ( + "array schema", + json!({"output_config": {"format": {"type": "json_schema", "schema": [1, 2]}}}), + None, + true, + ), + ( + "null schema", + json!({"output_config": {"format": {"type": "json_schema", "schema": Value::Null}}}), + None, + true, + ), + ( + "format is not an object", + json!({"output_config": {"format": "nope"}}), + None, + true, + ), + ]; - let output = engine - .translate_request( + for (label, output, expected_schema, expect_diagnostic) in cases { + let translated = engine.translate_request( WireFormat::AnthropicMessages, WireFormat::OpenAiChat, - &body, + &anthropic_structured_output_request(output), &TranslationPolicy::default(), - )? - .body; + )?; + 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["type"], "json_schema", "{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}"), + } - assert_eq!( - output["response_format"]["json_schema"]["schema"], - city_schema() - ); + let reported = translated + .diagnostics + .iter() + .any(|diagnostic| diagnostic.message.contains("Anthropic structured output")); + assert_eq!(reported, expect_diagnostic, "{label}"); + } Ok(()) } -// The current field wins so a client migrating off the beta spelling cannot end up -// sending the stale schema. +// Reasoning effort shares `output_config`, so reading the schema must not disturb it. #[test] -fn anthropic_output_config_format_wins_over_legacy_output_format() -> TestResult { +fn anthropic_output_config_effort_survives_schema_decoding() -> TestResult { let engine = TranslationEngine::default(); let body = anthropic_structured_output_request(json!({ - "output_config": {"format": {"type": "json_schema", "schema": city_schema()}}, - "output_format": {"type": "json_schema", "schema": {"type": "object", "properties": {"stale": {"type": "string"}}}} + "output_config": {"effort": "high", "format": {"type": "json_schema", "schema": city_schema()}} })); let output = engine @@ -1502,6 +1558,7 @@ fn anthropic_output_config_format_wins_over_legacy_output_format() -> TestResult )? .body; + assert_eq!(output["reasoning_effort"], "high"); assert_eq!( output["response_format"]["json_schema"]["schema"], city_schema() @@ -1509,43 +1566,6 @@ fn anthropic_output_config_format_wins_over_legacy_output_format() -> TestResult Ok(()) } -// A format that cannot be mapped must be reported; the caller asked for constrained -// output and would otherwise get prose with no indication the constraint was lost. -#[test] -fn anthropic_unmappable_output_format_is_reported() -> TestResult { - let engine = TranslationEngine::default(); - for format in [ - json!({"type": "json_object"}), - json!({"type": "json_schema"}), - json!({"type": "json_schema", "schema": "not-a-schema"}), - json!({"type": "json_schema", "schema": 42}), - json!({"type": "json_schema", "schema": [1, 2]}), - json!({"type": "json_schema", "schema": null}), - json!("not-an-object"), - ] { - let body = anthropic_structured_output_request(json!({ - "output_config": {"format": format} - })); - - let translated = engine.translate_request( - WireFormat::AnthropicMessages, - WireFormat::OpenAiChat, - &body, - &TranslationPolicy::default(), - )?; - - assert!(translated.body.get("response_format").is_none()); - assert!( - translated - .diagnostics - .iter() - .any(|diagnostic| diagnostic.message.contains("Anthropic structured output")), - "expected a diagnostic for {body}" - ); - } - Ok(()) -} - // Strict callers get an error instead of an unconstrained upstream request. #[test] fn anthropic_unmappable_output_format_is_rejected_under_strict_policy() -> TestResult { @@ -1572,46 +1592,6 @@ fn anthropic_unmappable_output_format_is_rejected_under_strict_policy() -> TestR Ok(()) } -// Reasoning effort shares `output_config`, so reading the schema must not disturb it. -#[test] -fn anthropic_output_config_effort_survives_without_a_response_format() -> TestResult { - let engine = TranslationEngine::default(); - let body = anthropic_structured_output_request(json!({ - "output_config": {"effort": "high"} - })); - - let translated = engine.translate_request( - WireFormat::AnthropicMessages, - WireFormat::OpenAiChat, - &body, - &TranslationPolicy::default(), - )?; - - assert_eq!(translated.body["reasoning_effort"], "high"); - assert!(translated.body.get("response_format").is_none()); - assert!(translated.diagnostics.is_empty()); - Ok(()) -} - -// A request without structured output must not gain a response format. -#[test] -fn anthropic_request_without_structured_output_sends_no_response_format() -> TestResult { - let engine = TranslationEngine::default(); - let body = anthropic_structured_output_request(json!({})); - - let output = engine - .translate_request( - WireFormat::AnthropicMessages, - WireFormat::OpenAiChat, - &body, - &TranslationPolicy::default(), - )? - .body; - - assert!(output.get("response_format").is_none()); - 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 { From 639b92d67ce6f5776437b1c205aa515b873ac060 Mon Sep 17 00:00:00 2001 From: Elias Shieh <32212900+ting-hong-shieh@users.noreply.github.com> Date: Tue, 18 Aug 2026 08:24:26 +0800 Subject: [PATCH 5/6] test(translation): cut redundant Anthropic structured-output cases Four rows differed only in which non-object value stood in for the schema, and two others restated coverage the remaining rows already provided. Keep one row per behavior: the current field, the legacy spelling, the current field winning over the legacy one, no structured output, an unsupported format type, and a non-object schema. Reasoning effort now rides along with the first row rather than occupying a test of its own, and the diagnostic expectation is derived from the case instead of being spelled out per row. Signed-off-by: Elias Shieh <32212900+ting-hong-shieh@users.noreply.github.com> --- .../tests/request_translation.rs | 123 +++++------------- 1 file changed, 33 insertions(+), 90 deletions(-) diff --git a/crates/switchyard-translation/tests/request_translation.rs b/crates/switchyard-translation/tests/request_translation.rs index 5f2626211..580fba3d0 100644 --- a/crates/switchyard-translation/tests/request_translation.rs +++ b/crates/switchyard-translation/tests/request_translation.rs @@ -1420,8 +1420,8 @@ fn anthropic_structured_output_request(output: Value) -> Value { fn city_schema() -> Value { json!({ "type": "object", - "properties": {"city": {"type": "string"}, "ok": {"type": "boolean"}}, - "required": ["city", "ok"], + "properties": {"city": {"type": "string"}}, + "required": ["city"], "additionalProperties": false }) } @@ -1430,89 +1430,52 @@ fn city_schema() -> Value { // `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. +// 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 stale = json!({"type": "object", "properties": {"stale": {"type": "string"}}}); - let cases: Vec<(&str, Value, Option, bool)> = vec![ + 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", - json!({"output_config": {"format": {"type": "json_schema", "schema": city_schema()}}}), + "current field, alongside effort", + json!({"output_config": {"effort": "high", "format": format}}), Some(city_schema()), - false, + Some("high"), ), ( "legacy beta field", - json!({"output_format": {"type": "json_schema", "schema": city_schema()}}), + json!({"output_format": format}), Some(city_schema()), - false, + None, ), ( "current field wins over legacy", - json!({ - "output_config": {"format": {"type": "json_schema", "schema": city_schema()}}, - "output_format": {"type": "json_schema", "schema": stale} - }), + json!({"output_config": {"format": format}, "output_format": stale}), Some(city_schema()), - false, - ), - ("no structured output", json!({}), None, false), - ( - "reasoning effort shares output_config", - json!({"output_config": {"effort": "high"}}), None, - false, ), + ("no structured output", json!({}), None, None), ( "unsupported format type", json!({"output_config": {"format": {"type": "json_object"}}}), None, - true, - ), - ( - "missing schema", - json!({"output_config": {"format": {"type": "json_schema"}}}), None, - true, ), ( - "string schema", + "schema is not an object", json!({"output_config": {"format": {"type": "json_schema", "schema": "nope"}}}), None, - true, - ), - ( - "numeric schema", - json!({"output_config": {"format": {"type": "json_schema", "schema": 42}}}), - None, - true, - ), - ( - "array schema", - json!({"output_config": {"format": {"type": "json_schema", "schema": [1, 2]}}}), None, - true, - ), - ( - "null schema", - json!({"output_config": {"format": {"type": "json_schema", "schema": Value::Null}}}), - None, - true, - ), - ( - "format is not an object", - json!({"output_config": {"format": "nope"}}), - None, - true, ), ]; - for (label, output, expected_schema, expect_diagnostic) in cases { + for (label, output, expected_schema, expected_effort) in cases { let translated = engine.translate_request( WireFormat::AnthropicMessages, WireFormat::OpenAiChat, - &anthropic_structured_output_request(output), + &anthropic_structured_output_request(output.clone()), &TranslationPolicy::default(), )?; let response_format = translated.body.get("response_format"); @@ -1520,7 +1483,6 @@ fn anthropic_structured_output_maps_to_openai_response_format() -> TestResult { match &expected_schema { Some(schema) => { let response_format = response_format.ok_or(label)?; - assert_eq!(response_format["type"], "json_schema", "{label}"); assert_eq!(response_format["json_schema"]["schema"], *schema, "{label}"); assert!( response_format["json_schema"]["name"] @@ -1531,41 +1493,24 @@ fn anthropic_structured_output_maps_to_openai_response_format() -> TestResult { } None => assert!(response_format.is_none(), "{label}"), } + if let Some(effort) = expected_effort { + assert_eq!(translated.body["reasoning_effort"], effort, "{label}"); + } - let reported = translated - .diagnostics - .iter() - .any(|diagnostic| diagnostic.message.contains("Anthropic structured output")); - assert_eq!(reported, expect_diagnostic, "{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(()) } -// Reasoning effort shares `output_config`, so reading the schema must not disturb it. -#[test] -fn anthropic_output_config_effort_survives_schema_decoding() -> TestResult { - let engine = TranslationEngine::default(); - let body = anthropic_structured_output_request(json!({ - "output_config": {"effort": "high", "format": {"type": "json_schema", "schema": city_schema()}} - })); - - let output = engine - .translate_request( - WireFormat::AnthropicMessages, - WireFormat::OpenAiChat, - &body, - &TranslationPolicy::default(), - )? - .body; - - assert_eq!(output["reasoning_effort"], "high"); - assert_eq!( - output["response_format"]["json_schema"]["schema"], - city_schema() - ); - Ok(()) -} - // Strict callers get an error instead of an unconstrained upstream request. #[test] fn anthropic_unmappable_output_format_is_rejected_under_strict_policy() -> TestResult { @@ -1578,17 +1523,15 @@ fn anthropic_unmappable_output_format_is_rejected_under_strict_policy() -> TestR "output_config": {"format": {"type": "json_object"}} })); - let error = match engine.translate_request( + match engine.translate_request( WireFormat::AnthropicMessages, WireFormat::OpenAiChat, &body, &policy, ) { Ok(_) => panic!("an unmappable output format should be rejected by strict policy"), - Err(error) => error, - }; - - assert_eq!(error.kind(), "LossyConversion"); + Err(error) => assert_eq!(error.kind(), "LossyConversion"), + } Ok(()) } From 62aa79a557a8c9c865debb35a7d69026e1c8f464 Mon Sep 17 00:00:00 2001 From: Elias Shieh <32212900+ting-hong-shieh@users.noreply.github.com> Date: Tue, 18 Aug 2026 08:29:50 +0800 Subject: [PATCH 6/6] refactor(translation): trim Anthropic output-format comments Inline the single-use schema-name constant and cut the block comments down to the line each one needed. Signed-off-by: Elias Shieh <32212900+ting-hong-shieh@users.noreply.github.com> --- .../src/codecs/anthropic/buffered.rs | 24 ++++--------------- 1 file changed, 5 insertions(+), 19 deletions(-) diff --git a/crates/switchyard-translation/src/codecs/anthropic/buffered.rs b/crates/switchyard-translation/src/codecs/anthropic/buffered.rs index 279b17c36..7d4b2d2c2 100644 --- a/crates/switchyard-translation/src/codecs/anthropic/buffered.rs +++ b/crates/switchyard-translation/src/codecs/anthropic/buffered.rs @@ -28,11 +28,6 @@ use crate::util::{ json_string, push_lossy, stable_id, string_value, validate_request_capabilities, }; -// Schema name applied when converting Anthropic structured output to the neutral -// contract. Anthropic identifies the schema only by position, while the neutral -// OpenAI shape requires a name, so requests that arrive this way share one. -const ANTHROPIC_STRUCTURED_OUTPUT_SCHEMA_NAME: &str = "response"; - /// Format codec for Anthropic Messages payloads. pub struct AnthropicMessagesCodec; @@ -367,16 +362,8 @@ impl FormatCodec for AnthropicMessagesCodec { } } -// Reads Anthropic's structured-output schema into the neutral response format. -// -// `output_config.format` is the current field; the top-level `output_format` is -// the earlier beta spelling that Anthropic still accepts, so both are read and -// the current one wins. The neutral contract is OpenAI-shaped and requires a -// schema name that Anthropic never sends, so one is supplied here. -// -// A format that cannot be mapped is reported rather than dropped in silence: the -// caller asked for constrained output and would otherwise receive prose with no -// indication that the constraint never reached the upstream. +// 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, @@ -406,9 +393,7 @@ fn decode_anthropic_output_format( )?; return Ok(None); } - // A non-object schema would be forwarded verbatim into the neutral contract and - // reach the upstream as a malformed `json_schema.schema`, so it is refused here - // rather than handed on. + // 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, @@ -420,7 +405,8 @@ fn decode_anthropic_output_format( Ok(Some(json!({ "type": "json_schema", "json_schema": { - "name": ANTHROPIC_STRUCTURED_OUTPUT_SCHEMA_NAME, + // Anthropic identifies the schema by position; the neutral shape needs a name. + "name": "response", "schema": schema.clone(), }, })))