Skip to content

fix(lib): normalise JSON Schema type arrays instead of hitting assert_never - #1877

Open
cmun2 wants to merge 1 commit into
anthropics:mainfrom
cmun2:fix/transform-schema-type-arrays
Open

fix(lib): normalise JSON Schema type arrays instead of hitting assert_never#1877
cmun2 wants to merge 1 commit into
anthropics:mainfrom
cmun2:fix/transform-schema-type-arrays

Conversation

@cmun2

@cmun2 cmun2 commented Aug 23, 2026

Copy link
Copy Markdown

Fixes #1876.

The bug

transform_schema raises AssertionError: Expected code to be unreachable, but got: ['string', 'null'] on any schema whose type is an array. It fails locally, before any request is sent, so a caller sees an AssertionError rather than a validation error or a 400.

type_ is annotated Optional[SupportedTypes], but nothing enforces that at runtime: the value is written straight into strict_schema["type"] and the dispatch chain then falls through to assert_never, which is a static-exhaustiveness device rather than an input validator.

Type arrays are valid JSON Schema (draft 4 onward) and are the default output of z.string().nullable() and Optional[str], so they arrive routinely rather than exceptionally. The strict tool use documentation budgets for them explicitly:

Total parameters that use anyOf or type arrays (for example, "type": ["string", "null"]) across all strict schemas.

The change

A type array means the same thing as the anyOf spelling, so it is rewritten that way — the move this function already makes for oneOf immediately above.

transform_schema({"type": ["string", "null"]})
# {'anyOf': [{'type': 'string'}, {'type': 'null'}]}

and it now agrees with the equivalent input, which there is a test for:

transform_schema({"type": ["string", "null"]}) == transform_schema({"anyOf": [{"type": "string"}, {"type": "null"}]})

Each branch receives only the keywords its own type consumes. Handing the whole schema to every branch looked simpler but produced this for ["object", "null"] — the null branch carrying a stringified copy of properties into its description, which then goes to the model:

{"anyOf": [{"type": "object", ...}, {"type": "null", "description": "{properties: {'a': {'type': 'string'}}, required: ['a']}"}]}

So properties/required/additionalProperties, format, and items/minItems go to the matching branch, and anything no branch claims stays on the parent and is described once:

transform_schema({"type": ["string", "null"], "description": "A query", "minLength": 2})
# {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'description': 'A query\n\n{minLength: 2}'}

Malformed arrays — empty, non-string members, unknown type names, repeated members — raise ValueError.

assert_never is deliberately left reachable for an unrecognised type name, since test_unsupported_type_asserts pins that. A type array is not a bad type name; it is a different shape, and only that shape is redirected.

Tests

12 added, covering a nullable primitive, a union without null, equivalence with the anyOf spelling, description hoisting, the object and array branches, a type array nested in a property, the four malformed inputs, and input immutability. The 14 existing tests are unchanged and still pass. ruff check, ruff format --check and pyright are clean on both files.

Why this shape matters in practice

I found this running the SDK's own transform_schema as an oracle over 14,804 tool schemas collected from 617 public MCP servers (data). Type arrays appear in 88 tools across 30 servers:

type array tools servers
["string","null"] 57 17
["integer","null"] 9 2
["number","null"] 7 6
["string","number"] 7 3
other arrays (5 shapes) 8 5
any type array 88 30

75 of the 88 contain "null", so nullable dominates — but ["string","number"] is real, which is why the fix is not nullable-only.

Note

@russlan23 proposed this approach independently in #1876 and reached the same conclusions about the cause and about src/anthropic/lib/ being outside generated code. Happy to add a Co-authored-by trailer, or to close this if you would rather take their PR — I opened it because I had the reproduction and the corpus to hand, not to pre-empt anyone.

If explicit rejection is the intended contract rather than normalisation, the change is small either way and I am happy to switch it.

@cmun2
cmun2 requested a review from a team as a code owner August 23, 2026 15:30

@russlan23 russlan23 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I reviewed the type-array normalization and branch-specific keyword routing against the failure in #1876. The implementation preserves member order, keeps shared annotations on the parent, routes object, string, and array keywords only to matching branches, validates malformed arrays, and preserves input immutability.

Independent verification:

  • Python 3.10.20: 26/26 focused transform tests passed
  • Python 3.14.7: 26/26 focused transform tests passed
  • Ruff check and format check passed on both changed files
  • Pyright reported 0 errors and 0 warnings on both changed files

I did not find a blocking issue.

Since this implements the design and regression-test scope I proposed in #1876, yes, please add Co-authored-by: Russlan Ramdowar rrusslanjr@gmail.com as you offered.

…_never

transform_schema raised AssertionError on any schema whose `type` is an
array, e.g. `{"type": ["string", "null"]}`. `type_` is annotated
Optional[SupportedTypes] but nothing enforces that at runtime, so a list
was written into strict_schema and then fell through the dispatch chain
to assert_never — a static-exhaustiveness device reached by ordinary
input, before any request was sent.

Type arrays are valid JSON Schema and are what z.string().nullable() and
Optional[str] emit by default. The strict tool use limits table budgets
for them explicitly: "Total parameters that use anyOf or type arrays (for
example, "type": ["string", "null"])".

A type array means the same thing as the anyOf spelling, so rewrite it
that way — the move this function already makes for oneOf. Each branch
receives only the keywords its own type consumes; handing the whole
schema to every branch would stringify a `["object", "null"]` schema's
entire properties dict into the null branch's description. Malformed
arrays (empty, non-string members, unknown type names, repeats) raise
ValueError.

An unrecognised type *name* still reaches assert_never, which
test_unsupported_type_asserts pins deliberately. That path is untouched:
a type array is not a bad type name, it is a different shape.

Fixes anthropics#1876

Co-authored-by: Russlan Ramdowar <rrusslanjr@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cmun2
cmun2 force-pushed the fix/transform-schema-type-arrays branch from fc10f26 to 90cf3c1 Compare August 23, 2026 15:50
@cmun2

cmun2 commented Aug 23, 2026

Copy link
Copy Markdown
Author

Added — the commit now carries Co-authored-by: Russlan Ramdowar <rrusslanjr@gmail.com>. Thank you for running it on 3.10 and 3.14; I had only checked one interpreter, so that is coverage I did not have.

Two places where the implementation departed from your outline, in case either is worth pushing back on:

Branch keyword routing. Your description said each branch preserves the existing object/array/string-format/extra-key handling, which I first read as "hand the whole schema to every branch". That turned out badly for ["object", "null"] — the null branch inherited properties, no branch consumed it, and it landed in that branch's description as a stringified dict that then goes to the model. So each branch now receives only the keywords its own type consumes, and anything unclaimed stays on the parent and is described once.

The scalar path. I did try making an unrecognised type name raise ValueError too, since it reaches the same assert_never. test_unsupported_type_asserts fails on that, so it is a deliberate contract and I reverted it. Worth stating in the PR because it narrows the claim: the defect is not that assert_never is wrong, it is that a list is not a bad type name and should never have arrived there.

Neither of us can approve anything here, so this is still a maintainer's call — including whether normalisation or explicit rejection is the intended contract.

@russlan23

Copy link
Copy Markdown

No pushback on either choice. Branch-specific routing is the safer normalization because it preserves keyword applicability and prevents object-only metadata from leaking into the null branch description. Keeping unknown scalar type names on the existing assert_never path also keeps this fix scoped and preserves the behavior pinned by the current test.

I compared amended head 90cf3c1 with the reviewed head: their Git trees are identical, so the Python 3.10/3.14, Ruff, and Pyright results still apply. From my side this is ready for the SDK maintainers.

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.

transform_schema raises AssertionError on type arrays (e.g. type: ["string","null"])

2 participants