-
Notifications
You must be signed in to change notification settings - Fork 1.3k
feat: Add support for zod@4 schemas #1666
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: next
Are you sure you want to change the base?
Changes from 3 commits
8f56474
7be8d10
cbf5f81
6b4ae77
a1a4718
19713bf
747c0df
284cfe7
7a98adb
98e5b8c
a324c80
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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; | ||
} |
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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( | ||
|
@@ -286,7 +290,7 @@ describe('zodResponseFormat', () => { | |
`); | ||
}); | ||
|
||
it('throws error on optional fields', () => { | ||
(version === 'v4' ? it.skip : it)('throws error on optional fields', () => { | ||
|
||
expect(() => | ||
zodResponseFormat( | ||
z.object({ | ||
|
@@ -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({ | ||
|
Uh oh!
There was an error while loading. Please reload this page.