Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
8f56474
feat: add support for zod@4 schemas
karpetrosyan Oct 1, 2025
7be8d10
add v4 schema support for zodFunction
karpetrosyan Oct 1, 2025
cbf5f81
fixes
karpetrosyan Oct 1, 2025
6b4ae77
Merge branch 'next' into support-zod-v4-schemas
karpetrosyan Oct 2, 2025
a1a4718
review fixes + add zod@4 support for tool functions
karpetrosyan Oct 2, 2025
19713bf
more tests!!
karpetrosyan Oct 2, 2025
747c0df
improve tests
karpetrosyan Oct 2, 2025
284cfe7
fix cast
karpetrosyan Oct 2, 2025
7a98adb
remove name parameter from zodV4ToJsonSchema
karpetrosyan Oct 3, 2025
98e5b8c
Merge branch 'next' into support-zod-v4-schemas
karpetrosyan Oct 3, 2025
a324c80
mention zod3/zod4 support in all eaxmples
karpetrosyan Oct 3, 2025
a532410
chore(internal): use npm pack for build uploads
stainless-app[bot] Oct 6, 2025
f3ab503
Merge branch 'next' into support-zod-v4-schemas
karpetrosyan Oct 7, 2025
946a7b0
Merge branch 'next' into support-zod-v4-schemas
karpetrosyan Oct 16, 2025
d4aaef9
fix(api): internal openapi updates
stainless-app[bot] Oct 17, 2025
2ddb9c9
Merge branch 'next' into support-zod-v4-schemas
karpetrosyan Oct 20, 2025
704ba4c
Merge branch 'next' into support-zod-v4-schemas
karpetrosyan Oct 20, 2025
1192cb2
review fixes
karpetrosyan Oct 23, 2025
646c820
Merge branch 'next' into support-zod-v4-schemas
karpetrosyan Oct 23, 2025
dfed1f0
remove AI traces
karpetrosyan Oct 24, 2025
195a6ed
Update tests/lib/transform.test.ts
karpetrosyan Oct 24, 2025
79a8c53
improve types
RobertCraigie Oct 24, 2025
2fbb1ce
fix doc comment
RobertCraigie Oct 24, 2025
ddeb05e
rename isDict
RobertCraigie Oct 24, 2025
95ce1a9
improve error
RobertCraigie Oct 24, 2025
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
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)
Copy link
Collaborator

Choose a reason for hiding this comment

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

fyi that you should basically never use ts-ignore, instead you should use ts-expect-error so that you're notified if the ignore comment is redundant

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
Contributor 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