fix(lib): normalise JSON Schema type arrays instead of hitting assert_never - #1877
fix(lib): normalise JSON Schema type arrays instead of hitting assert_never#1877cmun2 wants to merge 1 commit into
Conversation
russlan23
left a comment
There was a problem hiding this comment.
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>
fc10f26 to
90cf3c1
Compare
|
Added — the commit now carries 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 The scalar path. I did try making an unrecognised type name raise 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. |
|
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. |
Fixes #1876.
The bug
transform_schemaraisesAssertionError: Expected code to be unreachable, but got: ['string', 'null']on any schema whosetypeis an array. It fails locally, before any request is sent, so a caller sees anAssertionErrorrather than a validation error or a 400.type_is annotatedOptional[SupportedTypes], but nothing enforces that at runtime: the value is written straight intostrict_schema["type"]and the dispatch chain then falls through toassert_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()andOptional[str], so they arrive routinely rather than exceptionally. The strict tool use documentation budgets for them explicitly:The change
A type array means the same thing as the
anyOfspelling, so it is rewritten that way — the move this function already makes foroneOfimmediately above.and it now agrees with the equivalent input, which there is a test for:
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 ofpropertiesinto 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, anditems/minItemsgo to the matching branch, and anything no branch claims stays on the parent and is described once:Malformed arrays — empty, non-string members, unknown type names, repeated members — raise
ValueError.assert_neveris deliberately left reachable for an unrecognised type name, sincetest_unsupported_type_assertspins 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 theanyOfspelling, 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 --checkandpyrightare clean on both files.Why this shape matters in practice
I found this running the SDK's own
transform_schemaas an oracle over 14,804 tool schemas collected from 617 public MCP servers (data). Type arrays appear in 88 tools across 30 servers:typearray["string","null"]["integer","null"]["number","null"]["string","number"]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 aCo-authored-bytrailer, 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.