mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
fix(anthropic): filter unsupported JSON schema constraints for structured outputs (#20813)
* fix(anthropic): filter unsupported JSON schema constraints for structured outputs Fixes 400 error when using Anthropic models with structured outputs that have min/max constraints. The Anthropic API doesn't support these JSON schema constraints: - minimum/maximum (numeric) - exclusiveMinimum/exclusiveMaximum (numeric) - minLength/maxLength (string) - minItems/maxItems (array) This mirrors the transformation done by the official Anthropic Python SDK. See: https://platform.claude.com/docs/en/build-with-claude/structured-outputs#how-sdk-transformation-works Adds tests for the schema filtering function. * fix: update descriptions with removed constraint info in filter_anthropic_output_schema Address review feedback: the function now appends removed constraint information to the description field (matching Anthropic SDK behavior), rather than silently dropping constraints. --------- Co-authored-by: OpenClaw <openclaw@users.noreply.github.com>
This commit is contained in:
parent
96802e177b
commit
84934a7258
2 changed files with 206 additions and 7 deletions
|
|
@ -208,29 +208,73 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
|
||||||
Filter out unsupported fields from JSON schema for Anthropic's output_format API.
|
Filter out unsupported fields from JSON schema for Anthropic's output_format API.
|
||||||
|
|
||||||
Anthropic's output_format doesn't support certain JSON schema properties:
|
Anthropic's output_format doesn't support certain JSON schema properties:
|
||||||
- maxItems: Not supported for array types
|
- maxItems/minItems: Not supported for array types
|
||||||
- minItems: Not supported for array types
|
- minimum/maximum: Not supported for numeric types
|
||||||
|
- minLength/maxLength: Not supported for string types
|
||||||
|
|
||||||
This function recursively removes these unsupported fields while preserving
|
This mirrors the transformation done by the Anthropic Python SDK.
|
||||||
all other valid schema properties.
|
See: https://platform.claude.com/docs/en/build-with-claude/structured-outputs#how-sdk-transformation-works
|
||||||
|
|
||||||
|
The SDK approach:
|
||||||
|
1. Remove unsupported constraints from schema
|
||||||
|
2. Add constraint info to description (e.g., "Must be at least 100")
|
||||||
|
3. Validate responses against original schema
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
schema: The JSON schema dictionary to filter
|
schema: The JSON schema dictionary to filter
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A new dictionary with unsupported fields removed
|
A new dictionary with unsupported fields removed and descriptions updated
|
||||||
|
|
||||||
Related issue: https://github.com/BerriAI/litellm/issues/19444
|
Related issues:
|
||||||
|
- https://github.com/BerriAI/litellm/issues/19444
|
||||||
"""
|
"""
|
||||||
if not isinstance(schema, dict):
|
if not isinstance(schema, dict):
|
||||||
return schema
|
return schema
|
||||||
|
|
||||||
unsupported_fields = {"maxItems", "minItems"}
|
# All numeric/string/array constraints not supported by Anthropic
|
||||||
|
unsupported_fields = {
|
||||||
|
"maxItems", "minItems", # array constraints
|
||||||
|
"minimum", "maximum", # numeric constraints
|
||||||
|
"exclusiveMinimum", "exclusiveMaximum", # numeric constraints
|
||||||
|
"minLength", "maxLength", # string constraints
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build description additions from removed constraints
|
||||||
|
constraint_descriptions: list = []
|
||||||
|
constraint_labels = {
|
||||||
|
"minItems": "minimum number of items: {}",
|
||||||
|
"maxItems": "maximum number of items: {}",
|
||||||
|
"minimum": "minimum value: {}",
|
||||||
|
"maximum": "maximum value: {}",
|
||||||
|
"exclusiveMinimum": "exclusive minimum value: {}",
|
||||||
|
"exclusiveMaximum": "exclusive maximum value: {}",
|
||||||
|
"minLength": "minimum length: {}",
|
||||||
|
"maxLength": "maximum length: {}",
|
||||||
|
}
|
||||||
|
for field in unsupported_fields:
|
||||||
|
if field in schema:
|
||||||
|
constraint_descriptions.append(
|
||||||
|
constraint_labels[field].format(schema[field])
|
||||||
|
)
|
||||||
|
|
||||||
result: Dict[str, Any] = {}
|
result: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
# Update description with removed constraint info
|
||||||
|
if constraint_descriptions:
|
||||||
|
existing_desc = schema.get("description", "")
|
||||||
|
constraint_note = "Note: " + ", ".join(constraint_descriptions) + "."
|
||||||
|
if existing_desc:
|
||||||
|
result["description"] = existing_desc + " " + constraint_note
|
||||||
|
else:
|
||||||
|
result["description"] = constraint_note
|
||||||
|
|
||||||
for key, value in schema.items():
|
for key, value in schema.items():
|
||||||
if key in unsupported_fields:
|
if key in unsupported_fields:
|
||||||
continue
|
continue
|
||||||
|
if key == "description" and "description" in result:
|
||||||
|
# Already handled above
|
||||||
|
continue
|
||||||
|
|
||||||
if key == "properties" and isinstance(value, dict):
|
if key == "properties" and isinstance(value, dict):
|
||||||
result[key] = {
|
result[key] = {
|
||||||
|
|
|
||||||
155
tests/litellm/llms/anthropic/test_anthropic_schema_filter.py
Normal file
155
tests/litellm/llms/anthropic/test_anthropic_schema_filter.py
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
"""
|
||||||
|
Tests for Anthropic JSON schema filtering.
|
||||||
|
|
||||||
|
Related to: https://platform.claude.com/docs/en/build-with-claude/structured-outputs#how-sdk-transformation-works
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from litellm.llms.anthropic.chat.transformation import AnthropicConfig
|
||||||
|
|
||||||
|
|
||||||
|
class TestFilterAnthropicOutputSchema:
|
||||||
|
"""Test the filter_anthropic_output_schema function."""
|
||||||
|
|
||||||
|
def test_removes_numeric_constraints(self):
|
||||||
|
"""Test that minimum/maximum are removed from numeric schemas."""
|
||||||
|
schema = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"age": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 150,
|
||||||
|
"description": "Person's age"
|
||||||
|
},
|
||||||
|
"score": {
|
||||||
|
"type": "number",
|
||||||
|
"exclusiveMinimum": 0,
|
||||||
|
"exclusiveMaximum": 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = AnthropicConfig.filter_anthropic_output_schema(schema)
|
||||||
|
|
||||||
|
# minimum/maximum should be removed
|
||||||
|
assert "minimum" not in result["properties"]["age"]
|
||||||
|
assert "maximum" not in result["properties"]["age"]
|
||||||
|
assert "exclusiveMinimum" not in result["properties"]["score"]
|
||||||
|
assert "exclusiveMaximum" not in result["properties"]["score"]
|
||||||
|
|
||||||
|
# Other fields preserved
|
||||||
|
assert result["properties"]["age"]["type"] == "integer"
|
||||||
|
# Description should be updated with removed constraint info
|
||||||
|
assert "Person's age" in result["properties"]["age"]["description"]
|
||||||
|
assert "minimum value: 0" in result["properties"]["age"]["description"]
|
||||||
|
assert "maximum value: 150" in result["properties"]["age"]["description"]
|
||||||
|
# Score had no description, should get one from constraints
|
||||||
|
assert "exclusive minimum value: 0" in result["properties"]["score"]["description"]
|
||||||
|
assert "exclusive maximum value: 100" in result["properties"]["score"]["description"]
|
||||||
|
|
||||||
|
def test_removes_string_constraints(self):
|
||||||
|
"""Test that minLength/maxLength are removed from string schemas."""
|
||||||
|
schema = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = AnthropicConfig.filter_anthropic_output_schema(schema)
|
||||||
|
|
||||||
|
assert "minLength" not in result["properties"]["name"]
|
||||||
|
assert "maxLength" not in result["properties"]["name"]
|
||||||
|
assert result["properties"]["name"]["type"] == "string"
|
||||||
|
# Description should contain constraint info
|
||||||
|
assert "minimum length: 1" in result["properties"]["name"]["description"]
|
||||||
|
assert "maximum length: 100" in result["properties"]["name"]["description"]
|
||||||
|
|
||||||
|
def test_removes_array_constraints(self):
|
||||||
|
"""Test that minItems/maxItems are removed from array schemas."""
|
||||||
|
schema = {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
"minItems": 1,
|
||||||
|
"maxItems": 10
|
||||||
|
}
|
||||||
|
|
||||||
|
result = AnthropicConfig.filter_anthropic_output_schema(schema)
|
||||||
|
|
||||||
|
assert "minItems" not in result
|
||||||
|
assert "maxItems" not in result
|
||||||
|
assert result["type"] == "array"
|
||||||
|
assert result["items"] == {"type": "string"}
|
||||||
|
# Description should contain constraint info
|
||||||
|
assert "minimum number of items: 1" in result["description"]
|
||||||
|
assert "maximum number of items: 10" in result["description"]
|
||||||
|
|
||||||
|
def test_handles_nested_schemas(self):
|
||||||
|
"""Test that nested schemas are also filtered."""
|
||||||
|
schema = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"items": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"quantity": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"minItems": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = AnthropicConfig.filter_anthropic_output_schema(schema)
|
||||||
|
|
||||||
|
# Top level array constraint removed
|
||||||
|
assert "minItems" not in result["properties"]["items"]
|
||||||
|
|
||||||
|
# Nested numeric constraints removed
|
||||||
|
nested_props = result["properties"]["items"]["items"]["properties"]
|
||||||
|
assert "minimum" not in nested_props["quantity"]
|
||||||
|
assert "maximum" not in nested_props["quantity"]
|
||||||
|
|
||||||
|
def test_handles_anyof_oneof_allof(self):
|
||||||
|
"""Test that anyOf/oneOf/allOf schemas are filtered recursively."""
|
||||||
|
schema = {
|
||||||
|
"anyOf": [
|
||||||
|
{"type": "integer", "minimum": 0},
|
||||||
|
{"type": "string", "minLength": 1}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
result = AnthropicConfig.filter_anthropic_output_schema(schema)
|
||||||
|
|
||||||
|
assert "minimum" not in result["anyOf"][0]
|
||||||
|
assert "minLength" not in result["anyOf"][1]
|
||||||
|
|
||||||
|
def test_preserves_valid_fields(self):
|
||||||
|
"""Test that valid schema fields are preserved."""
|
||||||
|
schema = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["active", "inactive"],
|
||||||
|
"description": "Current status"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["status"],
|
||||||
|
"additionalProperties": False
|
||||||
|
}
|
||||||
|
|
||||||
|
result = AnthropicConfig.filter_anthropic_output_schema(schema)
|
||||||
|
|
||||||
|
assert result == schema # Should be unchanged
|
||||||
Loading…
Add table
Reference in a new issue