Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/helpers/zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,22 @@ function zodV4ToJsonSchema(schema: z4.ZodType): Record<string, unknown> {
return toStrictJsonSchema(
z4.toJSONSchema(schema, {
target: 'draft-7',
override: ({ zodSchema, jsonSchema }) => {
const def = zodSchema._zod.def;

if (def.type === 'union' && 'discriminator' in def && Array.isArray(jsonSchema.oneOf)) {
if (jsonSchema.anyOf !== undefined) {
throw new Error(
'Zod discriminated union generated both `anyOf` and `oneOf`, which cannot be represented in an OpenAI strict schema',
);
}

// Discriminator values are mutually exclusive, so anyOf preserves the
// union while staying inside the API's supported JSON Schema subset.
jsonSchema.anyOf = jsonSchema.oneOf;
delete jsonSchema.oneOf;
}
},
}) as JSONSchema,
) as Record<string, unknown>;
}
Expand Down
14 changes: 14 additions & 0 deletions tests/helpers/zod.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,20 @@ function expectDefinitionRefsToResolve(schema: Record<string, unknown>) {
visit(schema, new Set());
}

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 },
Expand Down
Loading