Skip to content
Open
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
41 changes: 30 additions & 11 deletions src/helpers/zod.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ResponseFormatJSONSchema } from '../resources/index';
import * as z3 from 'zod/v3';
import * as z4 from 'zod/v4';
import * as z4Mini from 'zod/v4-mini';
import {
AutoParseableResponseFormat,
AutoParseableTextFormat,
Expand All @@ -15,8 +16,12 @@ import { type ResponseFormatTextJSONSchemaConfig } from '../resources/responses/
import { toStrictJsonSchema } from '../lib/transform';
import { JSONSchema } from '../lib/jsonschema';

type ZodV4Schema = z4.ZodType | z4Mini.ZodMiniType;
type ZodSchema = z3.ZodType | ZodV4Schema;

type InferZodType<T> =
T extends z4.ZodType ? z4.infer<T>
T extends z4Mini.ZodMiniType ? z4Mini.infer<T>
: T extends z4.ZodType ? z4.infer<T>
: T extends z3.ZodType ? z3.infer<T>
: never;

Expand All @@ -30,7 +35,7 @@ function zodV3ToJsonSchema(schema: z3.ZodType, options: { name: string }): Recor
});
}

function zodV4ToJsonSchema(schema: z4.ZodType): Record<string, unknown> {
function zodV4ToJsonSchema(schema: ZodV4Schema): Record<string, unknown> {
return toStrictJsonSchema(
z4.toJSONSchema(schema, {
target: 'draft-7',
Expand All @@ -54,10 +59,24 @@ function zodV4ToJsonSchema(schema: z4.ZodType): Record<string, unknown> {
) as Record<string, unknown>;
}

function isZodV4(zodObject: z3.ZodType | z4.ZodType): zodObject is z4.ZodType {
function isZodV4(zodObject: ZodSchema): zodObject is ZodV4Schema {
return '_zod' in zodObject;
}

function parseZodObject<ZodInput extends ZodSchema>(
zodObject: ZodInput,
content: string,
): InferZodType<ZodInput> {
const parsed = JSON.parse(content);
const parser = (zodObject as { parse?: (data: unknown) => unknown }).parse;

if (typeof parser === 'function') {
return parser.call(zodObject, parsed) as InferZodType<ZodInput>;
}

return z4.parse(zodObject as ZodV4Schema, parsed) as InferZodType<ZodInput>;
}

/**
* Creates a chat completion `JSONSchema` response format object from
* the given Zod schema.
Expand Down Expand Up @@ -95,7 +114,7 @@ function isZodV4(zodObject: z3.ZodType | z4.ZodType): zodObject is z4.ZodType {
* This can be passed directly to the `.create()` method but will not
* result in any automatic parsing, you'll have to parse the response yourself.
*/
export function zodResponseFormat<ZodInput extends z3.ZodType | z4.ZodType>(
export function zodResponseFormat<ZodInput extends ZodSchema>(
zodObject: ZodInput,
name: string,
props?: Omit<ResponseFormatJSONSchema.JSONSchema, 'schema' | 'strict' | 'name'>,
Expand All @@ -110,11 +129,11 @@ export function zodResponseFormat<ZodInput extends z3.ZodType | z4.ZodType>(
schema: isZodV4(zodObject) ? zodV4ToJsonSchema(zodObject) : zodV3ToJsonSchema(zodObject, { name }),
},
},
(content) => zodObject.parse(JSON.parse(content)),
(content) => parseZodObject(zodObject, content),
);
}

export function zodTextFormat<ZodInput extends z3.ZodType | z4.ZodType>(
export function zodTextFormat<ZodInput extends ZodSchema>(
zodObject: ZodInput,
name: string,
props?: Omit<ResponseFormatTextJSONSchemaConfig, 'schema' | 'type' | 'strict' | 'name'>,
Expand All @@ -127,7 +146,7 @@ export function zodTextFormat<ZodInput extends z3.ZodType | z4.ZodType>(
strict: true,
schema: isZodV4(zodObject) ? zodV4ToJsonSchema(zodObject) : zodV3ToJsonSchema(zodObject, { name }),
},
(content) => zodObject.parse(JSON.parse(content)),
(content) => parseZodObject(zodObject, content),
);
}

Expand All @@ -136,7 +155,7 @@ export function zodTextFormat<ZodInput extends z3.ZodType | z4.ZodType>(
* automatically by the chat completion `.runTools()` method or automatically
* parsed by `.parse()` / `.stream()`.
*/
export function zodFunction<Parameters extends z3.ZodType | z4.ZodType>(options: {
export function zodFunction<Parameters extends ZodSchema>(options: {
name: string;
parameters: Parameters;
function?: ((args: InferZodType<Parameters>) => unknown | Promise<unknown>) | undefined;
Expand All @@ -162,12 +181,12 @@ export function zodFunction<Parameters extends z3.ZodType | z4.ZodType>(options:
},
{
callback: options.function,
parser: (args) => options.parameters.parse(JSON.parse(args)),
parser: (args) => parseZodObject(options.parameters, args),
},
);
}

export function zodResponsesFunction<Parameters extends z3.ZodType | z4.ZodType>(options: {
export function zodResponsesFunction<Parameters extends ZodSchema>(options: {
name: string;
parameters: Parameters;
function?: ((args: InferZodType<Parameters>) => unknown | Promise<unknown>) | undefined;
Expand All @@ -190,7 +209,7 @@ export function zodResponsesFunction<Parameters extends z3.ZodType | z4.ZodType>
},
{
callback: options.function,
parser: (args) => options.parameters.parse(JSON.parse(args)),
parser: (args) => parseZodObject(options.parameters, args),
},
);
}
66 changes: 65 additions & 1 deletion tests/helpers/zod.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { zodResponseFormat, zodTextFormat } from 'openai/helpers/zod';
import { zodFunction, zodResponseFormat, zodResponsesFunction, zodTextFormat } from 'openai/helpers/zod';
import { expectType } from '../utils/typing';
import { z as zv3 } from 'zod/v3';
import { z as zv4 } from 'zod/v4';
import { z as zv4Mini } from 'zod/v4-mini';

function collectRefs(value: unknown, refs: string[] = []): string[] {
if (!value || typeof value !== 'object') return refs;
Expand Down Expand Up @@ -57,6 +59,51 @@ it('converts Zod v4 discriminated unions to anyOf for strict schemas', () => {
expect(schema.properties.data.anyOf).toHaveLength(2);
});

describe('Zod v4 mini', () => {
const MiniSchema = zv4Mini.object({ hello: zv4Mini.literal('world') });
const expectedSchema = {
$schema: 'http://json-schema.org/draft-07/schema#',
type: 'object',
properties: {
hello: {
type: 'string',
const: 'world',
},
},
required: ['hello'],
additionalProperties: false,
};

it('supports response formats', () => {
const format = zodResponseFormat(MiniSchema, 'response');

expect(format.json_schema.schema).toEqual(expectedSchema);
expect(format.$parseRaw('{"hello":"world"}')).toEqual({ hello: 'world' });
expect(() => format.$parseRaw('{"hello":"there"}')).toThrow();
});

it('supports text formats', () => {
const format = zodTextFormat(MiniSchema, 'response');

expect(format.schema).toEqual(expectedSchema);
expect(format.$parseRaw('{"hello":"world"}')).toEqual({ hello: 'world' });
expect(() => format.$parseRaw('{"hello":"there"}')).toThrow();
});

it('supports tool argument parsing', () => {
const chatTool = zodFunction({ name: 'mini_tool', parameters: MiniSchema });
const responseTool = zodResponsesFunction({ name: 'mini_tool', parameters: MiniSchema });

expect(chatTool.function.parameters).toEqual(expectedSchema);
expect(chatTool.$parseRaw('{"hello":"world"}')).toEqual({ hello: 'world' });
expect(() => chatTool.$parseRaw('{"hello":"there"}')).toThrow();

expect(responseTool.parameters).toEqual(expectedSchema);
expect(responseTool.$parseRaw('{"hello":"world"}')).toEqual({ hello: 'world' });
expect(() => responseTool.$parseRaw('{"hello":"there"}')).toThrow();
});
});

describe.each([
{ version: 'v3', z: zv3 },
{ version: 'v4', z: zv4 as any as typeof zv3 },
Expand Down Expand Up @@ -560,3 +607,20 @@ describe.each([
});
}
});

function _typeTests() {
const MiniSchema = zv4Mini.object({ hello: zv4Mini.literal('world') });

expectType<{ hello: 'world' }>(zodResponseFormat(MiniSchema, 'response').__output);
expectType<{ hello: 'world' }>(zodTextFormat(MiniSchema, 'response').__output);
zodFunction({
name: 'mini_tool',
parameters: MiniSchema,
function: (args) => expectType<{ hello: 'world' }>(args),
});
zodResponsesFunction({
name: 'mini_tool',
parameters: MiniSchema,
function: (args) => expectType<{ hello: 'world' }>(args),
});
}