Skip to content
2 changes: 1 addition & 1 deletion examples/parsing.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { zodResponseFormat } from 'openai/helpers/zod';
import OpenAI from 'openai/index';
import { z } from 'zod/v3';
import { z } from 'zod/v3'; // Also works for 'zod/v4'

const Step = z.object({
explanation: z.string(),
Expand Down
69 changes: 62 additions & 7 deletions src/helpers/zod.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ResponseFormatJSONSchema } from '../resources/index';
import type { infer as zodInfer, ZodType } from 'zod/v3';
import { toJSONSchema, type infer as zodInferV4, type ZodType as ZodTypeV4 } from 'zod/v4';
import {
AutoParseableResponseFormat,
AutoParseableTextFormat,
Expand All @@ -11,6 +12,8 @@ import {
import { zodToJsonSchema as _zodToJsonSchema } from '../_vendor/zod-to-json-schema';
import { AutoParseableResponseTool, makeParseableResponseTool } from '../lib/ResponsesParser';
import { type ResponseFormatTextJSONSchemaConfig } from '../resources/responses/responses';
import { toStrictJsonSchema } from '../lib/transform';
import { JSONSchema } from '../lib/jsonschema';

function zodToJsonSchema(schema: ZodType, options: { name: string }): Record<string, unknown> {
return _zodToJsonSchema(schema, {
Expand All @@ -22,6 +25,14 @@ function zodToJsonSchema(schema: ZodType, options: { name: string }): Record<str
});
}

function nativeToJsonSchema(schema: ZodTypeV4): Record<string, unknown> {
return toJSONSchema(schema, { target: 'draft-7' }) as Record<string, unknown>;
}

function isZodV4(zodObject: ZodType | ZodTypeV4): zodObject is ZodTypeV4 {
return '_zod' in zodObject;
}

/**
* Creates a chat completion `JSONSchema` response format object from
* the given Zod schema.
Expand Down Expand Up @@ -63,15 +74,28 @@ export function zodResponseFormat<ZodInput extends ZodType>(
zodObject: ZodInput,
name: string,
props?: Omit<ResponseFormatJSONSchema.JSONSchema, 'schema' | 'strict' | 'name'>,
): AutoParseableResponseFormat<zodInfer<ZodInput>> {
): AutoParseableResponseFormat<zodInfer<ZodInput>>;
export function zodResponseFormat<ZodInput extends ZodTypeV4>(
zodObject: ZodInput,
name: string,
props?: Omit<ResponseFormatJSONSchema.JSONSchema, 'schema' | 'strict' | 'name'>,
): AutoParseableResponseFormat<zodInferV4<ZodInput>>;
export function zodResponseFormat<ZodInput extends ZodType | ZodTypeV4>(
zodObject: ZodInput,
name: string,
props?: Omit<ResponseFormatJSONSchema.JSONSchema, 'schema' | 'strict' | 'name'>,
): unknown {
return makeParseableResponseFormat(
{
type: 'json_schema',
json_schema: {
...props,
name,
strict: true,
schema: zodToJsonSchema(zodObject, { name }),
schema:
isZodV4(zodObject) ?
(toStrictJsonSchema(nativeToJsonSchema(zodObject)) as Record<string, unknown>)
: zodToJsonSchema(zodObject, { name }),
},
},
(content) => zodObject.parse(JSON.parse(content)),
Expand All @@ -82,14 +106,27 @@ export function zodTextFormat<ZodInput extends ZodType>(
zodObject: ZodInput,
name: string,
props?: Omit<ResponseFormatTextJSONSchemaConfig, 'schema' | 'type' | 'strict' | 'name'>,
): AutoParseableTextFormat<zodInfer<ZodInput>> {
): AutoParseableTextFormat<zodInfer<ZodInput>>;
export function zodTextFormat<ZodInput extends ZodTypeV4>(
zodObject: ZodInput,
name: string,
props?: Omit<ResponseFormatTextJSONSchemaConfig, 'schema' | 'type' | 'strict' | 'name'>,
): AutoParseableTextFormat<zodInferV4<ZodInput>>;
export function zodTextFormat<ZodInput extends ZodType | ZodTypeV4>(
zodObject: ZodInput,
name: string,
props?: Omit<ResponseFormatTextJSONSchemaConfig, 'schema' | 'type' | 'strict' | 'name'>,
): unknown {
return makeParseableTextFormat(
{
type: 'json_schema',
...props,
name,
strict: true,
schema: zodToJsonSchema(zodObject, { name }),
schema:
isZodV4(zodObject) ?
(toStrictJsonSchema(nativeToJsonSchema(zodObject)) as Record<string, unknown>)
: zodToJsonSchema(zodObject, { name }),
},
(content) => zodObject.parse(JSON.parse(content)),
);
Expand All @@ -109,14 +146,32 @@ export function zodFunction<Parameters extends ZodType>(options: {
arguments: Parameters;
name: string;
function: (args: zodInfer<Parameters>) => unknown;
}> {
// @ts-expect-error TODO
}>;
export function zodFunction<Parameters extends ZodTypeV4>(options: {
name: string;
parameters: Parameters;
function?: ((args: zodInferV4<Parameters>) => unknown | Promise<unknown>) | undefined;
description?: string | undefined;
}): AutoParseableTool<{
arguments: Parameters;
name: string;
function: (args: zodInferV4<Parameters>) => unknown;
}>;
export function zodFunction<Parameters extends ZodType | ZodTypeV4>(options: {
name: string;
parameters: Parameters;
function?: ((args: any) => unknown | Promise<unknown>) | undefined;
description?: string | undefined;
}): unknown {
return makeParseableTool<any>(
{
type: 'function',
function: {
name: options.name,
parameters: zodToJsonSchema(options.parameters, { name: options.name }),
parameters:
isZodV4(options.parameters) ?
nativeToJsonSchema(options.parameters)
: zodToJsonSchema(options.parameters, { name: options.name }),
strict: true,
...(options.description ? { description: options.description } : undefined),
},
Expand Down
24 changes: 24 additions & 0 deletions src/lib/jsonschema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,30 @@ export interface JSONSchema {
oneOf?: JSONSchemaDefinition[] | undefined;
not?: JSONSchemaDefinition | undefined;

/**
* @see https://json-schema.org/draft/2020-12/json-schema-core.html#section-8.2.4
*/
$defs?:
| {
[key: string]: JSONSchemaDefinition;
}
| undefined;

/**
* @deprecated Use $defs instead (draft 2019-09+)
* @see https://tools.ietf.org/doc/html/draft-handrews-json-schema-validation-01#page-22
*/
definitions?:
| {
[key: string]: JSONSchemaDefinition;
}
| undefined;

/**
* @see https://json-schema.org/draft/2020-12/json-schema-core#ref
*/
$ref?: string | undefined;

/**
* @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-7
*/
Expand Down
158 changes: 158 additions & 0 deletions src/lib/transform.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import type { JSONSchema, JSONSchemaDefinition } from './jsonschema';

export function toStrictJsonSchema(schema: JSONSchema): JSONSchema {
return ensureStrictJsonSchema(schema, [], schema);
}

function ensureStrictJsonSchema(
jsonSchema: JSONSchemaDefinition,
path: string[],
root: JSONSchema,
): JSONSchema {
/**
* Mutates the given JSON schema to ensure it conforms to the `strict` standard
* that the API expects.
*/
if (typeof jsonSchema === 'boolean') {
throw new TypeError(`Expected object schema but got boolean; path=${path.join('/')}`);
}

if (!isDict(jsonSchema)) {
throw new TypeError(`Expected ${JSON.stringify(jsonSchema)} to be a dictionary; path=${path.join('/')}`);
}

// Handle $defs (non-standard but sometimes used)
const defs = (jsonSchema as any).$defs;
if (isDict(defs)) {
for (const [defName, defSchema] of Object.entries(defs)) {
ensureStrictJsonSchema(defSchema, [...path, '$defs', defName], root);
}
}

// Handle definitions (draft-04 style, deprecated in draft-07 but still used)
const definitions = (jsonSchema as any).definitions;
if (isDict(definitions)) {
for (const [definitionName, definitionSchema] of Object.entries(definitions)) {
ensureStrictJsonSchema(definitionSchema, [...path, 'definitions', definitionName], root);
}
}

// Add additionalProperties: false to object types
const typ = jsonSchema.type;
if (typ === 'object' && !('additionalProperties' in jsonSchema)) {
jsonSchema.additionalProperties = false;
}

// Handle object properties
const properties = jsonSchema.properties;
if (isDict(properties)) {
jsonSchema.required = Object.keys(properties);
jsonSchema.properties = Object.fromEntries(
Object.entries(properties).map(([key, propSchema]) => [
key,
ensureStrictJsonSchema(propSchema, [...path, 'properties', key], root),
]),
);
}

// Handle arrays
const items = jsonSchema.items;
if (isDict(items)) {
// @ts-ignore(2345)
jsonSchema.items = ensureStrictJsonSchema(items, [...path, 'items'], root);
}

// Handle unions (anyOf)
const anyOf = jsonSchema.anyOf;
if (Array.isArray(anyOf)) {
jsonSchema.anyOf = anyOf.map((variant, i) =>
ensureStrictJsonSchema(variant, [...path, 'anyOf', String(i)], root),
);
}

// Handle intersections (allOf)
const allOf = jsonSchema.allOf;
if (Array.isArray(allOf)) {
if (allOf.length === 1) {
const resolved = ensureStrictJsonSchema(allOf[0]!, [...path, 'allOf', '0'], root);
Object.assign(jsonSchema, resolved);
delete jsonSchema.allOf;
} else {
jsonSchema.allOf = allOf.map((entry, i) =>
ensureStrictJsonSchema(entry, [...path, 'allOf', String(i)], root),
);
}
}

// Strip `null` defaults as there's no meaningful distinction
if (jsonSchema.default === null) {
delete jsonSchema.default;
}

// Handle $ref with additional properties
const ref = (jsonSchema as any).$ref;
if (ref && hasMoreThanNKeys(jsonSchema, 1)) {
if (typeof ref !== 'string') {
throw new TypeError(`Received non-string $ref - ${ref}`);
}

const resolved = resolveRef(root, ref);
if (typeof resolved === 'boolean') {
throw new Error(`Expected \`$ref: ${ref}\` to resolve to an object schema but got boolean`);
}
if (!isDict(resolved)) {
throw new Error(
`Expected \`$ref: ${ref}\` to resolve to a dictionary but got ${JSON.stringify(resolved)}`,
);
}

// Properties from the json schema take priority over the ones on the `$ref`
Object.assign(jsonSchema, { ...resolved, ...jsonSchema });
delete (jsonSchema as any).$ref;

// Since the schema expanded from `$ref` might not have `additionalProperties: false` applied,
// we call `ensureStrictJsonSchema` again to fix the inlined schema and ensure it's valid.
return ensureStrictJsonSchema(jsonSchema, path, root);
}

return jsonSchema;
}

function resolveRef(root: JSONSchema, ref: string): JSONSchemaDefinition {
if (!ref.startsWith('#/')) {
throw new Error(`Unexpected $ref format ${JSON.stringify(ref)}; Does not start with #/`);
}

const pathParts = ref.slice(2).split('/');
let resolved: any = root;

for (const key of pathParts) {
if (!isDict(resolved)) {
throw new Error(
`encountered non-dictionary entry while resolving ${ref} - ${JSON.stringify(resolved)}`,
);
}
const value = resolved[key];
if (value === undefined) {
throw new Error(`Key ${key} not found while resolving ${ref}`);
}
resolved = value;
}

return resolved;
}

function isDict(obj: any): obj is Record<string, any> {
return typeof obj === 'object' && obj !== null && !Array.isArray(obj);
}

function hasMoreThanNKeys(obj: Record<string, any>, n: number): boolean {
let i = 0;
for (const _ in obj) {
i++;
if (i > n) {
return true;
}
}
return false;
}
12 changes: 8 additions & 4 deletions tests/helpers/zod.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { zodResponseFormat } from 'openai/helpers/zod';
import { z } from 'zod/v3';
import { z as zv3 } from 'zod/v3';
import { z as zv4 } from 'zod';

describe('zodResponseFormat', () => {
describe.each([
{ version: 'v3', z: zv3 as any },
{ version: 'v4', z: zv4 as any },
])('zodResponseFormat (Zod $version)', ({ version, z }) => {
Comment on lines +5 to +8
Copy link
Collaborator

Choose a reason for hiding this comment

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

👌 this is a cool way to test things

it('does the thing', () => {
expect(
zodResponseFormat(
Expand Down Expand Up @@ -286,7 +290,7 @@ describe('zodResponseFormat', () => {
`);
});

it('throws error on optional fields', () => {
(version === 'v4' ? it.skip : it)('throws error on optional fields', () => {
Copy link
Author

Choose a reason for hiding this comment

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

as we transform the schema, we actually add missing fields in required property, as we do in python

Copy link
Collaborator

Choose a reason for hiding this comment

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

hmm I don't think this is quite right, in Python it's fine because the property being omitted or explicitly set to null results in the same type, None, but in TS it's different.

so if we add properties that are .optional() to the required array, then the API will send them back as null, which breaks the type promise because it'd be typed as property?: string instead of property?: string | null or property: string | null.

the equivalent behaviour here for python would be to only add properties to required when they're both .optional() and .nullable() which is why we throw the current error.

expect(() =>
zodResponseFormat(
z.object({
Expand All @@ -301,7 +305,7 @@ describe('zodResponseFormat', () => {
);
});

it('throws error on nested optional fields', () => {
(version === 'v4' ? it.skip : it)('throws error on nested optional fields', () => {
expect(() =>
zodResponseFormat(
z.object({
Expand Down
Loading
Loading