fix(lib): apply strict JSON schema transform to tuple prefixItems - #3588
fix(lib): apply strict JSON schema transform to tuple prefixItems#3588hsusul wants to merge 1 commit into
Conversation
`_ensure_strict_json_schema` recursed into `items`, `anyOf`, `allOf`, `properties` and `$defs` but not `prefixItems`, which pydantic v2 emits for `tuple[...]` fields. As a result a tuple element carrying a `$ref` with sibling keys (e.g. a field description) was left un-expanded, producing a schema the API rejects, and nested objects inside tuples did not receive the `additionalProperties: false` / `required` treatment. Recurse into `prefixItems` just like `items`.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e3d65507f9
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| prefix_items = json_schema.get("prefixItems") | ||
| if is_list(prefix_items): | ||
| json_schema["prefixItems"] = [ | ||
| _ensure_strict_json_schema(item, path=(*path, "prefixItems", str(i)), root=root) |
There was a problem hiding this comment.
Prevent infinite expansion of recursive tuple refs
When a recursive Pydantic model contains a tuple element such as tuple[Annotated[Node, Field(description="child")]], this traversal reaches the $ref with its description, expands the referenced Node, and then walks into the same prefixItems entry again. This repeats until to_strict_json_schema() raises RecursionError; self- and mutually recursive tuple schemas therefore need explicit cycle handling rather than unconditional descent.
Useful? React with 👍 / 👎.
|
Reproduced all of this on The bug is real and the test is a real regression test. Worth saying explicitly because it often isn't true of PRs like this: your added test genuinely fails without the fix. The snapshot asserts the expanded form, and the pre-fix output above is un-expanded, so it can't pass either way. The additive part: the fixed schema still can't be sent.
So for the motivating model the recursion fix is necessary but not sufficient: a A remedy that fits in the same function. For a homogeneous tuple, The caveat is that it only works when every element schema is identical. A heterogeneous tuple ( One adjacent gap, at its true strength. The same function also doesn't recurse into All of the above is checkable offline with no API key — |
Changes being requested
src/openai/lib/_pydantic.pyis hand-maintained (one of the files the Stainless generator leaves untouched, alongsideexamples/), so this fix belongs here rather than in the spec. It sits in the same function that PR history already patches for strict-schema handling (test_nested_inline_ref_expansioncovers the adjacent$ref-expansion behavior).Problem
_ensure_strict_json_schemawalks a Pydantic-generated JSON schema and applies the transforms the API'sstrictmode requires: forcingadditionalProperties: false, marking every propertyrequired, and — importantly — unravelling any$refthat also carries sibling keys (a$refwith siblings is rejected in strict mode). It recurses intoitems,anyOf,allOf,properties,$defsanddefinitions.It does not recurse into
prefixItems, which Pydantic v2 emits fortuple[...]fields. So any subschema reached only through a tuple element is skipped. The most visible symptom is a tuple element that is a model with a field-level description: Pydantic emits{"$ref": "...", "description": "..."}, and because it is never unravelled the generated schema still contains a$refwith sibling keys — which the API rejects. The identical construct inside alist[...]works, becauselistusesitems(which is recursed).Reproduction (before the fix)
Before:
{"$ref": "#/$defs/Location", "description": "The starting point."} # $ref + sibling key -> rejected by strict modeAfter:
{ "description": "The starting point.", "properties": {"lat": {"title": "Lat", "type": "number"}, "long": {"title": "Long", "type": "number"}}, "required": ["lat", "long"], "title": "Location", "type": "object", "additionalProperties": False, }This now matches exactly what
list[Annotated[Location, Field(description=...)]]already produces.Fix
Recurse into
prefixItemsjust likeitems/anyOf— nine lines, no behavior change for schemas that don't use tuples.Tests
Added
test_tuple_prefix_items_ref_expansionintests/lib/test_pydantic.py, guarded withskipif(PYDANTIC_V1)sinceprefixItemsis a Pydantic-v2 / JSON-Schema-2020-12 construct (v1 emits list-formitems, an unrelated path this change does not touch). The test fails onmain(the$refis left with a siblingdescription) and passes with this change.Validation (no network / no API key required — pure schema generation)
pytest -o addopts= tests/lib/test_pydantic.py—4 passed(new test fails onmain).pytest -o addopts= tests/lib/chat tests/lib/responses tests/lib/test_pydantic.py—43 passed.ruff check+ruff format --checkon both files — clean.pyright src/openai/lib/_pydantic.py— 0 errors.Compatibility
Non-breaking.
prefixItemsonly appears for tuple types; the added branch is a no-op for every schema that doesn't contain one, and for those that do it makes the output consistent withlist/items.