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
92 changes: 92 additions & 0 deletions crates/coverage-report/src/requests_expected_differences.json
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,98 @@
{ "pattern": "params.response_format.json_schema.schema.propertyOrdering", "reason": "Google propertyOrdering is a Google-only schema hint and is stripped for non-Google strict-schema targets" }
]
},
{
"testCase": "jsonSchemaPrefixItemsParam",
"source": "*",
"target": "Anthropic",
"fields": [
{ "pattern": "params.response_format.json_schema.schema.properties.tuple.prefixItems", "reason": "Anthropic structured outputs drop tuple-position hints during lossy schema normalization" },
{ "pattern": "params.response_format.json_schema.schema.properties.tuple.minItems", "reason": "Anthropic structured outputs drop array length bounds during lossy schema normalization" },
{ "pattern": "params.response_format.json_schema.schema.properties.tuple.maxItems", "reason": "Anthropic structured outputs drop array length bounds during lossy schema normalization" }
]
},
{
"testCase": "jsonSchemaPrefixItemsParam",
"source": "Google",
"target": "ChatCompletions",
"fields": [
{ "pattern": "params.response_format.json_schema.schema.additionalProperties", "reason": "Strict OpenAI-style targets require explicit closed object schemas when Google omits additionalProperties" }
]
},
{
"testCase": "jsonSchemaPrefixItemsParam",
"source": "Google",
"target": "Responses",
"fields": [
{ "pattern": "params.response_format.json_schema.schema.additionalProperties", "reason": "Strict OpenAI-style targets require explicit closed object schemas when Google omits additionalProperties" }
]
},
{
"testCase": "jsonSchemaFormatParam",
"source": "Google",
"target": "ChatCompletions",
"fields": [
{ "pattern": "params.response_format.json_schema.schema.additionalProperties", "reason": "Strict OpenAI-style targets require explicit closed object schemas when Google omits additionalProperties" }
]
},
{
"testCase": "jsonSchemaFormatParam",
"source": "Google",
"target": "Responses",
"fields": [
{ "pattern": "params.response_format.json_schema.schema.additionalProperties", "reason": "Strict OpenAI-style targets require explicit closed object schemas when Google omits additionalProperties" }
]
},
{
"testCase": "jsonSchemaMinMaxItemsParam",
"source": "*",
"target": "Anthropic",
"fields": [
{ "pattern": "params.response_format.json_schema.schema.properties.tags.minItems", "reason": "Anthropic structured outputs drop array length bounds during lossy schema normalization" },
{ "pattern": "params.response_format.json_schema.schema.properties.tags.maxItems", "reason": "Anthropic structured outputs drop array length bounds during lossy schema normalization" }
]
},
{
"testCase": "jsonSchemaMinMaxItemsParam",
"source": "Google",
"target": "ChatCompletions",
"fields": [
{ "pattern": "params.response_format.json_schema.schema.additionalProperties", "reason": "Strict OpenAI-style targets require explicit closed object schemas when Google omits additionalProperties" }
]
},
{
"testCase": "jsonSchemaMinMaxItemsParam",
"source": "Google",
"target": "Responses",
"fields": [
{ "pattern": "params.response_format.json_schema.schema.additionalProperties", "reason": "Strict OpenAI-style targets require explicit closed object schemas when Google omits additionalProperties" }
]
},
{
"testCase": "jsonSchemaMinimumMaximumParam",
"source": "*",
"target": "Anthropic",
"fields": [
{ "pattern": "params.response_format.json_schema.schema.properties.score.minimum", "reason": "Anthropic structured outputs drop numeric bounds during lossy schema normalization" },
{ "pattern": "params.response_format.json_schema.schema.properties.score.maximum", "reason": "Anthropic structured outputs drop numeric bounds during lossy schema normalization" }
]
},
{
"testCase": "jsonSchemaMinimumMaximumParam",
"source": "Google",
"target": "ChatCompletions",
"fields": [
{ "pattern": "params.response_format.json_schema.schema.additionalProperties", "reason": "Strict OpenAI-style targets require explicit closed object schemas when Google omits additionalProperties" }
]
},
{
"testCase": "jsonSchemaMinimumMaximumParam",
"source": "Google",
"target": "Responses",
"fields": [
{ "pattern": "params.response_format.json_schema.schema.additionalProperties", "reason": "Strict OpenAI-style targets require explicit closed object schemas when Google omits additionalProperties" }
]
},
{
"testCase": "thinkingLevelParam",
"source": "Google",
Expand Down
50 changes: 50 additions & 0 deletions crates/lingua/src/providers/anthropic/convert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1381,6 +1381,14 @@ impl TryFrom<&ResponseFormatConfig> for JsonOutputFormat {
field: "json_schema".to_string(),
}
})?;
// Anthropic's structured-output schema subset is narrower than
// the cross-provider JSON-schema surface. When we emit a typed
// `output_config.format` from canonical `response_format`, we
// intentionally drop tuple-position hints plus array/numeric
// bounds as a lossy compatibility fallback. Callers should not
// expect strict schema fidelity for `prefixItems`, `minItems`,
// `maxItems`, `minimum`, or `maximum`. Raw Anthropic
// `output_config` passthrough remains verbatim in adapter.rs.
match normalize_response_schema_for_strict_target(
&js.schema,
ProviderFormat::Anthropic,
Expand Down Expand Up @@ -1567,6 +1575,48 @@ mod tests {
);
}

