From 67133491411ba331c959358f541dc4fa01c6e2e0 Mon Sep 17 00:00:00 2001 From: kiwigitops Date: Sat, 23 May 2026 01:51:01 -0400 Subject: [PATCH 1/2] fix: convert zod oneOf unions for strict schemas --- src/lib/transform.ts | 8 +++++++- tests/helpers/zod.test.ts | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/lib/transform.ts b/src/lib/transform.ts index d241c215cb..9ad5d0a8f2 100644 --- a/src/lib/transform.ts +++ b/src/lib/transform.ts @@ -99,7 +99,13 @@ function ensureStrictJsonSchema( jsonSchema.items = ensureStrictJsonSchema(items, [...path, 'items'], root); } - // Handle unions (anyOf) + // Handle unions (OpenAI strict mode supports anyOf, but not oneOf) + const oneOf = jsonSchema.oneOf; + if (Array.isArray(oneOf)) { + jsonSchema.anyOf = Array.isArray(jsonSchema.anyOf) ? [...jsonSchema.anyOf, ...oneOf] : oneOf; + delete jsonSchema.oneOf; + } + const anyOf = jsonSchema.anyOf; if (Array.isArray(anyOf)) { jsonSchema.anyOf = anyOf.map((variant, i) => diff --git a/tests/helpers/zod.test.ts b/tests/helpers/zod.test.ts index 1bc766376c..ea666264ca 100644 --- a/tests/helpers/zod.test.ts +++ b/tests/helpers/zod.test.ts @@ -2,6 +2,20 @@ import { zodResponseFormat } from 'openai/helpers/zod'; import { z as zv3 } from 'zod/v3'; import { z as zv4 } from 'zod/v4'; +it('converts Zod v4 discriminated unions to anyOf for strict schemas', () => { + const ResponseSchema = zv4.object({ + data: zv4.discriminatedUnion('type', [ + zv4.object({ type: zv4.literal('a') }), + zv4.object({ type: zv4.literal('b') }), + ]), + }); + + const schema = zodResponseFormat(ResponseSchema, 'choice').json_schema.schema as any; + + expect(JSON.stringify(schema)).not.toContain('"oneOf"'); + expect(schema.properties.data.anyOf).toHaveLength(2); +}); + describe.each([ { version: 'v3', z: zv3 }, { version: 'v4', z: zv4 as any as typeof zv3 }, From 2957e7f9a749f11921d62f6cf0ade1ba2bceb042 Mon Sep 17 00:00:00 2001 From: kiwigitops Date: Sat, 23 May 2026 16:06:32 -0400 Subject: [PATCH 2/2] fix: preserve mixed anyOf/oneOf semantics --- src/lib/transform.ts | 43 ++++++++++++++++++++++++++++++++++++- tests/helpers/zod.test.ts | 4 +++- tests/lib/transform.test.ts | 31 ++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 2 deletions(-) diff --git a/src/lib/transform.ts b/src/lib/transform.ts index 9ad5d0a8f2..cc8827deeb 100644 --- a/src/lib/transform.ts +++ b/src/lib/transform.ts @@ -31,6 +31,32 @@ function isNullable(schema: JSONSchemaDefinition): boolean { return false; } +function cloneSchemaDefinition(schema: JSONSchemaDefinition): JSONSchemaDefinition { + return structuredClone(schema); +} + +function convertOneOfToStrictSchema(oneOf: JSONSchemaDefinition[]): JSONSchema { + if (oneOf.length === 1) { + return { allOf: [{ anyOf: [cloneSchemaDefinition(oneOf[0]!)] }] }; + } + + const overlaps = []; + for (let i = 0; i < oneOf.length; i++) { + for (let j = i + 1; j < oneOf.length; j++) { + overlaps.push({ + allOf: [cloneSchemaDefinition(oneOf[i]!), cloneSchemaDefinition(oneOf[j]!)], + }); + } + } + + return { + allOf: [ + { anyOf: oneOf.map((variant) => cloneSchemaDefinition(variant)) }, + { not: { anyOf: overlaps } }, + ], + }; +} + /** * Mutates the given JSON schema to ensure it conforms to the `strict` standard * that the API expects. @@ -102,8 +128,18 @@ function ensureStrictJsonSchema( // Handle unions (OpenAI strict mode supports anyOf, but not oneOf) const oneOf = jsonSchema.oneOf; if (Array.isArray(oneOf)) { - jsonSchema.anyOf = Array.isArray(jsonSchema.anyOf) ? [...jsonSchema.anyOf, ...oneOf] : oneOf; + const transformedOneOf = convertOneOfToStrictSchema(oneOf); delete jsonSchema.oneOf; + + if (Array.isArray(jsonSchema.anyOf)) { + const existingAnyOf = jsonSchema.anyOf; + delete jsonSchema.anyOf; + jsonSchema.allOf = [...(jsonSchema.allOf ?? []), { anyOf: existingAnyOf }, transformedOneOf]; + } else if (Array.isArray(jsonSchema.allOf)) { + jsonSchema.allOf = [...jsonSchema.allOf, transformedOneOf]; + } else { + Object.assign(jsonSchema, transformedOneOf); + } } const anyOf = jsonSchema.anyOf; @@ -127,6 +163,11 @@ function ensureStrictJsonSchema( } } + const not = jsonSchema.not; + if (isObject(not)) { + jsonSchema.not = ensureStrictJsonSchema(not, [...path, 'not'], root); + } + // Strip `null` defaults as there's no meaningful distinction if (jsonSchema.default === null) { delete jsonSchema.default; diff --git a/tests/helpers/zod.test.ts b/tests/helpers/zod.test.ts index ea666264ca..6dab816b74 100644 --- a/tests/helpers/zod.test.ts +++ b/tests/helpers/zod.test.ts @@ -13,7 +13,9 @@ it('converts Zod v4 discriminated unions to anyOf for strict schemas', () => { const schema = zodResponseFormat(ResponseSchema, 'choice').json_schema.schema as any; expect(JSON.stringify(schema)).not.toContain('"oneOf"'); - expect(schema.properties.data.anyOf).toHaveLength(2); + expect(schema.properties.data).toMatchObject({ + allOf: [{ anyOf: [{ type: 'object' }, { type: 'object' }] }, { not: { anyOf: expect.any(Array) } }], + }); }); describe.each([ diff --git a/tests/lib/transform.test.ts b/tests/lib/transform.test.ts index 87195d998d..df8a1a8e3b 100644 --- a/tests/lib/transform.test.ts +++ b/tests/lib/transform.test.ts @@ -271,6 +271,37 @@ describe('toStrictJsonSchema', () => { } `); }); + + test('preserves separate anyOf and oneOf semantics', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + value: { + anyOf: [{ type: 'string' }, { type: 'number' }], + oneOf: [{ minimum: 0 }, { maximum: 10 }], + }, + }, + required: ['value'], + }; + + const strict = toStrictJsonSchema(schema); + + expect(strict.properties?.value).toEqual({ + allOf: [ + { anyOf: [{ type: 'string' }, { type: 'number' }] }, + { + allOf: [ + { anyOf: [{ minimum: 0 }, { maximum: 10 }] }, + { + not: { + anyOf: [{ allOf: [{ minimum: 0 }, { maximum: 10 }] }], + }, + }, + ], + }, + ], + }); + }); }); describe('allOf Handling', () => {