From e3d65507f9eccbf20a7de4b9c862661f0dc6df19 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Sun, 9 Aug 2026 11:43:06 -0500 Subject: [PATCH] fix(lib): apply strict JSON schema transform to tuple prefixItems `_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`. --- src/openai/lib/_pydantic.py | 9 +++++ tests/lib/test_pydantic.py | 75 +++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/src/openai/lib/_pydantic.py b/src/openai/lib/_pydantic.py index 3cfe224cb1..3fd6ff4e19 100644 --- a/src/openai/lib/_pydantic.py +++ b/src/openai/lib/_pydantic.py @@ -66,6 +66,15 @@ def _ensure_strict_json_schema( if is_dict(items): json_schema["items"] = _ensure_strict_json_schema(items, path=(*path, "items"), root=root) + # tuples / positional arrays + # { 'type': 'array', 'prefixItems': [{...}, {...}] } + 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) + for i, item in enumerate(prefix_items) + ] + # unions any_of = json_schema.get("anyOf") if is_list(any_of): diff --git a/tests/lib/test_pydantic.py b/tests/lib/test_pydantic.py index 754a15151c..6ea5b1513c 100644 --- a/tests/lib/test_pydantic.py +++ b/tests/lib/test_pydantic.py @@ -1,7 +1,10 @@ from __future__ import annotations from enum import Enum +from typing import Tuple +from typing_extensions import Annotated +import pytest from pydantic import Field, BaseModel from inline_snapshot import snapshot @@ -169,6 +172,78 @@ def test_most_types() -> None: ) +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.")], + ] + + +@pytest.mark.skipif(PYDANTIC_V1, reason="`prefixItems` is only emitted by pydantic v2") +def test_tuple_prefix_items_ref_expansion() -> None: + # tuple elements are emitted under `prefixItems`; the strict transform must + # recurse into them the same way it does for `items`, otherwise a `$ref` that + # carries sibling keys (e.g. a field description) is left un-expanded and the + # API rejects the resulting schema. + assert to_strict_json_schema(Route) == snapshot( + { + "$defs": { + "Location": { + "properties": { + "lat": {"title": "Lat", "type": "number"}, + "long": {"title": "Long", "type": "number"}, + }, + "required": ["lat", "long"], + "title": "Location", + "type": "object", + "additionalProperties": False, + } + }, + "properties": { + "endpoints": { + "maxItems": 2, + "minItems": 2, + "prefixItems": [ + { + "description": "The starting point.", + "properties": { + "lat": {"title": "Lat", "type": "number"}, + "long": {"title": "Long", "type": "number"}, + }, + "required": ["lat", "long"], + "title": "Location", + "type": "object", + "additionalProperties": False, + }, + { + "description": "The ending point.", + "properties": { + "lat": {"title": "Lat", "type": "number"}, + "long": {"title": "Long", "type": "number"}, + }, + "required": ["lat", "long"], + "title": "Location", + "type": "object", + "additionalProperties": False, + }, + ], + "title": "Endpoints", + "type": "array", + } + }, + "required": ["endpoints"], + "title": "Route", + "type": "object", + "additionalProperties": False, + } + ) + + class Color(Enum): RED = "red" BLUE = "blue"