#[test]
fn test_json_schema_response_format_to_anthropic_is_lossy_for_unsupported_keywords() {
let config = ResponseFormatConfig {
format_type: Some(ResponseFormatType::JsonSchema),
json_schema: Some(JsonSchemaConfig {
name: "response".to_string(),
schema: json!({
"type": "object",
"properties": {
"tuple": {
"type": "array",
"prefixItems": [
{ "type": "string" },
{ "type": "integer" }
],
"minItems": 2,
"maxItems": 2
},
"score": {
"type": "integer",
"minimum": 0,
"maximum": 10
}
},
"required": ["tuple", "score"],
"additionalProperties": false
}),
strict: Some(true),
description: None,
}),
};

let format = JsonOutputFormat::try_from(&config).unwrap();
let schema = Value::Object(format.schema);

assert_eq!(schema.pointer("/properties/tuple/prefixItems"), None);
assert_eq!(schema.pointer("/properties/tuple/minItems"), None);
assert_eq!(schema.pointer("/properties/tuple/maxItems"), None);
assert_eq!(schema.pointer("/properties/score/minimum"), None);
assert_eq!(schema.pointer("/properties/score/maximum"), None);
}

#[test]
fn test_google_search_builtin_maps_to_anthropic_web_search() {
let tool = UniversalTool::builtin(
Expand Down
87 changes: 86 additions & 1 deletion crates/lingua/src/universal/response_format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ use crate::error::ConvertError;
use crate::processing::transform::TransformError;
use crate::providers::anthropic::generated::JsonOutputFormat;
use crate::providers::google::generated::GenerationConfig;
use crate::serde_json::{self, json, Map, Value};
use crate::serde_json::{self, json, Map, Number, Value};
use crate::universal::request::{JsonSchemaConfig, ResponseFormatConfig, ResponseFormatType};
use serde::{Deserialize, Serialize};

Expand Down Expand Up @@ -97,6 +97,13 @@ enum AdditionalPropertiesNormalizationView {
Other(Value),
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
enum SchemaScalarConstraintView {
Number(Number),
String(String),
Other(Value),
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct StrictTargetSchemaNodeView {
#[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
Expand All @@ -113,6 +120,35 @@ struct StrictTargetSchemaNodeView {
skip_serializing_if = "Option::is_none"
)]
property_ordering: Option<Vec<String>>,
#[serde(rename = "minItems", default, skip_serializing_if = "Option::is_none")]
min_items: Option<SchemaScalarConstraintView>,
#[serde(rename = "maxItems", default, skip_serializing_if = "Option::is_none")]
max_items: Option<SchemaScalarConstraintView>,
#[serde(rename = "minimum", default, skip_serializing_if = "Option::is_none")]
minimum: Option<SchemaScalarConstraintView>,
#[serde(rename = "maximum", default, skip_serializing_if = "Option::is_none")]
maximum: Option<SchemaScalarConstraintView>,
}

/// Anthropic structured outputs accept a narrower JSON Schema subset than the
/// cross-provider canonical format. When targeting Anthropic we intentionally
/// narrow the schemas by dropping unsupported tuple hints and array/numeric bounds.
fn strip_anthropic_unsupported_schema_keywords(
map: &mut Map<String, Value>,
node: &StrictTargetSchemaNodeView,
) {
match node.schema_type.as_deref() {
Some("array") => {
map.remove("prefixItems");
map.remove("minItems");
map.remove("maxItems");
}
Some("integer") | Some("number") => {
map.remove("minimum");
map.remove("maximum");
}
_ => {}
}
}

pub(crate) fn normalize_response_schema_for_strict_target(
Expand All @@ -134,6 +170,10 @@ pub(crate) fn normalize_response_schema_for_strict_target(
})?;

if let Value::Object(map) = value {
if target_provider == ProviderFormat::Anthropic {
strip_anthropic_unsupported_schema_keywords(map, &node);
}

if node.schema_type.as_deref() == Some("object") {
if target_provider != ProviderFormat::Google {
map.remove("propertyOrdering");
Expand Down Expand Up @@ -728,6 +768,51 @@ mod tests {
);
}

#[test]
fn test_anthropic_lossy_normalization_strips_array_and_numeric_bounds() {
let schema = json!({
"type": "object",
"properties": {
"tuple": {
"type": "array",
"prefixItems": [
{ "type": "string" },
{ "type": "integer" }
],
"minItems": 2,
"maxItems": 3,
"items": { "type": "string" }
},
"score": {
"type": "integer",
"minimum": 0,
"maximum": 10
}
}
});

let anthropic =
normalize_response_schema_for_strict_target(&schema, ProviderFormat::Anthropic)
.unwrap();
assert_eq!(anthropic.pointer("/properties/tuple/prefixItems"), None);
assert_eq!(anthropic.pointer("/properties/tuple/minItems"), None);
assert_eq!(anthropic.pointer("/properties/tuple/maxItems"), None);
assert_eq!(anthropic.pointer("/properties/score/minimum"), None);
assert_eq!(anthropic.pointer("/properties/score/maximum"), None);

let chat =
normalize_response_schema_for_strict_target(&schema, ProviderFormat::ChatCompletions)
.unwrap();
assert_eq!(
chat.pointer("/properties/tuple/prefixItems/0/type"),
Some(&Value::String("string".to_string()))
);
assert_eq!(chat.pointer("/properties/tuple/minItems"), Some(&json!(2)));
assert_eq!(chat.pointer("/properties/tuple/maxItems"), Some(&json!(3)));
assert_eq!(chat.pointer("/properties/score/minimum"), Some(&json!(0)));
assert_eq!(chat.pointer("/properties/score/maximum"), Some(&json!(10)));
}

#[test]
fn test_strict_target_rejects_explicit_additional_properties_true() {
let config = ResponseFormatConfig {
Expand Down
Loading
Loading