Skip to content

Commit c482776

Browse files
committed
feat: add support for zod@4 schemas
1 parent 94569a0 commit c482776

File tree

4 files changed

+761
-4
lines changed

4 files changed

+761
-4
lines changed

src/helpers/zod.ts

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { ResponseFormatJSONSchema } from '../resources/index';
22
import type { infer as zodInfer, ZodType } from 'zod/v3';
3+
import { toJSONSchema, type infer as zodInferV4, type ZodType as ZodTypeV4 } from 'zod/v4';
34
import {
45
AutoParseableResponseFormat,
56
AutoParseableTextFormat,
@@ -11,6 +12,8 @@ import {
1112
import { zodToJsonSchema as _zodToJsonSchema } from '../_vendor/zod-to-json-schema';
1213
import { AutoParseableResponseTool, makeParseableResponseTool } from '../lib/ResponsesParser';
1314
import { type ResponseFormatTextJSONSchemaConfig } from '../resources/responses/responses';
15+
import { toStrictJsonSchema } from '../lib/transform';
16+
import { JSONSchema } from '../lib/jsonschema';
1417

1518
function zodToJsonSchema(schema: ZodType, options: { name: string }): Record<string, unknown> {
1619
return _zodToJsonSchema(schema, {
@@ -22,6 +25,10 @@ function zodToJsonSchema(schema: ZodType, options: { name: string }): Record<str
2225
});
2326
}
2427

28+
function isZodV4(zodObject: ZodType | ZodTypeV4): zodObject is ZodTypeV4 {
29+
return '_zod' in zodObject;
30+
}
31+
2532
/**
2633
* Creates a chat completion `JSONSchema` response format object from
2734
* the given Zod schema.
@@ -63,15 +70,28 @@ export function zodResponseFormat<ZodInput extends ZodType>(
6370
zodObject: ZodInput,
6471
name: string,
6572
props?: Omit<ResponseFormatJSONSchema.JSONSchema, 'schema' | 'strict' | 'name'>,
66-
): AutoParseableResponseFormat<zodInfer<ZodInput>> {
73+
): AutoParseableResponseFormat<zodInfer<ZodInput>>;
74+
export function zodResponseFormat<ZodInput extends ZodTypeV4>(
75+
zodObject: ZodInput,
76+
name: string,
77+
props?: Omit<ResponseFormatJSONSchema.JSONSchema, 'schema' | 'strict' | 'name'>,
78+
): AutoParseableResponseFormat<zodInferV4<ZodInput>>;
79+
export function zodResponseFormat<ZodInput extends ZodType | ZodTypeV4>(
80+
zodObject: ZodInput,
81+
name: string,
82+
props?: Omit<ResponseFormatJSONSchema.JSONSchema, 'schema' | 'strict' | 'name'>,
83+
): unknown {
6784
return makeParseableResponseFormat(
6885
{
6986
type: 'json_schema',
7087
json_schema: {
7188
...props,
7289
name,
7390
strict: true,
74-
schema: zodToJsonSchema(zodObject, { name }),
91+
schema:
92+
isZodV4(zodObject) ?
93+
(toStrictJsonSchema(toJSONSchema(zodObject) as JSONSchema) as Record<string, unknown>)
94+
: zodToJsonSchema(zodObject, { name }),
7595
},
7696
},
7797
(content) => zodObject.parse(JSON.parse(content)),
@@ -82,14 +102,27 @@ export function zodTextFormat<ZodInput extends ZodType>(
82102
zodObject: ZodInput,
83103
name: string,
84104
props?: Omit<ResponseFormatTextJSONSchemaConfig, 'schema' | 'type' | 'strict' | 'name'>,
85-
): AutoParseableTextFormat<zodInfer<ZodInput>> {
105+
): AutoParseableTextFormat<zodInfer<ZodInput>>;
106+
export function zodTextFormat<ZodInput extends ZodTypeV4>(
107+
zodObject: ZodInput,
108+
name: string,
109+
props?: Omit<ResponseFormatTextJSONSchemaConfig, 'schema' | 'type' | 'strict' | 'name'>,
110+
): AutoParseableTextFormat<zodInferV4<ZodInput>>;
111+
export function zodTextFormat<ZodInput extends ZodType | ZodTypeV4>(
112+
zodObject: ZodInput,
113+
name: string,
114+
props?: Omit<ResponseFormatTextJSONSchemaConfig, 'schema' | 'type' | 'strict' | 'name'>,
115+
): unknown {
86116
return makeParseableTextFormat(
87117
{
88118
type: 'json_schema',
89119
...props,
90120
name,
91121
strict: true,
92-
schema: zodToJsonSchema(zodObject, { name }),
122+
schema:
123+
isZodV4(zodObject) ?
124+
(toStrictJsonSchema(toJSONSchema(zodObject) as JSONSchema) as Record<string, unknown>)
125+
: zodToJsonSchema(zodObject, { name }),
93126
},
94127
(content) => zodObject.parse(JSON.parse(content)),
95128
);

src/lib/jsonschema.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,30 @@ export interface JSONSchema {
131131
oneOf?: JSONSchemaDefinition[] | undefined;
132132
not?: JSONSchemaDefinition | undefined;
133133

134+
/**
135+
* @see https://json-schema.org/draft/2020-12/json-schema-core.html#section-8.2.4
136+
*/
137+
$defs?:
138+
| {
139+
[key: string]: JSONSchemaDefinition;
140+
}
141+
| undefined;
142+
143+
/**
144+
* @deprecated Use $defs instead (draft 2019-09+)
145+
* @see https://tools.ietf.org/doc/html/draft-handrews-json-schema-validation-01#page-22
146+
*/
147+
definitions?:
148+
| {
149+
[key: string]: JSONSchemaDefinition;
150+
}
151+
| undefined;
152+
153+
/**
154+
* @see https://json-schema.org/draft/2020-12/json-schema-core#ref
155+
*/
156+
$ref?: string | undefined;
157+
134158
/**
135159
* @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-7
136160
*/

src/lib/transform.ts

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import type { JSONSchema, JSONSchemaDefinition } from './jsonschema';
2+
3+
export function toStrictJsonSchema(schema: JSONSchema): JSONSchema {
4+
return ensureStrictJsonSchema(schema, [], schema);
5+
}
6+
7+
function ensureStrictJsonSchema(
8+
jsonSchema: JSONSchemaDefinition,
9+
path: string[],
10+
root: JSONSchema,
11+
): JSONSchema {
12+
/**
13+
* Mutates the given JSON schema to ensure it conforms to the `strict` standard
14+
* that the API expects.
15+
*/
16+
if (typeof jsonSchema === 'boolean') {
17+
throw new TypeError(`Expected object schema but got boolean; path=${path.join('/')}`);
18+
}
19+
20+
if (!isDict(jsonSchema)) {
21+
throw new TypeError(`Expected ${JSON.stringify(jsonSchema)} to be a dictionary; path=${path.join('/')}`);
22+
}
23+
24+
// Handle $defs (non-standard but sometimes used)
25+
const defs = (jsonSchema as any).$defs;
26+
if (isDict(defs)) {
27+
for (const [defName, defSchema] of Object.entries(defs)) {
28+
ensureStrictJsonSchema(defSchema, [...path, '$defs', defName], root);
29+
}
30+
}
31+
32+
// Handle definitions (draft-04 style, deprecated in draft-07 but still used)
33+
const definitions = (jsonSchema as any).definitions;
34+
if (isDict(definitions)) {
35+
for (const [definitionName, definitionSchema] of Object.entries(definitions)) {
36+
ensureStrictJsonSchema(definitionSchema, [...path, 'definitions', definitionName], root);
37+
}
38+
}
39+
40+
// Add additionalProperties: false to object types
41+
const typ = jsonSchema.type;
42+
if (typ === 'object' && !('additionalProperties' in jsonSchema)) {
43+
jsonSchema.additionalProperties = false;
44+
}
45+
46+
// Handle object properties
47+
const properties = jsonSchema.properties;
48+
if (isDict(properties)) {
49+
jsonSchema.required = Object.keys(properties);
50+
jsonSchema.properties = Object.fromEntries(
51+
Object.entries(properties).map(([key, propSchema]) => [
52+
key,
53+
ensureStrictJsonSchema(propSchema, [...path, 'properties', key], root),
54+
]),
55+
);
56+
}
57+
58+
// Handle arrays
59+
const items = jsonSchema.items;
60+
if (isDict(items)) {
61+
// @ts-ignore(2345)
62+
jsonSchema.items = ensureStrictJsonSchema(items, [...path, 'items'], root);
63+
}
64+
65+
// Handle unions (anyOf)
66+
const anyOf = jsonSchema.anyOf;
67+
if (Array.isArray(anyOf)) {
68+
jsonSchema.anyOf = anyOf.map((variant, i) =>
69+
ensureStrictJsonSchema(variant, [...path, 'anyOf', String(i)], root),
70+
);
71+
}
72+
73+
// Handle intersections (allOf)
74+
const allOf = jsonSchema.allOf;
75+
if (Array.isArray(allOf)) {
76+
if (allOf.length === 1) {
77+
const resolved = ensureStrictJsonSchema(allOf[0]!, [...path, 'allOf', '0'], root);
78+
Object.assign(jsonSchema, resolved);
79+
delete jsonSchema.allOf;
80+
} else {
81+
jsonSchema.allOf = allOf.map((entry, i) =>
82+
ensureStrictJsonSchema(entry, [...path, 'allOf', String(i)], root),
83+
);
84+
}
85+
}
86+
87+
// Strip `null` defaults as there's no meaningful distinction
88+
if (jsonSchema.default === null) {
89+
delete jsonSchema.default;
90+
}
91+
92+
// Handle $ref with additional properties
93+
const ref = (jsonSchema as any).$ref;
94+
if (ref && hasMoreThanNKeys(jsonSchema, 1)) {
95+
if (typeof ref !== 'string') {
96+
throw new TypeError(`Received non-string $ref - ${ref}`);
97+
}
98+
99+
const resolved = resolveRef(root, ref);
100+
if (typeof resolved === 'boolean') {
101+
throw new ValueError(`Expected \`$ref: ${ref}\` to resolve to an object schema but got boolean`);
102+
}
103+
if (!isDict(resolved)) {
104+
throw new ValueError(
105+
`Expected \`$ref: ${ref}\` to resolve to a dictionary but got ${JSON.stringify(resolved)}`,
106+
);
107+
}
108+
109+
// Properties from the json schema take priority over the ones on the `$ref`
110+
Object.assign(jsonSchema, { ...resolved, ...jsonSchema });
111+
delete (jsonSchema as any).$ref;
112+
113+
// Since the schema expanded from `$ref` might not have `additionalProperties: false` applied,
114+
// we call `ensureStrictJsonSchema` again to fix the inlined schema and ensure it's valid.
115+
return ensureStrictJsonSchema(jsonSchema, path, root);
116+
}
117+
118+
return jsonSchema;
119+
}
120+
121+
function resolveRef(root: JSONSchema, ref: string): JSONSchemaDefinition {
122+
if (!ref.startsWith('#/')) {
123+
throw new ValueError(`Unexpected $ref format ${JSON.stringify(ref)}; Does not start with #/`);
124+
}
125+
126+
const pathParts = ref.slice(2).split('/');
127+
let resolved: any = root;
128+
129+
for (const key of pathParts) {
130+
if (!isDict(resolved)) {
131+
throw new Error(
132+
`encountered non-dictionary entry while resolving ${ref} - ${JSON.stringify(resolved)}`,
133+
);
134+
}
135+
const value = resolved[key];
136+
if (value === undefined) {
137+
throw new Error(`Key ${key} not found while resolving ${ref}`);
138+
}
139+
resolved = value;
140+
}
141+
142+
return resolved;
143+
}
144+
145+
function isDict(obj: any): obj is Record<string, any> {
146+
return typeof obj === 'object' && obj !== null && !Array.isArray(obj);
147+
}
148+
149+
function hasMoreThanNKeys(obj: Record<string, any>, n: number): boolean {
150+
let i = 0;
151+
for (const _ in obj) {
152+
i++;
153+
if (i > n) {
154+
return true;
155+
}
156+
}
157+
return false;
158+
}
159+
160+
class ValueError extends Error {
161+
constructor(message: string) {
162+
super(message);
163+
this.name = 'ValueError';
164+
}
165+
}

0 commit comments

Comments
 (0)