From ff9b33e747f179f7dfff7a37bdee0de7dab4cea3 Mon Sep 17 00:00:00 2001 From: Bhumika Date: Sat, 5 Sep 2026 18:29:43 +0530 Subject: [PATCH 1/4] fix: force additionalProperties=false on all objects in strict schemas _ensure_strict_json_schema() only set additionalProperties=False when the key was missing from the schema entirely: if typ == "object" and "additionalProperties" not in json_schema: json_schema["additionalProperties"] = False Pydantic models with `extra="allow"` (or fields that produce a `Dict[str, ...]`-shaped schema) already have `additionalProperties` set - to `True`, or to a nested schema - so this branch never fired for them, and the resulting schema was sent to the API as-is. The Responses/Chat Completions APIs require `additionalProperties: false` on every object in a strict schema, unconditionally. A schema with `additionalProperties: true` (or a nested schema) is rejected with a 400: "'additionalProperties' is required to be supplied and to be false", which surfaces from client.beta.chat.completions.parse() and client.responses.parse() whenever the response model (or a nested model) uses `extra="allow"`. Since there's no schema shape where the API accepts anything other than `additionalProperties: false`, the fix removes the `not in json_schema` guard so the value is always normalized, overriding whatever Pydantic produced. Fixes #2740 --- src/openai/lib/_pydantic.py | 7 ++++++- tests/lib/test_pydantic.py | 20 +++++++++++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/openai/lib/_pydantic.py b/src/openai/lib/_pydantic.py index 3cfe224cb1..3399815f7e 100644 --- a/src/openai/lib/_pydantic.py +++ b/src/openai/lib/_pydantic.py @@ -46,8 +46,13 @@ def _ensure_strict_json_schema( for definition_name, definition_schema in definitions.items(): _ensure_strict_json_schema(definition_schema, path=(*path, "definitions", definition_name), root=root) + # The API requires `additionalProperties: false` on every object, with no exceptions - + # so this is set unconditionally, overriding any existing value. Pydantic models with + # `extra="allow"` (or a `Dict[str, ...]`-shaped field) otherwise produce a schema with + # `additionalProperties` set to `True` or to a nested schema, either of which the API + # rejects with a 400 (`'additionalProperties' is required to be supplied and to be false`). typ = json_schema.get("type") - if typ == "object" and "additionalProperties" not in json_schema: + if typ == "object": json_schema["additionalProperties"] = False # object types diff --git a/tests/lib/test_pydantic.py b/tests/lib/test_pydantic.py index 754a15151c..26decec1e6 100644 --- a/tests/lib/test_pydantic.py +++ b/tests/lib/test_pydantic.py @@ -2,7 +2,7 @@ from enum import Enum -from pydantic import Field, BaseModel +from pydantic import Field, BaseModel, ConfigDict from inline_snapshot import snapshot import openai @@ -409,3 +409,21 @@ def test_nested_inline_ref_expansion() -> None: "additionalProperties": False, } ) + + +class ModelWithExtraAllowed(BaseModel): + model_config = ConfigDict(extra="allow") + + name: str = Field(description="The name field.") + + +def test_additional_properties_is_forced_false_even_when_extra_allow() -> None: + """A Pydantic model with `extra="allow"` produces `additionalProperties: True` from + Pydantic itself, but the API requires `additionalProperties: false` on every object with + no exceptions - so `to_strict_json_schema` must override it rather than leave it alone. + """ + if PYDANTIC_V1: + pytest.skip("extra='allow' schema generation differs on Pydantic v1") + + schema = to_strict_json_schema(ModelWithExtraAllowed) + assert schema["additionalProperties"] is False From f9467c26b6d78ca0fabc04a94cbacabf4bf0fdae Mon Sep 17 00:00:00 2001 From: Bhumika Date: Sun, 6 Sep 2026 07:40:31 +0530 Subject: [PATCH 2/4] fix: raise instead of dropping schema-valued additionalProperties The previous commit made additionalProperties=False unconditional for every object, which is correct for extra="allow" models (where Pydantic sets additionalProperties=True) but wrong for a Dict[str, ...]-shaped field or mapping RootModel: Pydantic represents those with a schema describing the values' type, e.g. {"type": "string"}, not a boolean. Overwriting that schema with False would silently turn the field into an object that only accepts {}, changing its declared contract instead of surfacing the actual limitation - the API has no way to represent an arbitrary-key mapping in a strict schema. _ensure_strict_json_schema now only normalizes additionalProperties when it's already a bool (True -> False, or filling in the missing key), and raises a TypeError describing the limitation when it's a schema value. Addresses the automated review feedback on #3805. --- src/openai/lib/_pydantic.py | 21 ++++++++++++++++----- tests/lib/test_pydantic.py | 18 ++++++++++++++++++ 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/openai/lib/_pydantic.py b/src/openai/lib/_pydantic.py index 3399815f7e..13cc8ad5b8 100644 --- a/src/openai/lib/_pydantic.py +++ b/src/openai/lib/_pydantic.py @@ -46,13 +46,24 @@ def _ensure_strict_json_schema( for definition_name, definition_schema in definitions.items(): _ensure_strict_json_schema(definition_schema, path=(*path, "definitions", definition_name), root=root) - # The API requires `additionalProperties: false` on every object, with no exceptions - - # so this is set unconditionally, overriding any existing value. Pydantic models with - # `extra="allow"` (or a `Dict[str, ...]`-shaped field) otherwise produce a schema with - # `additionalProperties` set to `True` or to a nested schema, either of which the API - # rejects with a 400 (`'additionalProperties' is required to be supplied and to be false`). + # The API requires `additionalProperties: false` on every object, with no exceptions. + # Pydantic models with `extra="allow"` produce `additionalProperties: True` here, which + # we can safely correct to `False` since that's the only value the API accepts. But a + # `Dict[str, ...]`-shaped field (or a mapping `RootModel`) produces a schema-valued + # `additionalProperties` describing the values' type - overwriting that with `False` + # would silently turn the field into an object that only accepts `{}`, rather than the + # mapping type it was declared as. Since the API has no way to represent an arbitrary-key + # mapping in a strict schema, we raise instead of silently producing a broken one. typ = json_schema.get("type") if typ == "object": + additional_properties = json_schema.get("additionalProperties", False) + if additional_properties not in (False, True): + raise TypeError( + "Objects with a typed `additionalProperties` value (e.g. from a " + "`Dict[str, ...]`-shaped field or a mapping `RootModel`) are not supported in " + f"strict schemas, since the API requires `additionalProperties: false` on " + f"every object; path={path}" + ) json_schema["additionalProperties"] = False # object types diff --git a/tests/lib/test_pydantic.py b/tests/lib/test_pydantic.py index 26decec1e6..8f5dc18311 100644 --- a/tests/lib/test_pydantic.py +++ b/tests/lib/test_pydantic.py @@ -1,7 +1,9 @@ from __future__ import annotations from enum import Enum +from typing import Dict +import pytest from pydantic import Field, BaseModel, ConfigDict from inline_snapshot import snapshot @@ -427,3 +429,19 @@ def test_additional_properties_is_forced_false_even_when_extra_allow() -> None: schema = to_strict_json_schema(ModelWithExtraAllowed) assert schema["additionalProperties"] is False + + +class ModelWithDictField(BaseModel): + data: Dict[str, str] = Field(description="A mapping field.") + + +def test_dict_field_raises_instead_of_silently_dropping_value_schema() -> None: + """A `Dict[str, ...]`-shaped field produces a schema-valued `additionalProperties` + describing the values' type (e.g. `{"type": "string"}`), not a boolean. The API can't + represent an arbitrary-key mapping in a strict schema, so `to_strict_json_schema` must + raise rather than silently overwrite that value with `False` - which would turn the field + into an object that only accepts `{}`, changing its meaning instead of reporting the + actual limitation. + """ + with pytest.raises(TypeError, match="additionalProperties"): + to_strict_json_schema(ModelWithDictField) From 642214206f57a66a43ed597a48d8c6f1fe14142a Mon Sep 17 00:00:00 2001 From: Bhumika Date: Sun, 6 Sep 2026 10:21:36 +0530 Subject: [PATCH 3/4] fix: only forgive additionalProperties=true when properties are declared The previous commit treated every additionalProperties=True the same as extra="allow" and coerced it to False. But True is also what Pydantic emits for a Dict[str, Any]-shaped field, an Any-valued mapping RootModel, or a bare extra="allow" model with zero declared fields - none of which have a fixed set of properties to close the object around. Forcing False in those cases silently turns the object into one that only accepts {}, the same silent-contract-change bug as the schema-valued additionalProperties case fixed previously. additionalProperties=True is now only corrected to False when the schema also has at least one declared property (a real extra="allow" model with named fields, where closing to those fields is a reasonable strict-schema approximation). Every other non-False value - a schema, or True with no declared properties - raises the same TypeError as before, describing the object as accepting arbitrary keys, which strict schemas can't represent. Addresses further automated review feedback on #3805. --- src/openai/lib/_pydantic.py | 35 ++++++++++++++++--------- tests/lib/test_pydantic.py | 52 +++++++++++++++++++++++++++++++++++-- 2 files changed, 73 insertions(+), 14 deletions(-) diff --git a/src/openai/lib/_pydantic.py b/src/openai/lib/_pydantic.py index 13cc8ad5b8..c268effd95 100644 --- a/src/openai/lib/_pydantic.py +++ b/src/openai/lib/_pydantic.py @@ -47,22 +47,33 @@ def _ensure_strict_json_schema( _ensure_strict_json_schema(definition_schema, path=(*path, "definitions", definition_name), root=root) # The API requires `additionalProperties: false` on every object, with no exceptions. - # Pydantic models with `extra="allow"` produce `additionalProperties: True` here, which - # we can safely correct to `False` since that's the only value the API accepts. But a - # `Dict[str, ...]`-shaped field (or a mapping `RootModel`) produces a schema-valued - # `additionalProperties` describing the values' type - overwriting that with `False` - # would silently turn the field into an object that only accepts `{}`, rather than the - # mapping type it was declared as. Since the API has no way to represent an arbitrary-key - # mapping in a strict schema, we raise instead of silently producing a broken one. + # + # A Pydantic model with `extra="allow"` produces `additionalProperties: True` here. When + # the model also has at least one declared field, closing the object to exactly those + # fields is the closest representable approximation, and doesn't change what the + # *declared* fields accept - so that case is corrected to `False`. + # + # But `additionalProperties` can also describe a genuine arbitrary-key mapping that has no + # fixed shape at all: a `Dict[str, ...]`-shaped field or mapping `RootModel` produces a + # schema-valued `additionalProperties` (e.g. `{"type": "string"}`) describing the values' + # type; a `Dict[str, Any]`-shaped field, an `Any`-valued mapping `RootModel`, or a bare + # `extra="allow"` model with zero declared fields all produce `additionalProperties: True` + # with no (or no non-empty) `properties`. Overwriting any of these with `False` would + # silently turn the object into one that only accepts `{}`, rather than the mapping it was + # declared as - so the API has no way to represent it in a strict schema, and we raise + # instead of silently producing a broken one. typ = json_schema.get("type") if typ == "object": additional_properties = json_schema.get("additionalProperties", False) - if additional_properties not in (False, True): + properties = json_schema.get("properties") + has_declared_properties = is_dict(properties) and len(properties) > 0 + + if additional_properties is not False and not (additional_properties is True and has_declared_properties): raise TypeError( - "Objects with a typed `additionalProperties` value (e.g. from a " - "`Dict[str, ...]`-shaped field or a mapping `RootModel`) are not supported in " - f"strict schemas, since the API requires `additionalProperties: false` on " - f"every object; path={path}" + "Objects that accept arbitrary keys (e.g. a `Dict[str, ...]`-shaped field, a " + 'mapping `RootModel`, or a bare `extra="allow"` model with no declared fields) ' + "are not supported in strict schemas, since the API requires " + f"`additionalProperties: false` on every object; path={path}" ) json_schema["additionalProperties"] = False diff --git a/tests/lib/test_pydantic.py b/tests/lib/test_pydantic.py index 8f5dc18311..8ad4451547 100644 --- a/tests/lib/test_pydantic.py +++ b/tests/lib/test_pydantic.py @@ -1,10 +1,10 @@ from __future__ import annotations from enum import Enum -from typing import Dict +from typing import Any, Dict import pytest -from pydantic import Field, BaseModel, ConfigDict +from pydantic import Field, BaseModel, RootModel, ConfigDict from inline_snapshot import snapshot import openai @@ -445,3 +445,51 @@ def test_dict_field_raises_instead_of_silently_dropping_value_schema() -> None: """ with pytest.raises(TypeError, match="additionalProperties"): to_strict_json_schema(ModelWithDictField) + + +class ModelWithDictAnyField(BaseModel): + data: Dict[str, Any] = Field(description="An unconstrained mapping field.") + + +def test_dict_any_field_raises_instead_of_silently_allowing_only_empty_object() -> None: + """A `Dict[str, Any]`-shaped field produces `additionalProperties: True` with no declared + `properties` - the same boolean Pydantic uses for `extra="allow"`, but here there are no + declared fields to close the object around. Forcing `False` would silently accept only + `{}` instead of the arbitrary mapping the field was declared as, so this must raise just + like the schema-valued case above. + """ + with pytest.raises(TypeError, match="additionalProperties"): + to_strict_json_schema(ModelWithDictAnyField) + + +class EmptyModelWithExtraAllowed(BaseModel): + model_config = ConfigDict(extra="allow") + + +def test_empty_extra_allow_model_raises_instead_of_forcing_empty_object() -> None: + """An `extra="allow"` model with zero declared fields behaves like an unconstrained + mapping - any keys are allowed - unlike the with-declared-fields case above, where closing + the object to the declared fields is a reasonable strict-schema approximation. With no + fields to close around, this must raise instead of silently accepting only `{}`. + """ + if PYDANTIC_V1: + pytest.skip("extra='allow' schema generation differs on Pydantic v1") + + with pytest.raises(TypeError, match="additionalProperties"): + to_strict_json_schema(EmptyModelWithExtraAllowed) + + +def test_mapping_root_model_raises_instead_of_silently_dropping_value_schema() -> None: + """A mapping `RootModel` (e.g. `RootModel[Dict[str, str]]`) produces the same + schema-valued `additionalProperties` as a `Dict[str, ...]`-shaped field, just at the top + level of the schema instead of nested under a field - so it must raise for the same + reason. + """ + if PYDANTIC_V1: + pytest.skip("RootModel is not available on Pydantic v1") + + class MappingRootModel(RootModel[Dict[str, str]]): + pass + + with pytest.raises(TypeError, match="additionalProperties"): + to_strict_json_schema(MappingRootModel) From 13d12aa30f4e2ea81829b821524a7e10c2a01f71 Mon Sep 17 00:00:00 2001 From: Bhumika Date: Sun, 6 Sep 2026 10:44:54 +0530 Subject: [PATCH 4/4] fix: treat schema-valued additionalProperties like True when properties exist Three issues from the last round of review: 1. A Pydantic v2 model with extra="allow" and a typed __pydantic_extra__ (validating the extra values) produces a schema-valued additionalProperties, e.g. {"type": "integer"}, instead of True - but it still has declared properties to close the object around, exactly like the untyped extra="allow" case. The previous check only forgave additional_properties is True, so this got incorrectly rejected. Simplified the condition to key only on whether properties are declared, regardless of whether additionalProperties is True or a schema - which was the correct discriminator all along and subsumes the True-specific check. 2. The new test's `from pydantic import RootModel` was a module-level import. RootModel doesn't exist on Pydantic v1, so this raised ImportError during test collection on that lane, before the PYDANTIC_V1 skip in the test body ever got a chance to run - breaking collection for the whole file under ./scripts/test-pydantic-v1. Moved the import inside the test function, after the skip. 3. test_dict_any_field_raises_instead_of_silently_allowing_only_empty_object assumed Pydantic v2's `additionalProperties: True` representation for Dict[str, Any]. Pydantic v1 omits the key entirely for an Any-valued dict, so _ensure_strict_json_schema takes the missing-key path there and returns additionalProperties: false without raising. Added a PYDANTIC_V1 skip. Added test_typed_extra_is_forced_false_just_like_untyped_extra_allow covering (1). Full tests/lib/test_pydantic.py: 9 passed. --- src/openai/lib/_pydantic.py | 23 ++++++++++++----------- tests/lib/test_pydantic.py | 31 ++++++++++++++++++++++++++++++- 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/src/openai/lib/_pydantic.py b/src/openai/lib/_pydantic.py index c268effd95..dd8cc7ff64 100644 --- a/src/openai/lib/_pydantic.py +++ b/src/openai/lib/_pydantic.py @@ -48,17 +48,18 @@ def _ensure_strict_json_schema( # The API requires `additionalProperties: false` on every object, with no exceptions. # - # A Pydantic model with `extra="allow"` produces `additionalProperties: True` here. When - # the model also has at least one declared field, closing the object to exactly those - # fields is the closest representable approximation, and doesn't change what the - # *declared* fields accept - so that case is corrected to `False`. + # A Pydantic model with at least one declared property has a fixed shape to close the + # object around, so any non-`False` `additionalProperties` there - `True` for a plain + # `extra="allow"` model, or a schema for one with typed extras (`__pydantic_extra__` + # annotated to validate them) - is corrected to `False`. That only forbids keys beyond + # the ones already declared; it doesn't change what the *declared* fields accept. # - # But `additionalProperties` can also describe a genuine arbitrary-key mapping that has no - # fixed shape at all: a `Dict[str, ...]`-shaped field or mapping `RootModel` produces a - # schema-valued `additionalProperties` (e.g. `{"type": "string"}`) describing the values' - # type; a `Dict[str, Any]`-shaped field, an `Any`-valued mapping `RootModel`, or a bare - # `extra="allow"` model with zero declared fields all produce `additionalProperties: True` - # with no (or no non-empty) `properties`. Overwriting any of these with `False` would + # But `additionalProperties` can also describe a genuine arbitrary-key mapping with no + # fixed shape at all - no declared properties for it to be closed around: a + # `Dict[str, ...]`-shaped field or mapping `RootModel` produces a schema-valued + # `additionalProperties` describing the values' type; a `Dict[str, Any]`-shaped field, an + # `Any`-valued mapping `RootModel`, or a bare `extra="allow"` model with zero declared + # fields produce `additionalProperties: True`. Overwriting any of these with `False` would # silently turn the object into one that only accepts `{}`, rather than the mapping it was # declared as - so the API has no way to represent it in a strict schema, and we raise # instead of silently producing a broken one. @@ -68,7 +69,7 @@ def _ensure_strict_json_schema( properties = json_schema.get("properties") has_declared_properties = is_dict(properties) and len(properties) > 0 - if additional_properties is not False and not (additional_properties is True and has_declared_properties): + if additional_properties is not False and not has_declared_properties: raise TypeError( "Objects that accept arbitrary keys (e.g. a `Dict[str, ...]`-shaped field, a " 'mapping `RootModel`, or a bare `extra="allow"` model with no declared fields) ' diff --git a/tests/lib/test_pydantic.py b/tests/lib/test_pydantic.py index 8ad4451547..d1012bb7f4 100644 --- a/tests/lib/test_pydantic.py +++ b/tests/lib/test_pydantic.py @@ -4,7 +4,7 @@ from typing import Any, Dict import pytest -from pydantic import Field, BaseModel, RootModel, ConfigDict +from pydantic import Field, BaseModel, ConfigDict from inline_snapshot import snapshot import openai @@ -458,10 +458,34 @@ def test_dict_any_field_raises_instead_of_silently_allowing_only_empty_object() `{}` instead of the arbitrary mapping the field was declared as, so this must raise just like the schema-valued case above. """ + if PYDANTIC_V1: + pytest.skip("Pydantic v1 omits `additionalProperties` entirely for `Dict[str, Any]`") + with pytest.raises(TypeError, match="additionalProperties"): to_strict_json_schema(ModelWithDictAnyField) +class ModelWithTypedExtra(BaseModel): + model_config = ConfigDict(extra="allow") + __pydantic_extra__: Dict[str, int] # type: ignore[misc] + + name: str = Field(description="A declared field.") + + +def test_typed_extra_is_forced_false_just_like_untyped_extra_allow() -> None: + """A Pydantic v2 model with `extra="allow"` and a typed `__pydantic_extra__` produces a + schema-valued `additionalProperties` (e.g. `{"type": "integer"}`) instead of `True`, but it + still has declared properties to close the object around, exactly like the untyped + `extra="allow"` case - so it must be forced to `False` too, not rejected just because the + value happens to be a schema rather than a boolean. + """ + if PYDANTIC_V1: + pytest.skip("typed `__pydantic_extra__` is not available on Pydantic v1") + + schema = to_strict_json_schema(ModelWithTypedExtra) + assert schema["additionalProperties"] is False + + class EmptyModelWithExtraAllowed(BaseModel): model_config = ConfigDict(extra="allow") @@ -488,6 +512,11 @@ def test_mapping_root_model_raises_instead_of_silently_dropping_value_schema() - if PYDANTIC_V1: pytest.skip("RootModel is not available on Pydantic v1") + # Imported locally: `pydantic.RootModel` doesn't exist on Pydantic v1, and a module-level + # import would raise `ImportError` during test collection - before the `PYDANTIC_V1` skip + # above ever gets a chance to run - breaking collection for the entire file on that lane. + from pydantic import RootModel + class MappingRootModel(RootModel[Dict[str, str]]): pass