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"