Skip to content

fix(lib): apply strict JSON schema transform to tuple prefixItems - #3588

Open
hsusul wants to merge 1 commit into
openai:mainfrom
hsusul:fix/strict-schema-prefix-items
Open

fix(lib): apply strict JSON schema transform to tuple prefixItems#3588
hsusul wants to merge 1 commit into
openai:mainfrom
hsusul:fix/strict-schema-prefix-items

Conversation

@hsusul

@hsusul hsusul commented Aug 9, 2026

Copy link
Copy Markdown
  • I understand that this repository is auto-generated and my pull request may not be merged

Changes being requested

src/openai/lib/_pydantic.py is hand-maintained (one of the files the Stainless generator leaves untouched, alongside examples/), 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_expansion covers the adjacent $ref-expansion behavior).

Problem

_ensure_strict_json_schema walks a Pydantic-generated JSON schema and applies the transforms the API's strict mode requires: forcing additionalProperties: false, marking every property required, and — importantly — unravelling any $ref that also carries sibling keys (a $ref with siblings is rejected in strict mode). It recurses into items, anyOf, allOf, properties, $defs and definitions.

It does not recurse into prefixItems, which Pydantic v2 emits for tuple[...] 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 $ref with sibling keys — which the API rejects. The identical construct inside a list[...] works, because list uses items (which is recursed).

Reproduction (before the fix)

from typing import Tuple
from typing_extensions import Annotated
from pydantic import BaseModel, Field
from openai.lib._pydantic import to_strict_json_schema


class Location(BaseModel):
    lat: float
    long: float


class Route(BaseModel):
    endpoints: Tuple[
        Annotated[Location, Field(description="The starting point.")],
        Annotated[Location, Field(description="The ending point.")],
    ]


print(to_strict_json_schema(Route)["properties"]["endpoints"]["prefixItems"][0])

Before:

{"$ref": "#/$defs/Location", "description": "The starting point."}   # $ref + sibling key -> rejected by strict mode

After:

{
    "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 prefixItems just like items/anyOf — nine lines, no behavior change for schemas that don't use tuples.

Tests

Added test_tuple_prefix_items_ref_expansion in tests/lib/test_pydantic.py, guarded with skipif(PYDANTIC_V1) since prefixItems is a Pydantic-v2 / JSON-Schema-2020-12 construct (v1 emits list-form items, an unrelated path this change does not touch). The test fails on main (the $ref is left with a sibling description) and passes with this change.

Validation (no network / no API key required — pure schema generation)

  • pytest -o addopts= tests/lib/test_pydantic.py4 passed (new test fails on main).
  • pytest -o addopts= tests/lib/chat tests/lib/responses tests/lib/test_pydantic.py43 passed.
  • ruff check + ruff format --check on both files — clean.
  • pyright src/openai/lib/_pydantic.py — 0 errors.

Compatibility

Non-breaking. prefixItems only 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 with list/items.

`_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`.
@hsusul
hsusul requested a review from a team as a code owner August 9, 2026 23:35

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

@percymcn

Copy link
Copy Markdown

Reproduced all of this on openai==2.53.0 / pydantic 2.x before commenting, and the diff does what it says. Two things worth adding, one of which I think changes what the fix should be.

The bug is real and the test is a real regression test. _ensure_strict_json_schema recurses into items but not prefixItems, so your Route model comes out with the sibling-carrying $ref untouched:

# openai 2.53.0, to_strict_json_schema(Route)["properties"]["endpoints"]
{"maxItems": 2, "minItems": 2, "prefixItems": [
   {"$ref": "#/$defs/Location", "description": "The starting point."}, ...]}

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. prefixItems is itself an unsupported keyword in strict mode, so the document is rejected before and after. Measured by running both outputs through OpenAI's own strict transform in the JS SDK (openai@7.4.0, openai/lib/transform), which builds the exact payload that client sends:

input toStrictJsonSchema()
pre-fix (SDK today) throwsSchema at `properties/endpoints` uses unsupported keyword `prefixItems`
post-fix (this PR) throws — same error
items + minItems/maxItems accepted

So for the motivating model the recursion fix is necessary but not sufficient: a tuple[...] field is a guaranteed API error either way. That matters more here than it would in the TypeScript SDK, because this one has no validation step — _ensure_strict_json_schema transforms and never rejects, and the three builders hardcode strict: True — so what the JS client refuses to send locally, this client sends and the user gets a runtime 400.

A remedy that fits in the same function. For a homogeneous tuple, prefixItems: [T, T] + minItems: 2/maxItems: 2 is exactly equivalent to items: T + the same bounds, and both of those keywords are strict-supported — that's the "accepted" row above, and your Route (two Locations) is precisely that case. Pydantic already emits minItems/maxItems for fixed-length tuples, so the collapse is lossless and needs nothing invented.

The caveat is that it only works when every element schema is identical. A heterogeneous tuple (tuple[int, str]) has no lossless items form, and rewriting it to items: {anyOf: [...]} would silently widen what the model may emit — position 0 would start accepting a string. That case is better raised on than widened, so the user finds out at build time instead of from a 400.

One adjacent gap, at its true strength. The same function also doesn't recurse into oneOf, which ordinary pydantic does emit — Annotated[Union[Cat, Dog], Field(discriminator="kind")] produces oneOf, not anyOf. I checked whether that's currently a live bug and it isn't: pydantic puts the branches in $defs as bare $refs, $defs is visited, and both the plain and the described variant come back accepted by the transform above. So it's an unguarded assumption rather than a defect — nothing pins that branches stay in $defs and stay sibling-free, and it's the same one-line shape as the prefixItems block. Might be worth folding in while the function is open.

All of the above is checkable offline with no API key — toStrictJsonSchema from the JS package is a pure function, which makes it usable as a CI oracle for this exact class of change.

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.

2 participants