Skip to content

fix: only set additionalProperties on object schemas#1909

Merged
RobinPicard merged 1 commit into
dottxt-ai:mainfrom
vidigoat:fix-additional-properties-non-object-schema
Jul 20, 2026
Merged

fix: only set additionalProperties on object schemas#1909
RobinPicard merged 1 commit into
dottxt-ai:mainfrom
vidigoat:fix-additional-properties-non-object-schema

Conversation

@vidigoat

@vidigoat vidigoat commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

What this fixes

set_additional_properties_false_json_schema (used by the OpenAI backend to make a schema strict-compatible) sometimes injects "additionalProperties": false into non-object schemas. The OpenAI structured-output API rejects additionalProperties on anything that isn't an object, so a legitimate schema fails with a 400 invalid_request_error.

Reproduction

from typing import Literal
from pydantic import BaseModel
from outlines.models.openai import OpenAITypeAdapter

class Node(BaseModel):
    name: str
    kind: Literal["object"]   # pydantic emits {"type": "string", "const": "object"}

schema = OpenAITypeAdapter().format_output_type(Node)
kind = schema["response_format"]["json_schema"]["schema"]["properties"]["kind"]
print(kind)
# -> {'const': 'object', 'title': 'Kind', 'type': 'string', 'additionalProperties': False}
#                                                            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ invalid on a string field

Any scalar value equal to the string "object" triggers it — a Literal["object"] field (emitted as const), or a field whose default / title / description is "object".

Root cause

jsonpath_expr = jsonpath_ng.parse('$..*')
for match in jsonpath_expr.find(schema):
    if match.value == 'object':                       # matches ANY value == "object"
        if 'additionalProperties' not in match.context.value:
            match.context.value['additionalProperties'] = False  # mutates the parent

It keys off the string value "object" appearing anywhere in the schema and then mutates that node's parent, instead of keying off the JSON Schema keyword {"type": "object"}.

Fix

Recurse over the schema and set additionalProperties only on dicts whose type is "object". Nested object schemas are still handled; already-present additionalProperties values are preserved. This also drops the jsonpath_ng usage in this helper in favour of a plain recursive walk.

Test evidence

Added two regression tests in tests/models/test_utils.py:

  • test_set_additional_properties_false_json_schema_string_value_object — fails on main, passes with the fix (a string field with const/title "object" must not get additionalProperties).
  • test_set_additional_properties_false_json_schema_nested_objects — documents that nested object schemas still get additionalProperties.
$ pytest tests/models/test_utils.py tests/models/test_openai_type_adapter.py -q
16 passed

ruff check and mypy --allow-redefinition both pass on the changed files.

@vidigoat
vidigoat force-pushed the fix-additional-properties-non-object-schema branch from fd1f2cd to 7ea83e6 Compare July 9, 2026 04:52

@RobinPicard RobinPicard left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for opening a PR! There's a conflict to solve with the main main as the file you are modifying has been updated. Let's remove the jsonpath_ng dependency too as you are removing the only use of this library.

Comment thread src/outlines/models/utils.py Outdated
# appearing anywhere would wrongly add the keyword to non-object
# schemas, e.g. a string field with ``const``/``default``/``title``
# equal to ``"object"``, which the OpenAI API then rejects.
if node.get("type") == "object" and "additionalProperties" not in node:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This misses nullable object schemas whose type is ["object", "null"]. Those nested objects keep no additionalProperties, so the strict OpenAI/Mistral schema is rejected; the equivalent check needs to handle lists containing "object" too.

set_additional_properties_false_json_schema keyed off any value equal
to the string 'object' (match.value == 'object') and then mutated that
node's parent. This wrongly injected 'additionalProperties': False into
non-object schemas whenever a scalar value happened to equal 'object' --
for example a string field emitted by pydantic as {'type': 'string',
'const': 'object'} for a Literal['object'] discriminator, or a field
with a 'default'/'title'/'description' of 'object'.

The OpenAI structured-output API rejects 'additionalProperties' on
non-object schemas, so a legitimate model would fail with a 400 error.

Rewrite the helper to recurse over the schema and set
'additionalProperties' only on dicts whose 'type' is 'object', matching
the JSON Schema keyword rather than an incidental string value. Nested
object schemas are still handled. Add regression tests.
@vidigoat
vidigoat force-pushed the fix-additional-properties-non-object-schema branch from 7ea83e6 to 8f77a16 Compare July 20, 2026 13:17
@vidigoat

Copy link
Copy Markdown
Contributor Author

Thanks @RobinPicard! Rebased onto main so the conflict is resolved, and removed the jsonpath_ng dependency (from both pyproject.toml and uv.lock) since this was its only use — uv lock --check passes.

@Sanjays2402 good catch on nullable objects — the check now also matches type lists containing "object" (e.g. ["object", "null"]), so nested nullable object schemas get additionalProperties: false too. Added a regression test for that case alongside the existing ones.

@ErenAta16 ErenAta16 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reproduced the old bug directly before checking the fix, since the whole thing hinges on whether the old jsonpath walk really did hit non-object schemas.

It does. Running the old $..* version against the PR's own repro ({"type": "string", "const": "object", "title": "object"} nested under an object) adds additionalProperties: False straight onto the string schema, because $..* visits every value in the tree, "object" shows up as the value of const and title, and match.context.value is then the string schema itself. So the fix is addressing a real 400 from the OpenAI side, not a theoretical one.

The rewritten _walk keys off node.get("type") instead of any value that happens to equal "object", which is the correct signal. Checked it against a few cases beyond the one in the test:

  • The repro's string-with-const: "object" field: correctly left untouched.
  • Plain nested object: still gets additionalProperties: False.
  • Nullable object with "type": ["object", "null"]: still gets it, the list branch is preserved.
  • A property literally named object with a string type: correctly left untouched (the key is walked as a value, has no matching type, so nothing is added).

One thing worth noting for whoever merges: _walk mutates schema in place and returns the same object, same as the old version did, so behavior for existing callers is unchanged there. Dropping jsonpath_ng entirely is a nice side benefit given this was its only use.

Looks correct and well-scoped to me.

@github-actions

Copy link
Copy Markdown

📚 Documentation preview: https://dottxt-ai.github.io/outlines/pr-preview/pr-1909/

Preview updates automatically with each commit.

@RobinPicard RobinPicard left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks!

@RobinPicard
RobinPicard merged commit 69a5596 into dottxt-ai:main Jul 20, 2026
6 checks passed
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.

4 participants