fix(anthropic): reconcile enum with declared type in output_format schema - #37882
Conversation
…hema 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 BerriAI#37881
Greptile SummaryThe PR normalizes Anthropic output schemas by omitting a declared type when it conflicts with enum values, while preserving compatible scalar types.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains.
|
| Filename | Overview |
|---|---|
| litellm/llms/anthropic/chat/transformation.py | Adds immutable enum/type predicates and conditionally omits provider-incompatible type declarations during schema filtering. |
| tests/test_litellm/llms/anthropic/test_anthropic_schema_filter.py | Adds focused regression tests covering conflicting and compatible enum/type combinations and nested array items. |
Reviews (3): Last reviewed commit: "refactor(anthropic): make enum/type reco..." | Re-trigger Greptile
…isely 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
|
@greptileai addressed in 846913f — the predicate registry is now |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
|
bugbot run |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 846913f. Configure here.
mateo-berri
left a comment
There was a problem hiding this comment.
LGTM. Thanks for the contribution!
b36f348
into
BerriAI:litellm_internal_staging
Relevant issues
Fixes #37881
User Flow
Before: the structured-output request is rejected the moment a field is a nullable enum.
POST https://<proxy>/v1/chat/completionswith"model": "claude-sonnet-5", a prompt, and aresponse_formatoftype: json_schemawhoseconfidenceproperty is{"enum": ["low","medium","high",null], "type": ["string","null"]}(exactly what Pydantic emits for anOptional[SomeEnum]field).HTTP 400:output_format.schema: Invalid schema: Enum value 'low' does not match declared type '['string', 'null']'."type"key, leaving only theenum, and re-sends the samePOST /v1/chat/completions; it now returnsHTTP 200, so every Pydantic-generated schema has to be edited by hand before it goes through.After: the same nullable-enum schema is accepted as-is.
POST https://<proxy>/v1/chat/completionswith the sameresponse_format(confidencestill{"enum": ["low","medium","high",null], "type": ["string","null"]}).HTTP 200withchoices[0].message.contenta valid JSON object such as{"confidence":"high"}that matches the schema.Optional[SomeEnum]schema works directly against the nativeoutput_formatpathPre-Submission checklist
ruff formatclean, target suite greenScreenshots / Proof of Fix
Root cause
Anthropic cross-validates
enumagainsttypein structured outputs — every enum value must match a single declared type.filter_anthropic_output_schemanever reconciled the two (grep -c '"enum"'on the file returns 0), sotypewas passed through verbatim by the terminalelse: result[key] = valueand reached Anthropic alongside theenum:This is a different class from the constraint-stripping work in #33981 / #34313 / #34319 / #35811 — those handled keywords Anthropic doesn't support. Here both keywords are fully supported; the conflict is between them.
Pydantic emits exactly the failing shape for
Optional[SomeEnum], so any caller with a nullable enum field on the nativeoutput_formatpath is affected.vertex_aiis unaffected (forced onto the permissive tool-use path, #18625 / #19201).Before (staging, v1.99.0)
Live, against a real
azure_ai/claude-sonnet-4-6deployment addressed directly bymodel_id(so avertex_aifallback could not mask it):After (commit
b3e071c)The conflicting
typeis dropped and the schema is accepted. Verified end-to-end with a real production schema that 400s as-is: after this normalization itsconfidencefield becomes{"enum": ["low","medium","high",null]}, and the same request returns HTTP 200 from Azure.Regression check on
tests/test_litellm/llms/anthropic/: 21 failed / 723 passed with this change vs 21 failed / 717 passed unmodified — identical pre-existing failure set, +6 new passing tests. (The pre-existing failures and two collection errors are a missing localorjson; they reproduce unmodified.)Why the drop is conditional
typeis removed only when it genuinely conflicts — a union array, or a scalar type some enum value doesn't match. A matchingenum+ scalartypeis left untouched.That is load-bearing, not stylistic:
test_preserves_valid_fieldsassertsresult == schemafor{"type": "string", "enum": ["active","inactive"]}. An unconditional drop would break it. This also means the fix does not contradict #22500, which deliberately preservedenumalongsidetype— that behaviour is retained for every non-conflicting schema.Type
🐛 Bug Fix
Changes
litellm/llms/anthropic/chat/transformation.py— add_ENUM_TYPE_CHECKS(JSON-type predicates) and, infilter_anthropic_output_schema, droptypewhen it conflicts withenum. Placed after the key loop and before theadditionalPropertiescoercion so a droppedtypecan never skip that step. The function already recurses intoproperties/items/$defs/anyOf/allOf, so nested and array-item enums are covered with no extra plumbing.tests/test_litellm/llms/anthropic/test_anthropic_schema_filter.py— 6 tests: union type, null-in-enum with scalar type, the matching case preserved,integerenum satisfyingnumber,boolnot satisfyinginteger, and normalization inside array items.Semantic caveat (disclosed)
JSON Schema semantics are an intersection: valid values =
enum∩type. When some enum values don't match the declared type, those values were formally unreachable — e.g.{"enum": ["low", null], "type": "string"}strictly permits only"low". Droppingtypemakesnullreachable, so this is a widening, not a no-op.I think that's the right call: such a schema is almost always the nullable-enum idiom, the author's intent is evidently to allow
null, and the strict reading makes the schema self-contradictory — but it is a behaviour change and reviewers should see it named. Where the types already agree, dropping is lossless and this PR doesn't do it anyway.Two smaller notes: a mixed-type enum (
[1, "a", null]) has no single validtype, so there is nothing to preserve; and if a conflicting schema also carriesformat/pattern, those lose their type anchor — in practice inert, sinceenumalready pins the value set to a finite list.Scope notes
output_formatto the tool path when unsupported constructs appear. Its detection set is the numeric/length constraint keywords, so anenum+ union-typeschema still goes native and still 400s under it. Normalization is the smaller fix; if fix(anthropic): fall back to tool-based JSON when schema has unsupported constraints #31750 lands, this remains correct.$ref— a documented Anthropic limitation, not a LiteLLM gap. To my knowledge this closes the last LiteLLM-side gap in this family.Note
Low Risk
Low risk: localized schema normalization before the Anthropic API; only drops
typewhen it conflicts withenum, which can slightly widen valid values for contradictory nullable-enum schemas.Overview
Fixes Anthropic HTTP 400 on native structured outputs when Pydantic-style schemas combine
enumwith atypethat cannot hold every enum value (e.g.Optional[Enum]→type: ["string", "null"]plus enum includingnull).filter_anthropic_output_schemanow detects conflicts via_enum_conflicts_with_declared_type(uniontypearrays, or scalartypewhere any enum value fails JSON-type checks) and skips emittingtypewhile keepingenum. Matching pairs likeenum: ["active","inactive"]withtype: "string"are unchanged. Recursion throughproperties,items, etc. applies the same rule.Six unit tests cover union types, null in enum, preserved matches,
numbervs integer enums, bool vs integer, and nested array items.Reviewed by Cursor Bugbot for commit 846913f. Bugbot is set up for automated code reviews on this repo. Configure here.
Reviewer QA: live proof at tip
846913f3cTwo local proxies, same config and the same real model
anthropic/claude-sonnet-5, baselitellm_internal_stagingvs this PR's head, real Anthropic API and no mocks. Same request body for every row.Main repro (nullable enum, the
Optional[SomeEnum]shape):Base (before):
Head (after):
Full A/B matrix, base vs head against the same live proxy pair:
Every schema Anthropic already accepted is unchanged (200 stays 200); only the three it already rejected flip 400 to 200. The fix is a strict widening with no regression on the preserved paths, and the recursion reaches nested and array-item enums.
846913f3cpasses /live-pr-risk. Full dependency graph offilter_anthropic_output_schematraced: its only caller is the nativeoutput_formatpath viamap_response_format_to_anthropic_output_format, the new predicate helpers are module-private, and Bedrock is not yet wired in. Driven live base vs head with real Anthropic calls; no previously-working dependent path regresses