fix(anthropic): reconcile enum with declared type in output_format schema (#37882)

* fix(anthropic): reconcile enum with declared type in output_format schema

Anthropic cross-validates `enum` against `type` in structured outputs: every
enum value must match a single declared type. A union `type` array, or an enum
value whose JSON type differs from a scalar `type`, is rejected with
"Invalid schema: Enum value 'low' does not match declared type '['string','null']'"

filter_anthropic_output_schema had no enum/type reconciliation, so both keys
reached Anthropic untouched. Drop the conflicting `type` -- `enum` is the
tighter constraint, and an enum with no `type` is accepted

The drop is conditional: `type` is only removed when it is a union array, or
when some enum value does not match the scalar type. A matching enum plus
scalar `type` is left exactly as-is, so existing behaviour is unchanged

Pydantic emits the failing shape for Optional[SomeEnum], so this affects any
caller with a nullable enum field on the native output_format path. vertex_ai
is unaffected because it is forced onto the permissive tool-use path

Fixes #37881

* refactor(anthropic): make enum/type reconciliation immutable and precisely typed

Address review: the predicate registry was a mutable `dict[str, Any]`, and the
reconciliation removed `type` by mutating the built result with `pop`

- registry is now `Final[Mapping[str, Callable[[Any], bool]]]` wrapped in
  `MappingProxyType`, so predicate signatures are statically checked and the
  table cannot be mutated
- the conflict decision moves into a pure helper evaluated once against the
  input schema, and the conflicting `type` key is skipped at build time in the
  existing loop instead of being popped afterwards, so nothing is mutated

Behaviour is unchanged; all 27 tests in the schema-filter suite still pass
This commit is contained in:
Darien Kindlund 2026-08-24 14:43:47 -04:00 committed by GitHub
parent a626170c89
commit b36f34813a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 123 additions and 1 deletions

View file

@ -1,7 +1,8 @@
import json
import re
import time
from collections.abc import Mapping, Sequence
from collections.abc import Callable, Mapping, Sequence
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final, NoReturn, cast
import httpx
@ -121,6 +122,32 @@ else:
# response side.
_ANTHROPIC_TOOL_NAME_INVALID_CHARS: Final = re.compile(r"[^a-zA-Z0-9_-]")
_ANTHROPIC_TOOL_NAME_MAX_LEN: Final = 128
_ENUM_TYPE_CHECKS: Final[Mapping[str, Callable[[Any], bool]]] = MappingProxyType(
{
"null": lambda v: v is None,
"boolean": lambda v: isinstance(v, bool),
"integer": lambda v: isinstance(v, int) and not isinstance(v, bool),
"number": lambda v: isinstance(v, (int, float)) and not isinstance(v, bool),
"string": lambda v: isinstance(v, str),
"array": lambda v: isinstance(v, list),
"object": lambda v: isinstance(v, dict),
}
)
def _enum_conflicts_with_declared_type(schema: Mapping[str, Any]) -> bool:
"""Whether ``schema``'s ``enum`` cannot match its declared ``type``."""
enum_values: Final = schema.get("enum")
declared_type: Final = schema.get("type")
if not isinstance(enum_values, list) or declared_type is None:
return False
if isinstance(declared_type, list):
return True
check: Final = _ENUM_TYPE_CHECKS.get(declared_type)
return check is not None and not all(check(value) for value in enum_values)
# Single, internal-only key on ``litellm_params`` used to thread the per-
# request reverse map (sanitized -> original) from request build to response
# parsing. ``litellm_params`` is never serialized to a provider; ``optional_
@ -565,9 +592,13 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
else:
result["description"] = constraint_note
drops_conflicting_type: Final = _enum_conflicts_with_declared_type(schema)
for key, value in schema.items():
if key in unsupported_fields:
continue
if key == "type" and drops_conflicting_type:
continue
if key == "description" and "description" in result:
# Already handled above
continue

View file

@ -417,3 +417,94 @@ class TestFilterAnthropicOutputSchema:
result = AnthropicConfig.filter_anthropic_output_schema(schema)
assert result["additionalProperties"] is False
def test_drops_union_type_alongside_enum(self):
"""A union ``type`` can never match a single declared type.
Anthropic rejects it with "Invalid schema: Enum value 'low' does not
match declared type '['string', 'null']'". ``enum`` is the tighter
constraint, so the conflicting ``type`` is dropped.
"""
schema = {
"type": "object",
"properties": {
"confidence": {
"enum": ["low", "medium", "high", None],
"type": ["string", "null"],
}
},
}
result = AnthropicConfig.filter_anthropic_output_schema(schema)
assert "type" not in result["properties"]["confidence"]
assert result["properties"]["confidence"]["enum"] == [
"low",
"medium",
"high",
None,
]
def test_drops_type_when_an_enum_value_does_not_match_it(self):
"""``enum: ["x", None]`` with ``type: "string"`` is rejected too."""
schema = {
"type": "object",
"properties": {"a": {"enum": ["x", None], "type": "string"}},
}
result = AnthropicConfig.filter_anthropic_output_schema(schema)
assert "type" not in result["properties"]["a"]
def test_preserves_type_when_every_enum_value_matches(self):
"""The non-conflicting case must be left exactly as-is."""
schema = {
"type": "object",
"properties": {"a": {"enum": ["x", "y"], "type": "string"}},
}
result = AnthropicConfig.filter_anthropic_output_schema(schema)
assert result["properties"]["a"]["type"] == "string"
assert result["properties"]["a"]["enum"] == ["x", "y"]
def test_integer_enum_satisfies_number_type(self):
"""JSON Schema ``number`` accepts integers, so this is not a conflict."""
schema = {
"type": "object",
"properties": {"a": {"enum": [1, 2], "type": "number"}},
}
result = AnthropicConfig.filter_anthropic_output_schema(schema)
assert result["properties"]["a"]["type"] == "number"
def test_bool_enum_does_not_satisfy_integer_type(self):
"""``bool`` is a Python ``int`` subclass but is not a JSON integer."""
schema = {
"type": "object",
"properties": {"a": {"enum": [True], "type": "integer"}},
}
result = AnthropicConfig.filter_anthropic_output_schema(schema)
assert "type" not in result["properties"]["a"]
def test_normalizes_enum_type_inside_array_items(self):
"""Normalization applies at every recursion site, not just top level."""
schema = {
"type": "object",
"properties": {
"rows": {
"type": "array",
"items": {
"type": "object",
"properties": {"c": {"enum": ["a", None], "type": ["string", "null"]}},
},
}
},
}
result = AnthropicConfig.filter_anthropic_output_schema(schema)
assert "type" not in result["properties"]["rows"]["items"]["properties"]["c"]