Skip to content

fix(zod): convert oneOf to anyOf in toStrictJsonSchema for Zod v4 compatibility#1762

Open
s-zx wants to merge 2 commits intoopenai:masterfrom
s-zx:fix/1709-zod4-oneof-to-anyof
Open

fix(zod): convert oneOf to anyOf in toStrictJsonSchema for Zod v4 compatibility#1762
s-zx wants to merge 2 commits intoopenai:masterfrom
s-zx:fix/1709-zod4-oneof-to-anyof

Conversation

@s-zx
Copy link
Copy Markdown

@s-zx s-zx commented Mar 9, 2026

Problem

Zod v4.1.13 changed discriminated unions to emit oneOf in JSON Schema output (colinhacks/zod#5453). The OpenAI strict-mode API does not accept oneOf — it returns HTTP 400:

"Invalid schema for response_format 'choice': In context=('properties', 'data'), 'oneOf' is not permitted."

Any user on Zod v4.1.13+ who passes a discriminated union to zodResponseFormat / zodTextFormat / zodTool will hit this failure.

Reported in #1709.

Fix

After the existing anyOf handling block in ensureStrictJsonSchema, add a oneOfanyOf conversion step:

const oneOf = (jsonSchema as any).oneOf;
if (Array.isArray(oneOf)) {
  jsonSchema.anyOf = oneOf.map((variant, i) =>
    ensureStrictJsonSchema(variant, [...path, 'oneOf', String(i)], root),
  );
  delete (jsonSchema as any).oneOf;
}

Each variant still passes through ensureStrictJsonSchema, so nested objects get additionalProperties: false and required fields are set correctly. The conversion is semantically safe: Zod oneOf variants are mutually exclusive object schemas, and anyOf is the form the OpenAI API expects.

Fixes #1709

…patibility

Zod v4.1.13 changed discriminated unions to emit oneOf in JSON Schema output
(colinhacks/zod#5453). The OpenAI strict-mode API does not accept oneOf (it
returns HTTP 400 with 'oneOf is not permitted'), so any user on Zod v4.1.13+
who passes a discriminated union to zodResponseFormat / zodTextFormat / zodTool
will get an unexpected API failure.

toStrictJsonSchema() already handles anyOf by recursing into variants, but it
had no handling for oneOf. Add a post-anyOf step that converts oneOf variants
to anyOf in-place and deletes the original oneOf key. This mirrors the anyOf
handling exactly, so each variant is still passed through ensureStrictJsonSchema.

The conversion is semantically safe for the union types Zod generates because
Zod oneOf variants are mutually exclusive object schemas; anyOf is the form
the OpenAI API expects for structured-output unions.

Fixes openai#1709
@s-zx s-zx requested a review from a team as a code owner March 9, 2026 07:07
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

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: e9183061c7

ℹ️ 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/lib/transform.ts Outdated
// that Zod produces here, so we convert in-place and recurse into each variant.
const oneOf = (jsonSchema as any).oneOf;
if (Array.isArray(oneOf)) {
jsonSchema.anyOf = oneOf.map((variant: JSONSchemaDefinition, i: number) =>
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve existing anyOf when converting oneOf

This assignment overwrites any pre-existing anyOf alternatives on the same schema node, so schemas that legitimately contain both anyOf and oneOf lose constraints during strictification. In that case, ensureStrictJsonSchema first normalizes anyOf, then clobbers it with converted oneOf variants, which changes validation behavior (accepting/rejecting different payloads than the original schema) instead of only translating unsupported keywords.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done — merged oneOf into existing anyOf instead of overwriting. Schemas with both anyOf and oneOf now preserve all alternatives.

…g it

When ensureStrictJsonSchema encounters a schema node that already has an
anyOf array and also has a oneOf array, the previous code replaced anyOf
with the converted oneOf variants, silently dropping the anyOf constraints.

Spread both arrays so that all alternatives are preserved:

  jsonSchema.anyOf = Array.isArray(jsonSchema.anyOf)
    ? [...jsonSchema.anyOf, ...convertedOneOf]
    : convertedOneOf;
@s-zx
Copy link
Copy Markdown
Author

s-zx commented Mar 9, 2026

Good point — fixed. The previous code replaced anyOf outright with the converted oneOf variants. The updated logic merges them:

jsonSchema.anyOf = Array.isArray(jsonSchema.anyOf)
  ? [...jsonSchema.anyOf, ...convertedOneOf]
  : convertedOneOf;

This preserves any pre-existing anyOf alternatives when both arrays are present on the same schema node.

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.

Zod schema conversion broken for unions in Zod 4.1.13+

1 participant