diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index b7d14e7721e..e99f356f8f2 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -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 diff --git a/tests/litellm/llms/anthropic/test_anthropic_schema_filter.py b/tests/litellm/llms/anthropic/test_anthropic_schema_filter.py index 3785cb9bc9f..c10ac5532a0 100644 --- a/tests/litellm/llms/anthropic/test_anthropic_schema_filter.py +++ b/tests/litellm/llms/anthropic/test_anthropic_schema_filter.py @@ -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." + ) diff --git a/tests/test_litellm/llms/anthropic/test_anthropic_output_format_filter.py b/tests/test_litellm/llms/anthropic/test_anthropic_output_format_filter.py index 6f0ff4c7ca5..90cba035760 100644 --- a/tests/test_litellm/llms/anthropic/test_anthropic_output_format_filter.py +++ b/tests/test_litellm/llms/anthropic/test_anthropic_output_format_filter.py @@ -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." + )