diff --git a/examples/parsing-run-tools.ts b/examples/parsing-run-tools.ts index f63758e3b..a8919f1e7 100644 --- a/examples/parsing-run-tools.ts +++ b/examples/parsing-run-tools.ts @@ -1,5 +1,5 @@ import OpenAI from 'openai'; -import z from 'zod/v3'; +import z from 'zod/v4'; // Also works for 'zod/v3' import { zodFunction } from 'openai/helpers/zod'; const Table = z.enum(['orders', 'customers', 'products']); diff --git a/examples/parsing-stream.ts b/examples/parsing-stream.ts index 9a7e9863b..4bac67006 100644 --- a/examples/parsing-stream.ts +++ b/examples/parsing-stream.ts @@ -1,6 +1,6 @@ import { zodResponseFormat } from 'openai/helpers/zod'; import OpenAI from 'openai/index'; -import { z } from 'zod/v3'; +import { z } from 'zod/v4'; // Also works for 'zod/v3' const Step = z.object({ explanation: z.string(), diff --git a/examples/parsing-tools-stream.ts b/examples/parsing-tools-stream.ts index 54e97df7a..1d6cd115c 100644 --- a/examples/parsing-tools-stream.ts +++ b/examples/parsing-tools-stream.ts @@ -1,6 +1,6 @@ import { zodFunction } from 'openai/helpers/zod'; import OpenAI from 'openai/index'; -import { z } from 'zod/v3'; +import { z } from 'zod/v4'; // Also works for 'zod/v3' const GetWeatherArgs = z.object({ city: z.string(), diff --git a/examples/parsing-tools.ts b/examples/parsing-tools.ts index 4fd466395..164283615 100644 --- a/examples/parsing-tools.ts +++ b/examples/parsing-tools.ts @@ -1,6 +1,6 @@ import { zodFunction } from 'openai/helpers/zod'; import OpenAI from 'openai/index'; -import { z } from 'zod/v3'; +import { z } from 'zod/v4'; // Also works for 'zod/v3' const Table = z.enum(['orders', 'customers', 'products']); diff --git a/examples/parsing.ts b/examples/parsing.ts index 1290b074c..dead0895e 100644 --- a/examples/parsing.ts +++ b/examples/parsing.ts @@ -1,6 +1,6 @@ import { zodResponseFormat } from 'openai/helpers/zod'; import OpenAI from 'openai/index'; -import { z } from 'zod/v3'; +import { z } from 'zod/v4'; // Also works for 'zod/v3' const Step = z.object({ explanation: z.string(), diff --git a/examples/responses/streaming-tools.ts b/examples/responses/streaming-tools.ts index b62a4edc0..838e87bc2 100755 --- a/examples/responses/streaming-tools.ts +++ b/examples/responses/streaming-tools.ts @@ -2,7 +2,7 @@ import { OpenAI } from 'openai'; import { zodResponsesFunction } from 'openai/helpers/zod'; -import { z } from 'zod/v3'; +import { z } from 'zod/v4'; // Also works for 'zod/v3' const Table = z.enum(['orders', 'customers', 'products']); const Column = z.enum([ diff --git a/examples/responses/structured-outputs-tools.ts b/examples/responses/structured-outputs-tools.ts index 9605fd6eb..4687df49c 100755 --- a/examples/responses/structured-outputs-tools.ts +++ b/examples/responses/structured-outputs-tools.ts @@ -2,7 +2,7 @@ import { OpenAI } from 'openai'; import { zodResponsesFunction } from 'openai/helpers/zod'; -import { z } from 'zod/v3'; +import { z } from 'zod/v4'; // Also works for 'zod/v3' const Table = z.enum(['orders', 'customers', 'products']); const Column = z.enum([ diff --git a/examples/responses/structured-outputs.ts b/examples/responses/structured-outputs.ts index e1de6f219..2defb58a9 100755 --- a/examples/responses/structured-outputs.ts +++ b/examples/responses/structured-outputs.ts @@ -2,7 +2,7 @@ import { OpenAI } from 'openai'; import { zodTextFormat } from 'openai/helpers/zod'; -import { z } from 'zod/v3'; +import { z } from 'zod/v4'; // Also works for 'zod/v3' const Step = z.object({ explanation: z.string(), diff --git a/examples/tool-call-helpers-zod.ts b/examples/tool-call-helpers-zod.ts index 162b54946..297171dde 100755 --- a/examples/tool-call-helpers-zod.ts +++ b/examples/tool-call-helpers-zod.ts @@ -2,7 +2,7 @@ import OpenAI from 'openai'; import { zodFunction } from 'openai/helpers/zod'; -import { z } from 'zod/v3'; +import { z } from 'zod/v4'; // Also works for 'zod/v3' // gets API Key from environment variable OPENAI_API_KEY const openai = new OpenAI(); diff --git a/examples/ui-generation.ts b/examples/ui-generation.ts index 4e61e1f17..e1c7cd2de 100644 --- a/examples/ui-generation.ts +++ b/examples/ui-generation.ts @@ -1,5 +1,5 @@ import OpenAI from 'openai'; -import { z } from 'zod/v3'; +import { z } from 'zod/v4'; // Also works for 'zod/v3' import { zodResponseFormat } from 'openai/helpers/zod'; const openai = new OpenAI(); diff --git a/package.json b/package.json index b6e72b207..a489e0bb3 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@types/node": "^20.17.6", "@typescript-eslint/eslint-plugin": "8.31.1", "@typescript-eslint/parser": "8.31.1", + "deep-object-diff": "^1.1.9", "eslint": "^9.20.1", "eslint-plugin-prettier": "^5.4.1", "eslint-plugin-unused-imports": "^4.1.4", diff --git a/src/helpers/zod.ts b/src/helpers/zod.ts index 6495cc99d..4225e0759 100644 --- a/src/helpers/zod.ts +++ b/src/helpers/zod.ts @@ -1,5 +1,6 @@ import { ResponseFormatJSONSchema } from '../resources/index'; -import type { infer as zodInfer, ZodType } from 'zod/v3'; +import { z as z3 } from 'zod/v3'; +import { z as z4 } from 'zod/v4'; import { AutoParseableResponseFormat, AutoParseableTextFormat, @@ -11,8 +12,15 @@ 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 { +type InferZodType = + T extends z4.ZodType ? z4.infer + : T extends z3.ZodType ? z3.infer + : never; + +function zodV3ToJsonSchema(schema: z3.ZodType, options: { name: string }): Record { return _zodToJsonSchema(schema, { openaiStrictMode: true, name: options.name, @@ -22,6 +30,18 @@ function zodToJsonSchema(schema: ZodType, options: { name: string }): Record { + return toStrictJsonSchema( + z4.toJSONSchema(schema, { + target: 'draft-7', + }) as JSONSchema, + ) as Record; +} + +function isZodV4(zodObject: z3.ZodType | z4.ZodType): zodObject is z4.ZodType { + return '_zod' in zodObject; +} + /** * Creates a chat completion `JSONSchema` response format object from * the given Zod schema. @@ -59,11 +79,11 @@ function zodToJsonSchema(schema: ZodType, options: { name: string }): Record( +export function zodResponseFormat( zodObject: ZodInput, name: string, props?: Omit, -): AutoParseableResponseFormat> { +): AutoParseableResponseFormat> { return makeParseableResponseFormat( { type: 'json_schema', @@ -71,25 +91,25 @@ export function zodResponseFormat( ...props, name, strict: true, - schema: zodToJsonSchema(zodObject, { name }), + schema: isZodV4(zodObject) ? zodV4ToJsonSchema(zodObject) : zodV3ToJsonSchema(zodObject, { name }), }, }, (content) => zodObject.parse(JSON.parse(content)), ); } -export function zodTextFormat( +export function zodTextFormat( zodObject: ZodInput, name: string, props?: Omit, -): AutoParseableTextFormat> { +): AutoParseableTextFormat> { return makeParseableTextFormat( { type: 'json_schema', ...props, name, strict: true, - schema: zodToJsonSchema(zodObject, { name }), + schema: isZodV4(zodObject) ? zodV4ToJsonSchema(zodObject) : zodV3ToJsonSchema(zodObject, { name }), }, (content) => zodObject.parse(JSON.parse(content)), ); @@ -100,15 +120,15 @@ export function zodTextFormat( * automatically by the chat completion `.runTools()` method or automatically * parsed by `.parse()` / `.stream()`. */ -export function zodFunction(options: { +export function zodFunction(options: { name: string; parameters: Parameters; - function?: ((args: zodInfer) => unknown | Promise) | undefined; + function?: ((args: InferZodType) => unknown | Promise) | undefined; description?: string | undefined; }): AutoParseableTool<{ arguments: Parameters; name: string; - function: (args: zodInfer) => unknown; + function: (args: InferZodType) => unknown; }> { // @ts-expect-error TODO return makeParseableTool( @@ -116,7 +136,10 @@ export function zodFunction(options: { type: 'function', function: { name: options.name, - parameters: zodToJsonSchema(options.parameters, { name: options.name }), + parameters: + isZodV4(options.parameters) ? + zodV4ToJsonSchema(options.parameters) + : zodV3ToJsonSchema(options.parameters, { name: options.name }), strict: true, ...(options.description ? { description: options.description } : undefined), }, @@ -128,21 +151,24 @@ export function zodFunction(options: { ); } -export function zodResponsesFunction(options: { +export function zodResponsesFunction(options: { name: string; parameters: Parameters; - function?: ((args: zodInfer) => unknown | Promise) | undefined; + function?: ((args: InferZodType) => unknown | Promise) | undefined; description?: string | undefined; }): AutoParseableResponseTool<{ arguments: Parameters; name: string; - function: (args: zodInfer) => unknown; + function: (args: InferZodType) => unknown; }> { return makeParseableResponseTool( { type: 'function', name: options.name, - parameters: zodToJsonSchema(options.parameters, { name: options.name }), + parameters: + isZodV4(options.parameters) ? + zodV4ToJsonSchema(options.parameters) + : zodV3ToJsonSchema(options.parameters, { name: options.name }), strict: true, ...(options.description ? { description: options.description } : undefined), }, diff --git a/src/lib/jsonschema.ts b/src/lib/jsonschema.ts index 636277705..45eda0527 100644 --- a/src/lib/jsonschema.ts +++ b/src/lib/jsonschema.ts @@ -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 */ diff --git a/src/lib/transform.ts b/src/lib/transform.ts new file mode 100644 index 000000000..d241c215c --- /dev/null +++ b/src/lib/transform.ts @@ -0,0 +1,193 @@ +import type { JSONSchema, JSONSchemaDefinition } from './jsonschema'; + +export function toStrictJsonSchema(schema: JSONSchema): JSONSchema { + if (schema.type !== 'object') { + throw new Error( + `Root schema must have type: 'object' but got type: ${schema.type ? `'${schema.type}'` : 'undefined'}`, + ); + } + + const schemaCopy = structuredClone(schema); + return ensureStrictJsonSchema(schemaCopy, [], schemaCopy); +} + +function isNullable(schema: JSONSchemaDefinition): boolean { + if (typeof schema === 'boolean') { + return false; + } + if (schema.type === 'null') { + return true; + } + for (const oneOfVariant of schema.oneOf ?? []) { + if (isNullable(oneOfVariant)) { + return true; + } + } + for (const allOfVariant of schema.anyOf ?? []) { + if (isNullable(allOfVariant)) { + return true; + } + } + return false; +} + +/** + * Mutates the given JSON schema to ensure it conforms to the `strict` standard + * that the API expects. + */ +function ensureStrictJsonSchema( + jsonSchema: JSONSchemaDefinition, + path: string[], + root: JSONSchema, +): JSONSchema { + if (typeof jsonSchema === 'boolean') { + throw new TypeError(`Expected object schema but got boolean; path=${path.join('/')}`); + } + + if (!isObject(jsonSchema)) { + throw new TypeError(`Expected ${JSON.stringify(jsonSchema)} to be an object; path=${path.join('/')}`); + } + + // Handle $defs (non-standard but sometimes used) + const defs = (jsonSchema as any).$defs; + if (isObject(defs)) { + for (const [defName, defSchema] of Object.entries(defs)) { + ensureStrictJsonSchema(defSchema as JSONSchema, [...path, '$defs', defName], root); + } + } + + // Handle definitions (draft-04 style, deprecated in draft-07 but still used) + const definitions = (jsonSchema as any).definitions; + if (isObject(definitions)) { + for (const [definitionName, definitionSchema] of Object.entries(definitions)) { + ensureStrictJsonSchema(definitionSchema as JSONSchema, [...path, 'definitions', definitionName], root); + } + } + + // Add additionalProperties: false to object types + const typ = jsonSchema.type; + if (typ === 'object' && !('additionalProperties' in jsonSchema)) { + jsonSchema.additionalProperties = false; + } + + const required = jsonSchema.required ?? []; + + // Handle object properties + const properties = jsonSchema.properties; + if (isObject(properties)) { + for (const [key, value] of Object.entries(properties)) { + if (!isNullable(value) && !required.includes(key)) { + throw new Error( + `Zod field at \`${[...path, 'properties', key].join( + '/', + )}\` uses \`.optional()\` without \`.nullable()\` which is not supported by the API. See: https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses#all-fields-must-be-required`, + ); + } + } + 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 (isObject(items)) { + 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}; path=${path.join('/')}`); + } + + 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 (!isObject(resolved)) { + throw new Error( + `Expected \`$ref: ${ref}\` to resolve to an object 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 (!isObject(resolved)) { + throw new Error(`encountered non-object 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 isObject(obj: T | Array): obj is Extract> { + return typeof obj === 'object' && obj !== null && !Array.isArray(obj); +} + +function hasMoreThanNKeys(obj: Record, n: number): boolean { + let i = 0; + for (const _ in obj) { + i++; + if (i > n) { + return true; + } + } + return false; +} diff --git a/tests/helpers/zod.test.ts b/tests/helpers/zod.test.ts index 62a8e9162..1bc766376 100644 --- a/tests/helpers/zod.test.ts +++ b/tests/helpers/zod.test.ts @@ -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/v4'; -describe('zodResponseFormat', () => { +describe.each([ + { version: 'v3', z: zv3 }, + { version: 'v4', z: zv4 as any as typeof zv3 }, +])('zodResponseFormat (Zod $version)', ({ version, z }) => { it('does the thing', () => { expect( zodResponseFormat( @@ -287,31 +291,59 @@ describe('zodResponseFormat', () => { }); it('throws error on optional fields', () => { - expect(() => - zodResponseFormat( - z.object({ - required: z.string(), - optional: z.string().optional(), - optional_and_nullable: z.string().optional().nullable(), - }), - 'schema', - ), - ).toThrowErrorMatchingInlineSnapshot( - `"Zod field at \`#/definitions/schema/properties/optional\` uses \`.optional()\` without \`.nullable()\` which is not supported by the API. See: https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses#all-fields-must-be-required"`, - ); + if (version === 'v3') { + expect(() => + zodResponseFormat( + z.object({ + required: z.string(), + optional: z.string().optional(), + optional_and_nullable: z.string().optional().nullable(), + }), + 'schema', + ), + ).toThrowErrorMatchingInlineSnapshot( + `"Zod field at \`#/definitions/schema/properties/optional\` uses \`.optional()\` without \`.nullable()\` which is not supported by the API. See: https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses#all-fields-must-be-required"`, + ); + } else { + expect(() => + zodResponseFormat( + z.object({ + required: z.string(), + optional: z.string().optional(), + optional_and_nullable: z.string().optional().nullable(), + }), + 'schema', + ), + ).toThrowErrorMatchingInlineSnapshot( + `"Zod field at \`properties/optional\` uses \`.optional()\` without \`.nullable()\` which is not supported by the API. See: https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses#all-fields-must-be-required"`, + ); + } }); it('throws error on nested optional fields', () => { - expect(() => - zodResponseFormat( - z.object({ - foo: z.object({ bar: z.array(z.object({ can_be_missing: z.boolean().optional() })) }), - }), - 'schema', - ), - ).toThrowErrorMatchingInlineSnapshot( - `"Zod field at \`#/definitions/schema/properties/foo/properties/bar/items/properties/can_be_missing\` uses \`.optional()\` without \`.nullable()\` which is not supported by the API. See: https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses#all-fields-must-be-required"`, - ); + if (version === 'v3') { + expect(() => + zodResponseFormat( + z.object({ + foo: z.object({ bar: z.array(z.object({ can_be_missing: z.boolean().optional() })) }), + }), + 'schema', + ), + ).toThrowErrorMatchingInlineSnapshot( + `"Zod field at \`#/definitions/schema/properties/foo/properties/bar/items/properties/can_be_missing\` uses \`.optional()\` without \`.nullable()\` which is not supported by the API. See: https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses#all-fields-must-be-required"`, + ); + } else { + expect(() => + zodResponseFormat( + z.object({ + foo: z.object({ bar: z.array(z.object({ can_be_missing: z.boolean().optional() })) }), + }), + 'schema', + ), + ).toThrowErrorMatchingInlineSnapshot( + `"Zod field at \`properties/foo/properties/bar/items/properties/can_be_missing\` uses \`.optional()\` without \`.nullable()\` which is not supported by the API. See: https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses#all-fields-must-be-required"`, + ); + } }); it('does not warn on union nullable fields', () => { diff --git a/tests/lib/ChatCompletionStream.test.ts b/tests/lib/ChatCompletionStream.test.ts index 790736bc0..444b144c3 100644 --- a/tests/lib/ChatCompletionStream.test.ts +++ b/tests/lib/ChatCompletionStream.test.ts @@ -1,6 +1,6 @@ import { zodResponseFormat } from 'openai/helpers/zod'; import { ChatCompletionTokenLogprob } from 'openai/resources'; -import { z } from 'zod/v3'; +import { z } from 'zod/v4'; import { makeStreamSnapshotRequest } from '../utils/mock-snapshots'; jest.setTimeout(1000 * 30); diff --git a/tests/lib/parser.test.ts b/tests/lib/parser.test.ts index 70d3ec8c6..1aa33acf0 100644 --- a/tests/lib/parser.test.ts +++ b/tests/lib/parser.test.ts @@ -1,10 +1,14 @@ -import { z } from 'zod/v3'; +import { z as z4 } from 'zod/v4'; +import { z as z3 } from 'zod/v3'; import { zodResponseFormat } from 'openai/helpers/zod'; import { makeSnapshotRequest } from '../utils/mock-snapshots'; jest.setTimeout(1000 * 30); -describe('.parse()', () => { +describe.each([ + { version: 'v3', z: z3 }, + { version: 'v4', z: z4 as any as typeof z3 }, +])('.parse()', ({ z, version }) => { describe('zod', () => { it('deserialises response_format', async () => { const completion = await makeSnapshotRequest((openai) => @@ -156,7 +160,8 @@ describe('.parse()', () => { } `); - expect(zodResponseFormat(UI, 'ui').json_schema).toMatchInlineSnapshot(` + if (version === 'v3') { + expect(zodResponseFormat(UI, 'ui').json_schema).toMatchInlineSnapshot(` { "name": "ui", "schema": { @@ -267,6 +272,66 @@ describe('.parse()', () => { "strict": true, } `); + } else { + expect(zodResponseFormat(UI, 'ui').json_schema).toMatchInlineSnapshot(` +{ + "name": "ui", + "schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "properties": { + "attributes": { + "items": { + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + }, + "value": { + "type": "string", + }, + }, + "required": [ + "name", + "value", + ], + "type": "object", + }, + "type": "array", + }, + "children": { + "items": { + "$ref": "#", + }, + "type": "array", + }, + "label": { + "type": "string", + }, + "type": { + "enum": [ + "div", + "button", + "header", + "section", + "field", + "form", + ], + "type": "string", + }, + }, + "required": [ + "type", + "label", + "children", + "attributes", + ], + "type": "object", + }, + "strict": true, +} +`); + } }); test('merged schemas', async () => { @@ -294,8 +359,9 @@ describe('.parse()', () => { ), }); - expect(zodResponseFormat(contactPersonSchema, 'contactPerson').json_schema.schema) - .toMatchInlineSnapshot(` + if (version === 'v3') { + expect(zodResponseFormat(contactPersonSchema, 'contactPerson').json_schema.schema) + .toMatchInlineSnapshot(` { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, @@ -445,6 +511,100 @@ describe('.parse()', () => { "type": "object", } `); + } else { + expect(zodResponseFormat(contactPersonSchema, 'contactPerson').json_schema.schema) + .toMatchInlineSnapshot(` +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "properties": { + "person1": { + "additionalProperties": false, + "properties": { + "description": { + "anyOf": [ + { + "type": "string", + }, + { + "type": "null", + }, + ], + "description": "Open text for any other relevant information about what the contact does.", + }, + "name": { + "type": "string", + }, + "phone_number": { + "anyOf": [ + { + "type": "string", + }, + { + "type": "null", + }, + ], + }, + "roles": { + "description": "Any roles for which the contact is important, use other for custom roles", + "items": { + "enum": [ + "parent", + "child", + "sibling", + "spouse", + "friend", + "other", + ], + "type": "string", + }, + "type": "array", + }, + }, + "required": [ + "name", + "phone_number", + "roles", + "description", + ], + "type": "object", + }, + "person2": { + "additionalProperties": false, + "properties": { + "differentField": { + "type": "string", + }, + "name": { + "type": "string", + }, + "phone_number": { + "anyOf": [ + { + "type": "string", + }, + { + "type": "null", + }, + ], + }, + }, + "required": [ + "name", + "phone_number", + "differentField", + ], + "type": "object", + }, + }, + "required": [ + "person1", + "person2", + ], + "type": "object", +} +`); + } const completion = await makeSnapshotRequest( (openai) => @@ -517,7 +677,8 @@ describe('.parse()', () => { fields: z.array(z.union([fieldA, fieldB])), }); - expect(zodResponseFormat(model, 'query').json_schema.schema).toMatchInlineSnapshot(` + if (version === 'v3') { + expect(zodResponseFormat(model, 'query').json_schema.schema).toMatchInlineSnapshot(` { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, @@ -695,6 +856,101 @@ describe('.parse()', () => { "type": "object", } `); + } else { + expect(zodResponseFormat(model, 'query').json_schema.schema).toMatchInlineSnapshot(` +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "properties": { + "fields": { + "items": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "metadata": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "foo": { + "type": "string", + }, + }, + "required": [ + "foo", + ], + "type": "object", + }, + { + "type": "null", + }, + ], + }, + "name": { + "type": "string", + }, + "type": { + "const": "string", + "type": "string", + }, + }, + "required": [ + "type", + "name", + "metadata", + ], + "type": "object", + }, + { + "additionalProperties": false, + "properties": { + "metadata": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "foo": { + "type": "string", + }, + }, + "required": [ + "foo", + ], + "type": "object", + }, + { + "type": "null", + }, + ], + }, + "type": { + "const": "number", + "type": "string", + }, + }, + "required": [ + "type", + "metadata", + ], + "type": "object", + }, + ], + }, + "type": "array", + }, + "name": { + "type": "string", + }, + }, + "required": [ + "name", + "fields", + ], + "type": "object", +} +`); + } const completion = await makeSnapshotRequest( (openai) => @@ -792,10 +1048,12 @@ describe('.parse()', () => { const baseLinkedListNodeSchema = z.object({ value: z.number(), }); - type LinkedListNode = z.infer & { + + type LinkedListNode = z3.infer & { next: LinkedListNode | null; }; - const linkedListNodeSchema: z.ZodType = baseLinkedListNodeSchema.extend({ + + const linkedListNodeSchema: z3.ZodType = baseLinkedListNodeSchema.extend({ next: z.lazy(() => z.union([linkedListNodeSchema, z.null()])), }); @@ -804,7 +1062,8 @@ describe('.parse()', () => { linked_list: linkedListNodeSchema, }); - expect(zodResponseFormat(mainSchema, 'query').json_schema.schema).toMatchInlineSnapshot(` + if (version === 'v3') { + expect(zodResponseFormat(mainSchema, 'query').json_schema.schema).toMatchInlineSnapshot(` { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, @@ -902,6 +1161,48 @@ describe('.parse()', () => { "type": "object", } `); + } else { + expect(zodResponseFormat(mainSchema, 'query').json_schema.schema).toMatchInlineSnapshot(` +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "__schema0": { + "additionalProperties": false, + "properties": { + "next": { + "anyOf": [ + { + "$ref": "#/definitions/__schema0", + }, + { + "type": "null", + }, + ], + }, + "value": { + "type": "number", + }, + }, + "required": [ + "value", + "next", + ], + "type": "object", + }, + }, + "properties": { + "linked_list": { + "$ref": "#/definitions/__schema0", + }, + }, + "required": [ + "linked_list", + ], + "type": "object", +} +`); + } const completion = await makeSnapshotRequest( (openai) => @@ -948,16 +1249,21 @@ describe('.parse()', () => { }); test('ref schemas with `.transform()`', async () => { - const Inner = z.object({ - baz: z.boolean().transform((v) => v ?? true), + let Inner = z.object({ + baz: + version === 'v3' ? + z.boolean().transform((v: any) => v ?? true) + : z + .boolean() + .transform((v: any) => v ?? true) + .pipe(z.boolean()), }); - const Outer = z.object({ first: Inner, second: Inner, }); - - expect(zodResponseFormat(Outer, 'data').json_schema.schema).toMatchInlineSnapshot(` + if (version === 'v3') { + expect(zodResponseFormat(Outer, 'data').json_schema.schema).toMatchInlineSnapshot(` { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, @@ -1027,6 +1333,45 @@ describe('.parse()', () => { "type": "object", } `); + } else { + expect(zodResponseFormat(Outer, 'data').json_schema.schema).toMatchInlineSnapshot(` +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "properties": { + "first": { + "additionalProperties": false, + "properties": { + "baz": { + "type": "boolean", + }, + }, + "required": [ + "baz", + ], + "type": "object", + }, + "second": { + "additionalProperties": false, + "properties": { + "baz": { + "type": "boolean", + }, + }, + "required": [ + "baz", + ], + "type": "object", + }, + }, + "required": [ + "first", + "second", + ], + "type": "object", +} +`); + } const completion = await makeSnapshotRequest( (openai) => diff --git a/tests/lib/transform.test.ts b/tests/lib/transform.test.ts new file mode 100644 index 000000000..87195d998 --- /dev/null +++ b/tests/lib/transform.test.ts @@ -0,0 +1,536 @@ +import { toStrictJsonSchema } from 'openai/lib/transform'; +import { detailedDiff } from 'deep-object-diff'; +import { JSONSchema } from 'openai/lib/jsonschema'; + +describe('toStrictJsonSchema', () => { + describe('Root Schema Validation', () => { + test('throws error if root schema is not an object', () => { + const schema: any = { type: 'string' }; + + expect(() => toStrictJsonSchema(schema)).toThrow( + "Root schema must have type: 'object' but got type: 'string'", + ); + }); + + test('throws error if root schema has no type', () => { + const schema: any = { properties: {} }; + + expect(() => toStrictJsonSchema(schema)).toThrow( + "Root schema must have type: 'object' but got type: undefined", + ); + }); + }); + + describe('Additional Properties', () => { + test('adds additionalProperties: false to object schemas', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + name: { type: 'string' }, + }, + required: ['name'], + }; + + const strict = toStrictJsonSchema(schema); + const diff = detailedDiff(schema, strict); + + expect(diff).toMatchInlineSnapshot(` + { + "added": { + "additionalProperties": false, + }, + "deleted": {}, + "updated": {}, + } + `); + }); + + test('preserves existing additionalProperties value', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + name: { type: 'string' }, + }, + required: ['name'], + additionalProperties: true, + }; + + const strict = toStrictJsonSchema(schema); + const diff = detailedDiff(schema, strict); + + expect(diff).toMatchInlineSnapshot(` + { + "added": {}, + "deleted": {}, + "updated": {}, + } + `); + }); + + test('adds additionalProperties: false to nested objects', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + user: { + type: 'object', + properties: { + name: { type: 'string' }, + }, + required: ['name'], + }, + }, + required: ['user'], + }; + + const strict = toStrictJsonSchema(schema); + const diff = detailedDiff(schema, strict); + + expect(diff).toMatchInlineSnapshot(` + { + "added": { + "additionalProperties": false, + "properties": { + "user": { + "additionalProperties": false, + }, + }, + }, + "deleted": {}, + "updated": {}, + } + `); + }); + }); + + describe('Required Properties', () => { + test('makes all properties required when nullable', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + name: { anyOf: [{ type: 'string' }, { type: 'null' }] }, + age: { anyOf: [{ type: 'number' }, { type: 'null' }] }, + }, + }; + + const strict = toStrictJsonSchema(schema); + const diff = detailedDiff(schema, strict); + + expect(diff).toMatchInlineSnapshot(` + { + "added": { + "additionalProperties": false, + "required": [ + "name", + "age", + ], + }, + "deleted": {}, + "updated": {}, + } + `); + }); + + test('throws error for optional properties without nullable', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + name: { type: 'string' }, + }, + }; + + expect(() => toStrictJsonSchema(schema)).toThrow( + 'Zod field at `properties/name` uses `.optional()` without `.nullable()` which is not supported by the API', + ); + }); + }); + + describe('Nested Schemas', () => { + test('processes nested object properties', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + address: { + type: 'object', + properties: { + street: { type: 'string' }, + city: { anyOf: [{ type: 'string' }, { type: 'null' }] }, + }, + required: ['street'], + }, + }, + required: ['address'], + }; + + const strict = toStrictJsonSchema(schema); + const diff = detailedDiff(schema, strict); + + expect(diff).toMatchInlineSnapshot(` + { + "added": { + "additionalProperties": false, + "properties": { + "address": { + "additionalProperties": false, + "required": { + "1": "city", + }, + }, + }, + }, + "deleted": {}, + "updated": {}, + } + `); + }); + + test('processes array items', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + tags: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + }, + required: ['name'], + }, + }, + }, + required: ['tags'], + }; + + const strict = toStrictJsonSchema(schema); + const diff = detailedDiff(schema, strict); + + expect(diff).toMatchInlineSnapshot(` + { + "added": { + "additionalProperties": false, + "properties": { + "tags": { + "items": { + "additionalProperties": false, + }, + }, + }, + }, + "deleted": {}, + "updated": {}, + } + `); + }); + }); + + describe('anyOf Handling', () => { + test('processes anyOf variants', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + value: { + anyOf: [ + { + type: 'object', + properties: { num: { type: 'number' } }, + required: ['num'], + }, + { + type: 'object', + properties: { str: { type: 'string' } }, + required: ['str'], + }, + ], + }, + }, + required: ['value'], + }; + + const strict = toStrictJsonSchema(schema); + const diff = detailedDiff(schema, strict); + + expect(diff).toMatchInlineSnapshot(` + { + "added": { + "additionalProperties": false, + "properties": { + "value": { + "anyOf": { + "0": { + "additionalProperties": false, + }, + "1": { + "additionalProperties": false, + }, + }, + }, + }, + }, + "deleted": {}, + "updated": {}, + } + `); + }); + }); + + describe('allOf Handling', () => { + test('inlines single allOf variant', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + value: { + allOf: [ + { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + }, + ], + }, + }, + required: ['value'], + }; + + const strict = toStrictJsonSchema(schema); + const diff = detailedDiff(schema, strict); + + expect(diff).toMatchInlineSnapshot(` + { + "added": { + "additionalProperties": false, + "properties": { + "value": { + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + }, + }, + "required": [ + "name", + ], + "type": "object", + }, + }, + }, + "deleted": { + "properties": { + "value": { + "allOf": undefined, + }, + }, + }, + "updated": {}, + } + `); + }); + + test('processes multiple allOf variants', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + value: { + allOf: [ + { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + }, + { + type: 'object', + properties: { age: { type: 'number' } }, + required: ['age'], + }, + ], + }, + }, + required: ['value'], + }; + + const strict = toStrictJsonSchema(schema); + const diff = detailedDiff(schema, strict); + + expect(diff).toMatchInlineSnapshot(` + { + "added": { + "additionalProperties": false, + "properties": { + "value": { + "allOf": { + "0": { + "additionalProperties": false, + }, + "1": { + "additionalProperties": false, + }, + }, + }, + }, + }, + "deleted": {}, + "updated": {}, + } + `); + }); + }); + + describe('$ref Resolution', () => { + test('processes definitions', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + user: { $ref: '#/definitions/User' }, + }, + required: ['user'], + definitions: { + User: { + type: 'object', + properties: { + name: { type: 'string' }, + }, + required: ['name'], + }, + }, + }; + + const strict = toStrictJsonSchema(schema); + const diff = detailedDiff(schema, strict); + + expect(diff).toMatchInlineSnapshot(` + { + "added": { + "additionalProperties": false, + "definitions": { + "User": { + "additionalProperties": false, + }, + }, + }, + "deleted": {}, + "updated": {}, + } + `); + }); + }); + + describe('$defs and definitions', () => { + test('processes $defs schemas', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + user: { $ref: '#/$defs/User' }, + }, + required: ['user'], + $defs: { + User: { + type: 'object', + properties: { + name: { type: 'string' }, + }, + required: ['name'], + }, + }, + }; + + const strict = toStrictJsonSchema(schema); + const diff = detailedDiff(schema, strict); + + expect(diff).toMatchInlineSnapshot(` +{ + "added": { + "$defs": { + "User": { + "additionalProperties": false, + }, + }, + "additionalProperties": false, + }, + "deleted": {}, + "updated": {}, +} +`); + }); + + test('processes definitions schemas', () => { + const schema: JSONSchema = { + type: 'object', + properties: {}, + definitions: { + User: { + type: 'object', + properties: { + name: { type: 'string' }, + }, + required: ['name'], + }, + }, + }; + + const strict = toStrictJsonSchema(schema); + const diff = detailedDiff(schema, strict); + + expect(diff).toMatchInlineSnapshot(` + { + "added": { + "additionalProperties": false, + "definitions": { + "User": { + "additionalProperties": false, + }, + }, + "required": [], + }, + "deleted": {}, + "updated": {}, + } + `); + }); + }); + + describe('Complex Scenarios', () => { + test('handles deeply nested schemas', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + level1: { + type: 'object', + properties: { + level2: { + type: 'object', + properties: { + name: { type: 'string' }, + }, + required: ['name'], + }, + }, + required: ['level2'], + }, + }, + required: ['level1'], + }; + + const strict = toStrictJsonSchema(schema); + const diff = detailedDiff(schema, strict); + + expect(diff).toMatchInlineSnapshot(` + { + "added": { + "additionalProperties": false, + "properties": { + "level1": { + "additionalProperties": false, + "properties": { + "level2": { + "additionalProperties": false, + }, + }, + }, + }, + }, + "deleted": {}, + "updated": {}, + } + `); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 6c1e8b378..fa13be2ed 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1482,6 +1482,11 @@ deep-is@^0.1.3: resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== +deep-object-diff@^1.1.9: + version "1.1.9" + resolved "https://registry.yarnpkg.com/deep-object-diff/-/deep-object-diff-1.1.9.tgz#6df7ef035ad6a0caa44479c536ed7b02570f4595" + integrity sha512-Rn+RuwkmkDwCi2/oXOFS9Gsr5lJZu/yTGpK7wAaAIE75CC+LCGEZHpY6VQJa/RoJcrmaA/docWJZvYohlNkWPA== + deepmerge@^4.2.2: version "4.3.1" resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a"