Skip to content

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

Merged
mateo-berri merged 2 commits into
BerriAI:litellm_internal_stagingfrom
dkindlund:fix/anthropic-output-format-enum-type
Aug 24, 2026
Merged

fix(anthropic): reconcile enum with declared type in output_format schema#37882
mateo-berri merged 2 commits into
BerriAI:litellm_internal_stagingfrom
dkindlund:fix/anthropic-output-format-enum-type

Conversation

@dkindlund

@dkindlund dkindlund commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Relevant issues

Fixes #37881

User Flow

Before: the structured-output request is rejected the moment a field is a nullable enum.

  1. A developer sends POST https://<proxy>/v1/chat/completions with "model": "claude-sonnet-5", a prompt, and a response_format of type: json_schema whose confidence property is {"enum": ["low","medium","high",null], "type": ["string","null"]} (exactly what Pydantic emits for an Optional[SomeEnum] field).
  2. The call returns HTTP 400: output_format.schema: Invalid schema: Enum value 'low' does not match declared type '['string', 'null']'.
  3. The developer hand-strips the "type" key, leaving only the enum, and re-sends the same POST /v1/chat/completions; it now returns HTTP 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.

  1. The developer sends the same POST https://<proxy>/v1/chat/completions with the same response_format (confidence still {"enum": ["low","medium","high",null], "type": ["string","null"]}).
  2. The call returns HTTP 200 with choices[0].message.content a valid JSON object such as {"confidence":"high"} that matches the schema.
  3. No hand-editing: the untouched Optional[SomeEnum] schema works directly against the native output_format path

Pre-Submission checklist

  • I have added meaningful tests
  • My PR passes all CI/CD checks (lint, format, unit tests) — ruff format clean, target suite green
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 (pending automatic review)

Screenshots / Proof of Fix

Root cause

Anthropic cross-validates enum against type in structured outputs — every enum value must match a single declared type. filter_anthropic_output_schema never reconciled the two (grep -c '"enum"' on the file returns 0), so type was passed through verbatim by the terminal else: result[key] = value and reached Anthropic alongside the enum:

output_format.schema: Invalid schema: Enum value 'low' does not match declared type '['string', 'null']'

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 native output_format path is affected. vertex_ai is unaffected (forced onto the permissive tool-use path, #18625 / #19201).

Before (staging, v1.99.0)

AnthropicConfig.filter_anthropic_output_schema(
    {"type": "object", "properties": {
        "confidence": {"enum": ["low","medium","high",None], "type": ["string","null"]}}}
)["properties"]["confidence"]
# -> {'enum': ['low','medium','high',None], 'type': ['string','null']}   <-- 400s at Anthropic

Live, against a real azure_ai/claude-sonnet-4-6 deployment addressed directly by model_id (so a vertex_ai fallback could not mask it):

enum + type:["string","null"]                    -> HTTP 400
enum + type:["string","null"] (no null in enum)  -> HTTP 400
enum:["x",null] + type:"string"                  -> HTTP 400
enum + type:"string"  (matching)                 -> HTTP 200
enum with NO type key                            -> HTTP 200   <-- the accepted form

After (commit b3e071c)

The conflicting type is dropped and the schema is accepted. Verified end-to-end with a real production schema that 400s as-is: after this normalization its confidence field becomes {"enum": ["low","medium","high",null]}, and the same request returns HTTP 200 from Azure.

$ uv run pytest tests/test_litellm/llms/anthropic/test_anthropic_schema_filter.py -q
27 passed

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 local orjson; they reproduce unmodified.)

Why the drop is conditional

type is removed only when it genuinely conflicts — a union array, or a scalar type some enum value doesn't match. A matching enum + scalar type is left untouched.

That is load-bearing, not stylistic: test_preserves_valid_fields asserts result == schema for {"type": "string", "enum": ["active","inactive"]}. An unconditional drop would break it. This also means the fix does not contradict #22500, which deliberately preserved enum alongside type — 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, in filter_anthropic_output_schema, drop type when it conflicts with enum. Placed after the key loop and before the additionalProperties coercion so a dropped type can never skip that step. The function already recurses into properties/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, integer enum satisfying number, bool not satisfying integer, and normalization inside array items.

Semantic caveat (disclosed)

