Skip to content

fix: force additionalProperties=false on all objects in strict schemas - #3805

Open
Bhumika-1432006 wants to merge 4 commits into
openai:mainfrom
Bhumika-1432006:fix/strict-schema-additional-properties
Open

fix: force additionalProperties=false on all objects in strict schemas#3805
Bhumika-1432006 wants to merge 4 commits into
openai:mainfrom
Bhumika-1432006:fix/strict-schema-additional-properties

Conversation

@Bhumika-1432006

@Bhumika-1432006 Bhumika-1432006 commented Sep 5, 2026

Copy link
Copy Markdown

Summary

to_strict_json_schema() / _ensure_strict_json_schema() in src/openai/lib/_pydantic.py is supposed to normalize a Pydantic-generated JSON schema so it satisfies the Responses/Chat Completions strict-schema requirement that every object have additionalProperties: false. It only did this when the key was completely absent from the schema:

if typ == "object" and "additionalProperties" not in json_schema:
    json_schema["additionalProperties"] = False

A Pydantic model with model_config = ConfigDict(extra="allow") already produces additionalProperties: True in its generated schema (that's exactly what extra="allow" means to Pydantic), so this branch never fired for it, and the schema was sent to the API with additionalProperties: true still in place.

Fixes #2740.

Reproduction (from the issue)

from pydantic import BaseModel, ConfigDict

class MyClass(BaseModel):
    model_config = ConfigDict(extra="allow")
    field: str

response = await client.beta.chat.completions.parse(
    model="gpt-4.1",
    messages=[{"role": "user", "content": "..."}],
    response_format=MyClass,
)
BadRequestError: Error code: 400 - {'error': {'message': "Invalid schema for response_format 'MyClass': In context=(), 'additionalProperties' is required to be supplied and to be false.", ...}}

Fix

The discriminator that matters is whether the schema has at least one declared property - i.e. a fixed shape to close the object around:

typ = json_schema.get("type")
if typ == "object":
    additional_properties = json_schema.get("additionalProperties", False)
    properties = json_schema.get("properties")
    has_declared_properties = is_dict(properties) and len(properties) > 0

    if additional_properties is not False and not has_declared_properties:
        raise TypeError(...)
    json_schema["additionalProperties"] = False
  • Has declared properties (a real model with named fields) - any non-False additionalProperties there is corrected to False. This covers plain extra="allow" (additionalProperties: True) and extra="allow" with a typed __pydantic_extra__ (additionalProperties is a schema, e.g. {"type": "integer"}) - forbidding keys beyond the declared ones doesn't change what those declared fields accept.
  • No declared properties (a genuine arbitrary-key mapping with no fixed shape at all) - a Dict[str, ...]-shaped field, a mapping RootModel, a Dict[str, Any]-shaped field, an Any-valued mapping RootModel, or a bare extra="allow" model with zero fields. The API can't represent "any key, any/typed value" in a strict schema, so this now raises a clear TypeError instead of silently forcing additionalProperties: false, which would turn the object into one that only accepts {} - changing its declared contract instead of reporting the real limitation.

This can't regress a previously-working case: every schema shape this raises for was already going to be rejected by the API with a 400 before this fix - just with the API's own opaque error at request time, rather than this library's own clearer error before the request is even sent.

Review history

This PR went through several rounds of automated review, each catching a real edge case:

  1. Schema-valued additionalProperties (Dict[str, str], mapping RootModel) - the first version forced additionalProperties: False unconditionally, silently discarding the value schema. Fixed by raising instead of overwriting non-boolean values.
  2. Boolean additionalProperties: True with no declared properties (Dict[str, Any], Any-valued mapping RootModel, bare extra="allow" with no fields) - indistinguishable from safe extra="allow"-with-fields by value alone. Fixed by keying the decision on declared properties instead of the boolean.
  3. Typed extras (extra="allow" + typed __pydantic_extra__) produce a schema-valued additionalProperties with declared properties - the fix from (2) still rejected these since it special-cased True. Simplified further: the discriminator is declared properties alone, regardless of whether additionalProperties is True or a schema.
  4. Pydantic v1 test-collection crash: a module-level from pydantic import RootModel in the new test raised ImportError during collection on the v1 lane (RootModel doesn't exist there), before the PYDANTIC_V1 skip in the test body could run - breaking the whole file's collection under ./scripts/test-pydantic-v1. Fixed by moving the import inside the test, after the skip.
  5. Unguarded v1 assumption: a test assumed Pydantic v2's additionalProperties: True representation for Dict[str, Any]; Pydantic v1 omits the key entirely there, taking the missing-key path instead. Added a PYDANTIC_V1 skip.

Tests

tests/lib/test_pydantic.py now covers:

  • test_additional_properties_is_forced_false_even_when_extra_allow - extra="allow" with a declared field -> False.
  • test_typed_extra_is_forced_false_just_like_untyped_extra_allow - extra="allow" with a typed __pydantic_extra__ (schema-valued additionalProperties) and a declared field -> False.
  • test_empty_extra_allow_model_raises_instead_of_forcing_empty_object - extra="allow" with zero declared fields -> raises.
  • test_dict_field_raises_instead_of_silently_dropping_value_schema - Dict[str, str] field -> raises.
  • test_dict_any_field_raises_instead_of_silently_allowing_only_empty_object - Dict[str, Any] field -> raises (skipped on Pydantic v1, which represents this differently).
  • test_mapping_root_model_raises_instead_of_silently_dropping_value_schema - RootModel[Dict[str, str]] -> raises (skipped on Pydantic v1, RootModel import is local to avoid breaking v1 collection).

Full tests/lib/test_pydantic.py suite: 9 passed. No existing snapshot changed - none of the current tests exercise a model with a non-boolean or fieldless-True additionalProperties, so this remains a pure bugfix with no behavior change for existing schemas.

ruff check, ruff format --check, and mypy are clean on the changed files.

Notes for reviewers

This only touches the top-level normalization step in _ensure_strict_json_schema; the recursive walk into properties, items, anyOf, allOf, and $ref expansion is untouched, so nested objects (which already go through this same function recursively) get the same treatment for free.

_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 openai#2740
@Bhumika-1432006
Bhumika-1432006 requested a review from a team as a code owner September 5, 2026 13:00

@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: ff9b33e747

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/openai/lib/_pydantic.py
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 openai#3805.
@Bhumika-1432006

Copy link
Copy Markdown
Author

Good catch — fixed in f9467c2. additionalProperties is now only coerced when it's a boolean (True/missing -> False); when it's a schema value (e.g. from a Dict[str, ...] field), to_strict_json_schema now raises a TypeError describing the limitation instead of silently discarding it. See the updated PR description for details.

@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: f9467c26b6

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/openai/lib/_pydantic.py Outdated
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 openai#3805.

@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: 642214206f

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread tests/lib/test_pydantic.py Outdated
Comment thread src/openai/lib/_pydantic.py Outdated
Comment thread tests/lib/test_pydantic.py
…es 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.
@Bhumika-1432006

Copy link
Copy Markdown
Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Bravo.

Reviewed commit: 13d12aa30f

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@chatgpt-codex-connector

Copy link
Copy Markdown

Security review completed. No security issues were found in this pull request.

Reviewed commit: 13d12aa30f

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

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.

Improper handling of pydantic extra="allow"

1 participant