diff --git a/src/lib/transform.ts b/src/lib/transform.ts index d241c215cb..453a79459e 100644 --- a/src/lib/transform.ts +++ b/src/lib/transform.ts @@ -99,7 +99,21 @@ function ensureStrictJsonSchema( jsonSchema.items = ensureStrictJsonSchema(items, [...path, 'items'], root); } - // Handle unions (anyOf) + // Handle unions (anyOf). Zod v4 emits oneOf for discriminated unions, but + // OpenAI strict schemas only support anyOf. + const oneOf = jsonSchema.oneOf; + if (Array.isArray(oneOf)) { + const existingAnyOf = jsonSchema.anyOf; + delete jsonSchema.oneOf; + + if (Array.isArray(existingAnyOf)) { + jsonSchema.allOf = [...(jsonSchema.allOf ?? []), { anyOf: existingAnyOf }, { anyOf: oneOf }]; + delete jsonSchema.anyOf; + } else { + jsonSchema.anyOf = 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..3524377082 100644 --- a/tests/helpers/zod.test.ts +++ b/tests/helpers/zod.test.ts @@ -143,6 +143,22 @@ describe.each([ `); }); + it('converts Zod discriminated unions to strict anyOf schemas', () => { + const ResponseSchema = z.object({ + data: z.discriminatedUnion('type', [ + z.object({ type: z.literal('a') }), + z.object({ type: z.literal('b') }), + ]), + }); + + const schema = zodResponseFormat(ResponseSchema, 'choice').json_schema.schema as Record; + const dataSchema = schema['properties']['data']; + + expect(dataSchema.oneOf).toBeUndefined(); + expect(dataSchema.anyOf).toHaveLength(2); + expect(JSON.stringify(schema)).not.toContain('"oneOf"'); + }); + it('allows description field to be passed in', () => { expect( zodResponseFormat( diff --git a/tests/lib/transform.test.ts b/tests/lib/transform.test.ts index 87195d998d..93cc3bd842 100644 --- a/tests/lib/transform.test.ts +++ b/tests/lib/transform.test.ts @@ -271,6 +271,73 @@ describe('toStrictJsonSchema', () => { } `); }); + + test('keeps existing anyOf separate when converting oneOf', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + value: { + anyOf: [ + { + type: 'object', + properties: { str: { type: 'string' } }, + required: ['str'], + }, + ], + oneOf: [ + { + type: 'object', + properties: { num: { type: 'number' } }, + required: ['num'], + }, + ], + }, + }, + required: ['value'], + }; + + const strict = toStrictJsonSchema(schema); + const valueSchema = strict.properties?.['value'] as JSONSchema; + + expect(valueSchema.oneOf).toBeUndefined(); + expect(valueSchema.anyOf).toBeUndefined(); + expect(valueSchema.allOf).toMatchInlineSnapshot(` + [ + { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "str": { + "type": "string", + }, + }, + "required": [ + "str", + ], + "type": "object", + }, + ], + }, + { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "num": { + "type": "number", + }, + }, + "required": [ + "num", + ], + "type": "object", + }, + ], + }, + ] + `); + }); }); describe('allOf Handling', () => {