From f05f079e134a545c6d44224c126180b75ea3ec0b Mon Sep 17 00:00:00 2001 From: Darien Kindlund Date: Wed, 22 Jul 2026 18:49:45 -0400 Subject: [PATCH] fix(anthropic): strip uniqueItems + other unsupported array/object constraints from output_format schema (#33981) * fix(anthropic): strip uniqueItems + other unsupported array/object constraints from output_format schema Anthropic's structured outputs (`output_format`) validate the JSON schema against a strict subset and reject cross-element / count constraints that a constrained-decoding grammar cannot enforce, returning a 400 `invalid_request_error`. `filter_anthropic_output_schema` already stripped the numeric / string / item-count constraints (minimum, maximum, exclusiveMinimum/Maximum, minLength, maxLength, minItems, maxItems) but still let these through: - uniqueItems - contains / minContains / maxContains - minProperties / maxProperties so a request using them fails with e.g. "output_format.schema: For 'array' type, property 'uniqueItems' is not supported". This is provider-visible: newer Claude models on the native `output_format` path (e.g. `azure_ai`) 400, while `vertex_ai` is unaffected because it is forced onto the permissive tool-use path (#18625 / #19201). Add the missing keywords to the unsupported-field set and the description map, and skip the advisory description note for a disabled boolean constraint (`uniqueItems: false`) so it isn't misdescribed as required. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(anthropic): serialize contains sub-schema in output_format advisory note Address Greptile review: the `contains` advisory note previously discarded the sub-schema, so the description only said an item must match "a schema" without saying which. It now serializes the sub-schema as JSON (e.g. "array must contain an item matching: {\"type\": \"integer\", \"const\": 1}"), matching the other stripped constraints which carry their value. Sub-schema (dict/list) values are json.dumps'd; scalar constraints are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) * style(anthropic): apply ruff format to output_format filter change Co-Authored-By: Claude Opus 4.8 (1M context) * style(test): ruff format anthropic schema filter tests Co-Authored-By: Claude Opus 4.8 (1M context) * test(anthropic): cover output_format array/object constraint filtering in test_litellm tree Mirrors the schema-filter tests under tests/test_litellm/ so the coverage job exercises the new uniqueItems/contains/min-maxProperties handling and the uniqueItems: false branch. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- litellm/llms/anthropic/chat/transformation.py | 40 ++++++-- .../anthropic/test_anthropic_schema_filter.py | 91 +++++++++++++++++-- .../test_anthropic_output_format_filter.py | 73 +++++++++++++++ 3 files changed, 187 insertions(+), 17 deletions(-) create mode 100644 tests/test_litellm/llms/anthropic/test_anthropic_output_format_filter.py diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index 0ec1f3eae13..84f0c3ba7b9 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -478,10 +478,15 @@ 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: - - maxItems/minItems: Not supported for array types - - minimum/maximum: Not supported for numeric types - - minLength/maxLength: Not supported for string types + 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 + - minLength/maxLength: string constraints + - minProperties/maxProperties: object constraints 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 @@ -502,16 +507,22 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): if not isinstance(schema, dict): return schema - # All numeric/string/array constraints not supported by Anthropic + # All numeric/string/array/object constraints not supported by Anthropic unsupported_fields = { "maxItems", - "minItems", # array constraints + "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 @@ -519,16 +530,31 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): constraint_labels = { "minItems": "minimum number of items: {}", "maxItems": "maximum number of items: {}", + "uniqueItems": "all array items must be unique", + "contains": "array must contain an item matching: {}", + "minContains": "minimum number of matching items: {}", + "maxContains": "maximum number of matching items: {}", "minimum": "minimum value: {}", "maximum": "maximum value: {}", "exclusiveMinimum": "exclusive minimum value: {}", "exclusiveMaximum": "exclusive maximum value: {}", "minLength": "minimum length: {}", "maxLength": "maximum length: {}", + "minProperties": "minimum number of properties: {}", + "maxProperties": "maximum number of properties: {}", } for field in unsupported_fields: if field in schema: - constraint_descriptions.append(constraint_labels[field].format(schema[field])) + 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)) result: Dict[str, Any] = {} diff --git a/tests/litellm/llms/anthropic/test_anthropic_schema_filter.py b/tests/litellm/llms/anthropic/test_anthropic_schema_filter.py index bd3b4198e9e..3785cb9bc9f 100644 --- a/tests/litellm/llms/anthropic/test_anthropic_schema_filter.py +++ b/tests/litellm/llms/anthropic/test_anthropic_schema_filter.py @@ -45,21 +45,14 @@ class TestFilterAnthropicOutputSchema: 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"] - ) + 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} - }, + "properties": {"name": {"type": "string", "minLength": 1, "maxLength": 100}}, } result = AnthropicConfig.filter_anthropic_output_schema(schema) @@ -154,3 +147,81 @@ class TestFilterAnthropicOutputSchema: result = AnthropicConfig.filter_anthropic_output_schema(schema) assert result == schema # Should be unchanged + + def test_removes_uniqueitems(self): + """Test that uniqueItems is removed from array schemas. + + Reproduces the 400 ``invalid_request_error``: + "output_format.schema: For 'array' type, property 'uniqueItems' is not + supported". + """ + schema = { + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": {"type": "string"}, + "uniqueItems": True, + } + }, + } + + result = AnthropicConfig.filter_anthropic_output_schema(schema) + + assert "uniqueItems" not in result["properties"]["tags"] + assert result["properties"]["tags"]["items"] == {"type": "string"} + # Constraint intent preserved in the description + assert "all array items must be unique" in result["properties"]["tags"]["description"] + + def test_removes_contains_constraints(self): + """Test that contains/minContains/maxContains are removed from arrays.""" + schema = { + "type": "array", + "items": {"type": "integer"}, + "contains": {"type": "integer", "const": 1}, + "minContains": 1, + "maxContains": 3, + } + + result = AnthropicConfig.filter_anthropic_output_schema(schema) + + assert "contains" not in result + assert "minContains" not in result + assert "maxContains" not in result + assert result["items"] == {"type": "integer"} + # The contains sub-schema is serialized into the advisory note so the model + # knows what item the array must contain. + assert "array must contain an item matching:" in result["description"] + assert '"const": 1' in result["description"] + assert "minimum number of matching items: 1" in result["description"] + assert "maximum number of matching items: 3" in result["description"] + + def test_removes_object_property_constraints(self): + """Test that minProperties/maxProperties are removed from object schemas.""" + schema = { + "type": "object", + "properties": {"a": {"type": "string"}}, + "minProperties": 1, + "maxProperties": 5, + } + + result = AnthropicConfig.filter_anthropic_output_schema(schema) + + assert "minProperties" not in result + 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_uniqueitems_false_skips_misleading_note(self): + """``uniqueItems: false`` is stripped but must not add a 'unique' note.""" + schema = { + "type": "array", + "items": {"type": "string"}, + "uniqueItems": False, + } + + result = AnthropicConfig.filter_anthropic_output_schema(schema) + + assert "uniqueItems" not in result + # A disabled constraint imposes no requirement -> no advisory note + assert "unique" not in result.get("description", "") 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 new file mode 100644 index 00000000000..6f0ff4c7ca5 --- /dev/null +++ b/tests/test_litellm/llms/anthropic/test_anthropic_output_format_filter.py @@ -0,0 +1,73 @@ +""" +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. +""" + +from litellm.llms.anthropic.chat.transformation import AnthropicConfig + + +class TestOutputFormatArrayObjectConstraints: + def test_removes_uniqueitems(self): + schema = { + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": {"type": "string"}, + "uniqueItems": True, + } + }, + } + + result = AnthropicConfig.filter_anthropic_output_schema(schema) + + assert "uniqueItems" not in result["properties"]["tags"] + assert "all array items must be unique" in result["properties"]["tags"]["description"] + + def test_uniqueitems_false_skips_misleading_note(self): + schema = { + "type": "array", + "items": {"type": "string"}, + "uniqueItems": False, + } + + result = AnthropicConfig.filter_anthropic_output_schema(schema) + + assert "uniqueItems" not in result + assert "unique" not in result.get("description", "") + + def test_removes_contains_constraints(self): + schema = { + "type": "array", + "items": {"type": "integer"}, + "contains": {"type": "integer", "const": 1}, + "minContains": 1, + "maxContains": 3, + } + + result = AnthropicConfig.filter_anthropic_output_schema(schema) + + assert "contains" not in result + assert "minContains" not in result + assert "maxContains" not in result + assert "array must contain an item matching:" in result["description"] + assert '"const": 1' in result["description"] + + def test_removes_object_property_constraints(self): + schema = { + "type": "object", + "properties": {"a": {"type": "string"}}, + "minProperties": 1, + "maxProperties": 5, + } + + result = AnthropicConfig.filter_anthropic_output_schema(schema) + + assert "minProperties" not in result + assert "maxProperties" not in result + assert "minimum number of properties: 1" in result["description"] + assert "maximum number of properties: 5" in result["description"]