fix(anthropic): strip all remaining output_format schema keywords rejected by Anthropic

This commit is contained in:
mateo-berri 2026-07-22 16:14:10 -07:00
parent c2c12d70b5
commit 2ec2a92da2
3 changed files with 251 additions and 44 deletions

View file

@ -481,14 +481,20 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
Filter out unsupported fields from JSON schema for Anthropic's output_format API.
Anthropic's output_format doesn't support certain JSON schema properties.
These are cross-element / count constraints that cannot be enforced by the
constrained-decoding grammar Anthropic compiles the schema into, so the API
rejects them with a 400 ``invalid_request_error`` (e.g. "output_format.schema:
For 'array' type, property 'uniqueItems' is not supported"):
- maxItems/minItems/uniqueItems/contains/minContains/maxContains: array constraints
- minimum/maximum/exclusiveMinimum/exclusiveMaximum: numeric constraints
These are constraints that cannot be enforced by the constrained-decoding
grammar Anthropic compiles the schema into, so the API rejects them with a
400 ``invalid_request_error`` (e.g. "output_format.schema: For 'array' type,
property 'uniqueItems' is not supported"):
- maxItems/minItems/uniqueItems/contains/minContains/maxContains/prefixItems: array constraints
- minimum/maximum/exclusiveMinimum/exclusiveMaximum/multipleOf: numeric constraints
- minLength/maxLength: string constraints
- minProperties/maxProperties: object constraints
- minProperties/maxProperties/patternProperties/propertyNames: object constraints
- dependentRequired/dependentSchemas/unevaluatedProperties: object constraints
- if/then/else/not: conditional and negation keywords
``oneOf`` is also rejected ("Schema type 'oneOf' is not supported") and is
rewritten to ``anyOf``, matching the Anthropic SDK. Unknown keywords are
ignored by the API, so anything not listed here passes through untouched.
This mirrors the transformation done by the Anthropic Python SDK.
See: https://platform.claude.com/docs/en/build-with-claude/structured-outputs#how-sdk-transformation-works
@ -509,26 +515,6 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
if not isinstance(schema, dict):
return schema
# All numeric/string/array/object constraints not supported by Anthropic
unsupported_fields = {
"maxItems",
"minItems",
"uniqueItems",
"contains",
"minContains",
"maxContains", # array constraints
"minimum",
"maximum", # numeric constraints
"exclusiveMinimum",
"exclusiveMaximum", # numeric constraints
"minLength",
"maxLength", # string constraints
"minProperties",
"maxProperties", # object constraints
}
# Build description additions from removed constraints
constraint_descriptions: list = []
constraint_labels = {
"minItems": "minimum number of items: {}",
"maxItems": "maximum number of items: {}",
@ -536,27 +522,46 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
"contains": "array must contain an item matching: {}",
"minContains": "minimum number of matching items: {}",
"maxContains": "maximum number of matching items: {}",
"prefixItems": "leading items must match, in order: {}",
"minimum": "minimum value: {}",
"maximum": "maximum value: {}",
"exclusiveMinimum": "exclusive minimum value: {}",
"exclusiveMaximum": "exclusive maximum value: {}",
"multipleOf": "must be a multiple of {}",
"minLength": "minimum length: {}",
"maxLength": "maximum length: {}",
"minProperties": "minimum number of properties: {}",
"maxProperties": "maximum number of properties: {}",
"patternProperties": "properties whose names match each pattern must satisfy: {}",
"propertyNames": "property names must satisfy: {}",
"dependentRequired": "dependent required properties: {}",
"dependentSchemas": "dependent schemas: {}",
"unevaluatedProperties": "unevaluated properties must satisfy: {}",
"if": "conditional (if): {}",
"then": "conditional (then): {}",
"else": "conditional (else): {}",
"not": "must not match: {}",
}
for field in unsupported_fields:
if field in schema:
value = schema[field]
# A falsy boolean constraint (e.g. ``uniqueItems: false``) imposes no
# real requirement, so don't add a misleading advisory note for it.
if isinstance(value, bool) and not value:
continue
# Sub-schema constraints (e.g. ``contains``) are serialized as JSON so
# the advisory note preserves what the constraint actually required,
# instead of just noting that it existed.
note_value = json.dumps(value) if isinstance(value, (dict, list)) else value
constraint_descriptions.append(constraint_labels[field].format(note_value))
unsupported_fields = set(constraint_labels)
# Build description additions from removed constraints. Iterating
# constraint_labels (not the set) keeps the note order deterministic across
# processes, so identical requests serialize identically regardless of
# PYTHONHASHSEED and stay cache-friendly.
constraint_descriptions: list = []
for field, label in constraint_labels.items():
if field not in schema:
continue
value = schema[field]
# A falsy boolean constraint (e.g. ``uniqueItems: false``) imposes no
# real requirement, so don't add a misleading advisory note for it.
if isinstance(value, bool) and not value:
continue
# Sub-schema constraints (e.g. ``contains``) are serialized as JSON so
# the advisory note preserves what the constraint actually required,
# instead of just noting that it existed.
note_value = json.dumps(value) if isinstance(value, (dict, list)) else value
constraint_descriptions.append(label.format(note_value))
result: Dict[str, Any] = {}
@ -583,11 +588,17 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
elif key == "$defs" and isinstance(value, dict):
result[key] = {k: AnthropicConfig.filter_anthropic_output_schema(v) for k, v in value.items()}
elif key == "anyOf" and isinstance(value, list):
result[key] = [AnthropicConfig.filter_anthropic_output_schema(item) for item in value]
result["anyOf"] = result.get("anyOf", []) + [
AnthropicConfig.filter_anthropic_output_schema(item) for item in value
]
elif key == "allOf" and isinstance(value, list):
result[key] = [AnthropicConfig.filter_anthropic_output_schema(item) for item in value]
elif key == "oneOf" and isinstance(value, list):
result[key] = [AnthropicConfig.filter_anthropic_output_schema(item) for item in value]
# Anthropic rejects oneOf ("Schema type 'oneOf' is not supported");
# the Anthropic SDK rewrites it to anyOf, so do the same.
result["anyOf"] = result.get("anyOf", []) + [
AnthropicConfig.filter_anthropic_output_schema(item) for item in value
]
else:
result[key] = value

View file

@ -225,3 +225,125 @@ class TestFilterAnthropicOutputSchema:
assert "uniqueItems" not in result
# A disabled constraint imposes no requirement -> no advisory note
assert "unique" not in result.get("description", "")
def test_removes_multipleof(self):
"""multipleOf is rejected by Anthropic for integer and number types."""
schema = {
"type": "object",
"properties": {"n": {"type": "integer", "multipleOf": 5}},
}
result = AnthropicConfig.filter_anthropic_output_schema(schema)
assert "multipleOf" not in result["properties"]["n"]
assert "must be a multiple of 5" in result["properties"]["n"]["description"]
def test_removes_conditional_and_negation_keywords(self):
"""if/then/else and not are rejected by Anthropic and stripped into notes."""
schema = {
"type": "object",
"properties": {"kind": {"type": "string"}, "sound": {"type": "string", "not": {"const": "moo"}}},
"if": {"properties": {"kind": {"const": "dog"}}},
"then": {"required": ["sound"]},
"else": {"required": ["kind"]},
}
result = AnthropicConfig.filter_anthropic_output_schema(schema)
assert "if" not in result
assert "then" not in result
assert "else" not in result
assert "not" not in result["properties"]["sound"]
assert 'conditional (if): {"properties": {"kind": {"const": "dog"}}}' in result["description"]
assert 'conditional (then): {"required": ["sound"]}' in result["description"]
assert 'conditional (else): {"required": ["kind"]}' in result["description"]
assert 'must not match: {"const": "moo"}' in result["properties"]["sound"]["description"]
def test_removes_object_shape_keywords(self):
"""patternProperties/propertyNames/dependent*/unevaluatedProperties are stripped."""
schema = {
"type": "object",
"properties": {"first": {"type": "string"}},
"patternProperties": {"^x": {"type": "string"}},
"propertyNames": {"pattern": "^[a-z]+$"},
"dependentRequired": {"first": ["last"]},
"dependentSchemas": {"first": {"required": ["last"]}},
"unevaluatedProperties": {"type": "string"},
}
result = AnthropicConfig.filter_anthropic_output_schema(schema)
for field in (
"patternProperties",
"propertyNames",
"dependentRequired",
"dependentSchemas",
"unevaluatedProperties",
):
assert field not in result
assert 'properties whose names match each pattern must satisfy: {"^x": {"type": "string"}}' in result["description"]
assert 'property names must satisfy: {"pattern": "^[a-z]+$"}' in result["description"]
assert 'dependent required properties: {"first": ["last"]}' in result["description"]
assert 'dependent schemas: {"first": {"required": ["last"]}}' in result["description"]
assert 'unevaluated properties must satisfy: {"type": "string"}' in result["description"]
def test_removes_prefixitems(self):
"""prefixItems is rejected by Anthropic for array types."""
schema = {
"type": "array",
"prefixItems": [{"type": "number"}, {"type": "string"}],
"items": {"type": "number"},
}
result = AnthropicConfig.filter_anthropic_output_schema(schema)
assert "prefixItems" not in result
assert result["items"] == {"type": "number"}
assert 'leading items must match, in order: [{"type": "number"}, {"type": "string"}]' in result["description"]
def test_oneof_rewritten_to_anyof(self):
"""oneOf 400s ("Schema type 'oneOf' is not supported") and becomes anyOf, like the SDK."""
schema = {
"type": "object",
"properties": {"id": {"oneOf": [{"type": "string", "minLength": 1}, {"type": "integer"}]}},
}
result = AnthropicConfig.filter_anthropic_output_schema(schema)
id_schema = result["properties"]["id"]
assert "oneOf" not in id_schema
assert [v["type"] for v in id_schema["anyOf"]] == ["string", "integer"]
assert "minLength" not in id_schema["anyOf"][0]
assert "minimum length: 1" in id_schema["anyOf"][0]["description"]
def test_oneof_merges_into_existing_anyof(self):
schema = {
"anyOf": [{"type": "string"}],
"oneOf": [{"type": "integer"}],
}
result = AnthropicConfig.filter_anthropic_output_schema(schema)
assert "oneOf" not in result
assert [v["type"] for v in result["anyOf"]] == ["string", "integer"]
def test_constraint_note_order_is_deterministic(self):
"""Note order must not depend on set iteration order (PYTHONHASHSEED), or the
serialized request differs across proxy workers and breaks caching."""
schema = {
"type": "array",
"items": {"type": "string"},
"minItems": 1,
"maxItems": 10,
"uniqueItems": True,
"minContains": 2,
"maxContains": 3,
}
result = AnthropicConfig.filter_anthropic_output_schema(schema)
assert result["description"] == (
"Note: minimum number of items: 1, maximum number of items: 10, "
"all array items must be unique, minimum number of matching items: 2, "
"maximum number of matching items: 3."
)

View file

@ -2,9 +2,12 @@
Coverage for filter_anthropic_output_schema's array/object constraint stripping.
Mirrors tests/litellm/llms/anthropic/test_anthropic_schema_filter.py, but lives
under tests/test_litellm/ so the coverage-uploading CI job exercises the newly
added keyword handling (uniqueItems / contains / minProperties / maxProperties)
and the ``uniqueItems: false`` branch.
under tests/test_litellm/ so the coverage-uploading CI job exercises the stripped
keyword handling (uniqueItems / contains / minProperties / maxProperties plus
multipleOf / patternProperties / propertyNames / dependentRequired /
dependentSchemas / unevaluatedProperties / if / then / else / not / prefixItems),
the ``uniqueItems: false`` branch, the oneOf to anyOf rewrite, and the
deterministic note ordering.
"""
from litellm.llms.anthropic.chat.transformation import AnthropicConfig
@ -71,3 +74,74 @@ class TestOutputFormatArrayObjectConstraints:
assert "maxProperties" not in result
assert "minimum number of properties: 1" in result["description"]
assert "maximum number of properties: 5" in result["description"]
def test_removes_remaining_rejected_keywords(self):
schema = {
"type": "object",
"properties": {
"n": {"type": "integer", "multipleOf": 5},
"pair": {"type": "array", "prefixItems": [{"type": "number"}], "items": {"type": "number"}},
"color": {"type": "string", "not": {"const": "red"}},
},
"patternProperties": {"^x": {"type": "string"}},
"propertyNames": {"pattern": "^[a-z]+$"},
"dependentRequired": {"n": ["pair"]},
"dependentSchemas": {"n": {"required": ["pair"]}},
"unevaluatedProperties": {"type": "string"},
"if": {"properties": {"n": {"const": 5}}},
"then": {"required": ["pair"]},
"else": {"required": ["color"]},
}
result = AnthropicConfig.filter_anthropic_output_schema(schema)
for field in (
"patternProperties",
"propertyNames",
"dependentRequired",
"dependentSchemas",
"unevaluatedProperties",
"if",
"then",
"else",
):
assert field not in result
assert "multipleOf" not in result["properties"]["n"]
assert "must be a multiple of 5" in result["properties"]["n"]["description"]
assert "prefixItems" not in result["properties"]["pair"]
assert 'leading items must match, in order: [{"type": "number"}]' in result["properties"]["pair"]["description"]
assert "not" not in result["properties"]["color"]
assert 'must not match: {"const": "red"}' in result["properties"]["color"]["description"]
assert 'conditional (if): {"properties": {"n": {"const": 5}}}' in result["description"]
def test_oneof_rewritten_to_anyof(self):
schema = {
"type": "object",
"properties": {"id": {"oneOf": [{"type": "string", "minLength": 1}, {"type": "integer"}]}},
}
result = AnthropicConfig.filter_anthropic_output_schema(schema)
id_schema = result["properties"]["id"]
assert "oneOf" not in id_schema
assert [v["type"] for v in id_schema["anyOf"]] == ["string", "integer"]
assert "minLength" not in id_schema["anyOf"][0]
def test_constraint_note_order_is_deterministic(self):
schema = {
"type": "array",
"items": {"type": "string"},
"minItems": 1,
"maxItems": 10,
"uniqueItems": True,
"minContains": 2,
"maxContains": 3,
}
result = AnthropicConfig.filter_anthropic_output_schema(schema)
assert result["description"] == (
"Note: minimum number of items: 1, maximum number of items: 10, "
"all array items must be unique, minimum number of matching items: 2, "
"maximum number of matching items: 3."
)