JSON Schema semantics are an intersection: valid values = enumtype. 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". Dropping type makes null reachable, 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 valid type, so there is nothing to preserve; and if a conflicting schema also carries format/pattern, those lose their type anchor — in practice inert, since enum already pins the value set to a finite list.

Scope notes


Note

Low Risk
Low risk: localized schema normalization before the Anthropic API; only drops type when it conflicts with enum, 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 enum with a type that cannot hold every enum value (e.g. Optional[Enum]type: ["string", "null"] plus enum including null).

filter_anthropic_output_schema now detects conflicts via _enum_conflicts_with_declared_type (union type arrays, or scalar type where any enum value fails JSON-type checks) and skips emitting type while keeping enum. Matching pairs like enum: ["active","inactive"] with type: "string" are unchanged. Recursion through properties, items, etc. applies the same rule.

Six unit tests cover union types, null in enum, preserved matches, number vs 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 846913f3c

Two local proxies, same config and the same real model anthropic/claude-sonnet-5, base litellm_internal_staging vs this PR's head, real Anthropic API and no mocks. Same request body for every row.

Main repro (nullable enum, the Optional[SomeEnum] shape):

curl -sS -w '\nHTTP:%{http_code}\n' "$PROXY/v1/chat/completions" \
  -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' -d '{
    "model":"claude-struct",
    "messages":[{"role":"user","content":"How confident are you that 2+2=4? Emit a minimal valid object."}],
    "max_tokens":150,
    "response_format":{"type":"json_schema","json_schema":{"name":"r","schema":{
      "type":"object",
      "properties":{"confidence":{"enum":["low","medium","high",null],"type":["string","null"]}},
      "required":["confidence"]}}}
  }'

Base (before):

{"error":{"message":"litellm.BadRequestError: AnthropicException - {...\"message\":\"output_format.schema: Invalid schema: Enum value 'low' does not match declared type '['string', 'null']'\"...}. Received Model Group=claude-struct","code":"400"}}
HTTP:400

Head (after):

{"id":"chatcmpl-...","object":"chat.completion","model":"claude-struct","choices":[{"finish_reason":"stop","index":0,"message":{"content":"{\"confidence\":\"high\"}","role":"assistant"}}],"usage":{"completion_tokens":12,"prompt_tokens":240,"total_tokens":252}}
HTTP:200

Full A/B matrix, base vs head against the same live proxy pair:

Schema Base Head
enum + matching scalar type 200 200
plain typed object, no enum 200 200
integer enum + number type 200 200
null-in-enum + scalar string 400 200
union type + enum (main repro) 400 200
nested array-items enum conflict 400 200

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.

  • 846913f3c passes /live-pr-risk. Full dependency graph of filter_anthropic_output_schema traced: its only caller is the native output_format path via map_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

…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-apps

greptile-apps Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR normalizes Anthropic output schemas by omitting a declared type when it conflicts with enum values, while preserving compatible scalar types.

  • Adds immutable JSON-type predicate dispatch and a pure conflict-detection helper.
  • Applies conflict handling during recursive schema reconstruction.
  • Adds regression coverage for nullable, numeric, boolean, and nested enum schemas.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

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

Comment thread litellm/llms/anthropic/chat/transformation.py Outdated
…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
@dkindlund

Copy link
Copy Markdown
Contributor Author

@greptileai addressed in 846913f — the predicate registry is now Final[Mapping[str, Callable[[Any], bool]]] wrapped in MappingProxyType, and the pop mutation is gone entirely: the conflict decision moved to a pure helper and the conflicting type key is skipped at build time in the existing loop, so result is never mutated after the fact. Please re-review

@codecov

codecov Bot commented Aug 21, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@codspeed-hq

codspeed-hq Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing dkindlund:fix/anthropic-output-format-enum-type (846913f) with litellm_internal_staging (0a5fa4f)1

Open in CodSpeed

Footnotes

  1. No successful run was found on litellm_internal_staging (f48d219) during the generation of this report, so 0a5fa4f was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@mateo-berri

Copy link
Copy Markdown
Contributor

bugbot run

@mateo-berri

Copy link
Copy Markdown
Contributor

@greptileai

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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 mateo-berri left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Thanks for the contribution!

@mateo-berri
mateo-berri merged commit b36f348 into BerriAI:litellm_internal_staging Aug 24, 2026
72 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: enum + union/mismatched type is forwarded to Anthropic output_format.schema and 400s ("Enum value X does not match declared type")

2 participants