mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
fix(anthropic): strip all remaining output_format schema keywords rejected by Anthropic
This commit is contained in:
parent
c2c12d70b5
commit
2ec2a92da2
3 changed files with 251 additions and 44 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue