Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 53 additions & 1 deletion crates/switchyard-translation/src/codecs/anthropic/buffered.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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),
Expand Down Expand Up @@ -146,6 +147,7 @@ impl FormatCodec for AnthropicMessagesCodec {
"top_k",
"thinking",
"output_config",
"output_format",
"stream",
],
);
Expand Down Expand Up @@ -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<String, Value>,
diagnostics: &mut Vec<TranslationDiagnostic>,
policy: &TranslationPolicy,
) -> Result<Option<Value>> {
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,
Expand Down
132 changes: 132 additions & 0 deletions crates/switchyard-translation/tests/request_translation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Comment thread
nachiketb-nvidia marked this conversation as resolved.
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<Value>, 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 {
Expand Down
Loading