mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
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:
parent
a626170c89
commit
b36f34813a
2 changed files with 123 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue