Skip to content

fix: stop merging anyOf and oneOf into a single widened union - #4303

Closed
abhay-codes07 wants to merge 1 commit into
openai:mainfrom
abhay-codes07:fix/strict-schema-anyof-oneof-merge
Closed

fix: stop merging anyOf and oneOf into a single widened union#4303
abhay-codes07 wants to merge 1 commit into
openai:mainfrom
abhay-codes07:fix/strict-schema-anyof-oneof-merge

Conversation

@abhay-codes07

Copy link
Copy Markdown
Contributor

Summary

When a schema node carries both anyOf and oneOf, strict conversion folds the oneOf variants into the existing anyOf list. Those are two constraints that must BOTH hold, so the fold turns a conjunction into a union, and the converted schema accepts values the original rejects.

Checked against a JSON Schema validator:

{"anyOf": [{"type": "string"}, {"type": "integer"}],
 "oneOf": [{"type": "string"}, {"type": "boolean"}]}
value original after conversion
"hello" accept accept
5 reject (fails the oneOf) accept
true reject (fails the anyOf) accept
3.14 reject reject

For a function tool or output type this widens the declared contract: the provider constrains generation to the converted schema, so the model can produce arguments or outputs the original schema forbids, and nothing downstream notices.

The prior expectation in test_oneof_merged_with_existing_anyof makes the problem concrete: its input schema accepts no value at all (a string fails its oneOf, an integer fails its anyOf), yet the merged form accepted strings, integers and booleans alike.

Fix

The combination now fails conversion the way other inexpressible schemas do, with an actionable message. Callers that can degrade take their existing fallback: an MCP tool with such a schema is served non-strict with both constraints intact:

MCP: strict = False | field served as:
{"anyOf": [string, integer], "oneOf": [string, boolean]}

There is no meaning-preserving strict form to emit instead: computing the actual conjunction of two schema lists is not generally possible, and any flattening picks one constraint over the other.

Unchanged behaviour, pinned by tests that pass both before and after:

  • oneOf alone still converts to anyOf, the documented discriminated-union path
  • oneOf next to an empty anyOf list still converts, since an empty list constrains nothing
  • discriminator preservation and nested/deep oneOf conversions are untouched

Test plan

test_anyof_and_oneof_on_the_same_node_are_rejected_not_merged fails on main and passes here. test_oneof_merged_with_existing_anyof asserted the widened merge and is replaced by test_oneof_alongside_anyof_is_rejected_rather_than_merged, with the unsatisfiable-input observation recorded in the test.

Command Result
make format / make lint clean
make mypy 5 errors, all pre-existing on main
make pyright 0 errors on the touched files
uv run pytest tests/test_strict_schema.py tests/test_strict_schema_oneof.py 41 passed
make tests 6407 passed, no new failures against main at the same commit

This is independent of #4277: it changes only the oneOf block, which that PR does not modify, and the defect exists on main today. If both land, whichever goes second rebases trivially and I will handle that.

Issue number

None reported; found while auditing the strict conversion for other silent semantic changes.

Checks

  • I've added new tests, if relevant
  • I've run .agents/skills/code-change-verification/scripts/run.sh
  • I've confirmed all verification steps pass
  • If using Codex, I've run /review before submitting this PR

The verification script shells out to make; I ran the underlying steps individually, with the results above.

When a schema node carries both anyOf and oneOf, strict conversion
folded the oneOf variants into the existing anyOf list. Those are two
constraints that must BOTH hold, so the fold turns a conjunction into a
union and the converted schema accepts values the original rejects:

    {"anyOf": [string, integer], "oneOf": [string, boolean]}

accepts "hello" only, since 5 fails the oneOf and true fails the anyOf.
After merging, the schema is anyOf [string, integer, string, boolean]
and both 5 and true validate. For a function tool or output type this
widens the declared contract: the provider constrains generation to the
converted schema, so the model can produce arguments or outputs the
original schema forbids and nothing downstream notices.

The prior expectation in test_oneof_merged_with_existing_anyof made the
problem concrete: its input schema accepts no value at all, a string
fails its oneOf and an integer fails its anyOf, yet the merged form
accepted strings, integers and booleans alike.

The combination now fails conversion the way other inexpressible
schemas do. Callers that can degrade, such as MCP tool conversion, fall
back to serving the original schema as non-strict, which preserves both
constraints; function tools and output types get an actionable error.
The documented oneOf-to-anyOf conversion for discriminated unions is
unchanged when no competing anyOf exists, and an empty anyOf list is
still replaced, since it constrains nothing.
Copilot AI lite review requested due to automatic review settings August 8, 2026 06:26

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@seratch

seratch commented Aug 8, 2026

Copy link
Copy Markdown
Member

Thanks for identifying the semantic widening here. The core observation is correct, but we do not want to merge another isolated condition into the current oneOf normalization.

The proposed exception for anyOf: [] is also incorrect: JSON Schema requires anyOf to contain at least one schema, so this shape must not be treated as imposing no constraint. More broadly, converting oneOf to anyOf is only meaning-preserving when the branches are mutually exclusive; this PR still widens overlapping oneOf schemas.

We are going to address this at the strict-conversion contract level: accept only known meaning-preserving normalizations, reject ambiguous or unsupported compositions before mutation, and validate the converted result against the supported Structured Outputs subset while preserving MCP's non-strict fallback. I am going to close this PR as a standalone fix, but we will reuse its mixed-union regression case in that work. Thank you for the careful investigation.

@seratch seratch closed this Aug 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants