Skip to content

fix(types): preserve anyOf and oneOf constraints - #1951

Open
Solaris-star wants to merge 2 commits into
dottxt-ai:mainfrom
Solaris-star:fix/1950-anyof-oneof-unions
Open

fix(types): preserve anyOf and oneOf constraints#1951
Solaris-star wants to merge 2 commits into
dottxt-ai:mainfrom
Solaris-star:fix/1950-anyof-oneof-unions

Conversation

@Solaris-star

Copy link
Copy Markdown
Contributor

Summary

schema_type_to_python handled type, enum, const, and type arrays, but silently fell through to Any for JSON Schema anyOf and oneOf.

That dropped all value constraints when json_schema_dict_to_pydantic converted a schema containing a union. Structured generation could then accept arbitrary values for a field whose schema specified a finite union.

Fix

  • Recursively convert each anyOf / oneOf branch
  • Combine the resulting Python types with Union
  • Preserve explicit nullable branches as Optional[...]

oneOf exclusivity cannot be represented by Python Union when branches overlap, but retaining the allowed type union is strictly safer than widening to Any.

Verification

  • pytest tests/types/test_json_schema_utils.py — 24 passed
  • pre-commit: mypy, ruff, whitespace, merge checks all passed

Fixes #1950

@github-actions

Copy link
Copy Markdown

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

Preview updates automatically with each commit.

members = tuple(
schema_type_to_python(member, caller_target_type)
for member in schema[keyword]
)

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.

The early return drops sibling constraints, but JSON Schema combines them with anyOf. For {"type":"string","anyOf":[{"const":"ok"},{"const":1}]}, this produces Literal["ok", 1] and Pydantic accepts 1, even though the schema rejects it. Can the outer constraints be preserved here?

@Solaris-star

Copy link
Copy Markdown
Contributor Author

Good catch, and you're right that the sibling type is dropped. I confirmed it locally:

schema_type_to_python({"type": "string", "anyOf": [{"const": "ok"}, {"const": 1}]}, "pydantic")
# -> Union[Literal["ok"], Literal[1]]   # the outer "type": "string" is lost, so 1 slips through

The reason I kept the early return is that combining type with anyOf/oneOf is an intersection (AND) in JSON Schema — the value must satisfy both the bare type and one of the union members. Python's typing system has no general intersection type, so there's no faithful annotation for the combined constraint: Union[...] alone is too loose (your 1 case), and narrowing to just type would be too strict (drops the enum-like refinement).

This PR is scoped to #1950, which is specifically about anyOf/oneOf collapsing to Any when they appear on their own — the common Pydantic-emitted shape. The type + anyOf intersection is a real but separate limitation that predates this change and can't be resolved without either a runtime validator on top of the generated type or an intersection representation.

Happy to open a follow-up issue documenting the intersection gap so it's tracked rather than silently lost. Would you prefer I add a short comment in the code flagging that sibling scalar constraints alongside anyOf/oneOf are not intersected, so the limitation is visible at the call site?

# widening the value back to its bare type.
return Literal[schema["const"]]

for keyword in ("anyOf", "oneOf"):

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 returns before type is consulted, so the constraint-only form of anyOf/oneOf loses the declared type: {"type": "string", "anyOf": [{"minLength": 1}, {"const": "x"}]} has members with no type, each maps to Any, and Union[Any, Any] collapses to plain Any so the field stops being a str. Same widening the const comment above is guarding against. Would it be safer to only take this branch when type is absent, or to intersect with the outer type the way the isinstance(t, list) branch reuses {**schema, ...}?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You were right that the early union return widened sibling type constraints. I pushed 692f64e: anyOf/oneOf are converted to a Python union only when no outer type is present; otherwise the existing outer-type mapping remains the conservative approximation of the JSON Schema intersection. Added regressions for untyped branches and a conflicting const. The type-utils module is 25/25 green, and the repo-pinned pre-commit/mypy/ruff hooks pass.

Signed-off-by: Solaris-star <820622658@qq.com>

@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.

The core of this works. Ran it against a clone of main at be2cd15 and then against 692f64e:

                                   main    this PR
anyOf [str, int]                   Any     Union[str, int]
oneOf [str, null]                  Any     Optional[str]
anyOf [int, null]                  Any     Optional[int]
nested anyOf                       Any     int
plain type / enum / array          unchanged

Guarding on "type" not in schema is the right precedence choice, since a schema carrying both is already ambiguous and deferring to type keeps the existing path intact.

There is a regression I would want fixed before this lands, though. The members go straight into the recursive call with no guard on what they are, and JSON Schema allows a subschema to be a boolean, not just an object. Draft 6 onward defines true as the always-valid schema and false as the never-valid one, so {"anyOf": [{"type": "integer"}, true]} is a legal document. Today it degrades to Any; on this branch it raises:

schema_type_to_python({"anyOf": [{"type": "integer"}, True]}, "pydantic")
# main:     typing.Any
# this PR:  TypeError: argument of type 'bool' is not iterable

It comes through the realistic entry point too, not just the helper:

json_schema_dict_to_pydantic({
    "type": "object",
    "properties": {"x": {"anyOf": [{"type": "integer"}, True]}},
    "required": [],
})
# main:     x -> Optional[Any]
# this PR:  TypeError: argument of type 'bool' is not iterable

A non-dict member reaches "enum" in schema and schema.get(...) inside the recursive call, so a string member fails a bit differently but just as hard (AttributeError: 'str' object has no attribute 'get'). Turning a permissive fallback into an exception is the part that concerns me more than the specific input, because callers converting third-party schemas currently get a loose model and would now get a traceback.

Filtering the members to dicts before the comprehension is enough, and it also keeps the Any fallback meaningful when everything is filtered out.

The other thing worth raising is overlap. #1963 covers the same two keywords and I ran both branches through the same matrix:

                        this PR                 #1963
anyOf [str, int]        Union[str, int]         Union[str, int]
oneOf [str, null]       Optional[str]           Optional[str]
anyOf [int, null]       Optional[int]           Optional[int]
allOf single            Any                     int
$ref to nested model    Any                     resolved model
anyOf with boolean      TypeError               int

On anyOf and oneOf the two are behaviourally identical, so this is a subset of #1963 rather than a complementary change, and #1963 already filters non-dict members so it does not have the crash above. They also touch the same function in the same file, so they will conflict.

I do not think that makes this PR redundant on the process side, it is the smaller and easier change to review, and if #1963 stalls this is the one that should go in. But the two should be decided together rather than reviewed as independent contributions. Flagging for the maintainers.

Tests are fine as far as they go. If this stays separate, a case with a boolean subschema member would pin the behaviour the fix above restores.

@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.

Confirming this fix and adding the round-trip case, since it's the one that makes the priority clear.

Pydantic emits anyOf for Optional[T] and Union[...], so this isn't only a hand-written-schema concern — any model going through json_schema_dict_to_pydantic loses those fields:

class Source(BaseModel):
    maybe_int: Optional[int]
    either: Union[int, str]
    plain: int
field on main with this PR
maybe_int Any Optional[int]
either Any Union[int, str]
plain int int

On main the regenerated schema for maybe_int is just {"title": "Maybe Int"} — nothing left to constrain generation with. With the fix it round-trips back to {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Maybe Int"}, identical to what Pydantic produced.

The "type" not in schema guard is the right call. I had written the same fix without it (#1973, now closed in favour of this one) and it diverges on {"type": "object", "anyOf": [...]}: without the guard the declared type is dropped in favour of the branches, which is a widening. JSON Schema treats both as constraints that apply together, so letting type win is the conservative reading.

{"type": "object", "anyOf": [{"type":"integer"},{"type":"string"}]}
  without the guard -> Union[int, str]     (type discarded)
  this PR           -> falls through to the type dispatch

Empty branch lists agree either way: {"anyOf": []} gives Any here, and Any via the type fallthrough without the guard.

If it's useful, the tests I had written cover a few cases beyond the ones here — both keywords parametrised over the same assertion, null-branch to Optional, anyOf agreeing with the equivalent {"type": ["integer","string"]} spelling, recursive branch mapping (anyOf of array/string to Union[List[int], str]), and the round trip above. Happy to open them as a follow-up against this branch rather than a competing PR.

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.

schema_type_to_python drops anyOf/oneOf constraints to Any

3 participants