diff --git a/package.json b/package.json index e34815dd0..949bb8aaa 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "tslib": "^2.8.1", "typescript": "5.8.3", "ws": "^8.18.0", - "zod": "^3.25 || ^4.0", + "zod": "^3.25 || ^4.1.13", "typescript-eslint": "8.31.1" }, "bin": { diff --git a/src/helpers/zod.ts b/src/helpers/zod.ts index 400b448fd..f4587b0e4 100644 --- a/src/helpers/zod.ts +++ b/src/helpers/zod.ts @@ -14,6 +14,7 @@ import { AutoParseableResponseTool, makeParseableResponseTool } from '../lib/Res import { type ResponseFormatTextJSONSchemaConfig } from '../resources/responses/responses'; import { toStrictJsonSchema } from '../lib/transform'; import { JSONSchema } from '../lib/jsonschema'; +import { transformJSONSchema } from '../lib/transform-json-schema'; type InferZodType = T extends z4.ZodType ? z4.infer @@ -21,21 +22,25 @@ type InferZodType = : never; function zodV3ToJsonSchema(schema: z3.ZodType, options: { name: string }): Record { - return _zodToJsonSchema(schema, { - openaiStrictMode: true, - name: options.name, - nameStrategy: 'duplicate-ref', - $refStrategy: 'extract-to-root', - nullableStrategy: 'property', - }); + return transformJSONSchema( + _zodToJsonSchema(schema, { + openaiStrictMode: true, + name: options.name, + nameStrategy: 'duplicate-ref', + $refStrategy: 'extract-to-root', + nullableStrategy: 'property', + }), + ); } function zodV4ToJsonSchema(schema: z4.ZodType): Record { - return toStrictJsonSchema( - z4.toJSONSchema(schema, { - target: 'draft-7', - }) as JSONSchema, - ) as Record; + return transformJSONSchema( + toStrictJsonSchema( + z4.toJSONSchema(schema, { + target: 'draft-7', + }) as JSONSchema, + ) as Record, + ); } function isZodV4(zodObject: z3.ZodType | z4.ZodType): zodObject is z4.ZodType { diff --git a/src/internal/transform-json-schema.ts b/src/internal/transform-json-schema.ts new file mode 100644 index 000000000..2200ec870 --- /dev/null +++ b/src/internal/transform-json-schema.ts @@ -0,0 +1,85 @@ +import type { JSONSchema } from '../lib/jsonschema'; +import { pop } from '../internal/utils'; + +function deepClone(obj: T): T { + return JSON.parse(JSON.stringify(obj)); +} + +export function transformJSONSchema(jsonSchema: JSONSchema): JSONSchema { + const workingCopy = deepClone(jsonSchema); + return _transformJSONSchema(workingCopy); +} + +function _transformJSONSchema(jsonSchema: JSONSchema): JSONSchema { + if (typeof jsonSchema !== 'object' || jsonSchema === null) { + // e.g. base case for additionalProperties + return jsonSchema; + } + + const defs = pop(jsonSchema, '$defs'); + if (defs !== undefined) { + const strictDefs: Record = {}; + jsonSchema.$defs = strictDefs; + for (const [name, defSchema] of Object.entries(defs)) { + strictDefs[name] = _transformJSONSchema(defSchema as JSONSchema); + } + } + + const type = jsonSchema.type; + const anyOf = pop(jsonSchema, 'anyOf'); + const oneOf = pop(jsonSchema, 'oneOf'); + const allOf = pop(jsonSchema, 'allOf'); + const not = pop(jsonSchema, 'not'); + + const shouldHaveType = [anyOf, oneOf, allOf, not].some(Array.isArray); + if (!shouldHaveType && type === undefined) { + throw new Error('JSON schema must have a type defined if anyOf/oneOf/allOf are not used'); + } + + if (Array.isArray(anyOf)) { + jsonSchema.anyOf = anyOf.map((variant) => _transformJSONSchema(variant as JSONSchema)); + } + + if (Array.isArray(oneOf)) { + // replace all oneOfs to anyOf + jsonSchema.anyOf = oneOf.map((variant) => _transformJSONSchema(variant as JSONSchema)); + delete jsonSchema.oneOf; + } + + if (Array.isArray(allOf)) { + jsonSchema.allOf = allOf.map((entry) => _transformJSONSchema(entry as JSONSchema)); + } + + if (not !== undefined) { + jsonSchema.not = _transformJSONSchema(not as JSONSchema); + } + + const additionalProperties = pop(jsonSchema, 'additionalProperties'); + if (additionalProperties !== undefined) { + jsonSchema.additionalProperties = _transformJSONSchema(additionalProperties as JSONSchema); + } + + switch (type) { + case 'object': { + const properties = pop(jsonSchema, 'properties'); + if (properties !== undefined) { + jsonSchema.properties = Object.fromEntries( + Object.entries(properties).map(([key, propSchema]) => [ + key, + _transformJSONSchema(propSchema as JSONSchema), + ]), + ); + } + break; + } + case 'array': { + const items = pop(jsonSchema, 'items'); + if (items !== undefined) { + jsonSchema.items = _transformJSONSchema(items as JSONSchema); + } + break; + } + } + + return jsonSchema; +} diff --git a/src/internal/utils/values.ts b/src/internal/utils/values.ts index 284ff5cde..d3bce3e1b 100644 --- a/src/internal/utils/values.ts +++ b/src/internal/utils/values.ts @@ -103,3 +103,10 @@ export const safeJSON = (text: string) => { return undefined; } }; + +// Gets a value from an object, deletes the key, and returns the value (or undefined if not found) +export const pop = , K extends string>(obj: T, key: K): T[K] => { + const value = obj[key]; + delete obj[key]; + return value; +}; diff --git a/tests/helpers/transform-json-schema.test.ts b/tests/helpers/transform-json-schema.test.ts new file mode 100644 index 000000000..37b825027 --- /dev/null +++ b/tests/helpers/transform-json-schema.test.ts @@ -0,0 +1,204 @@ +import { transformJSONSchema } from '../../src/internal/transform-json-schema'; + +describe('transformJsonSchema', () => { + it('should not mutate the original schema', () => { + const input = { + type: 'object', + properties: { + bonus: { + type: 'integer', + default: 100000, + minimum: 100000, + title: 'Bonus', + description: 'Annual bonus in USD', + }, + tags: { + type: 'array', + items: { type: 'string' }, + minItems: 3, + }, + }, + title: 'Employee', + additionalProperties: true, + }; + + const inputCopy = JSON.parse(JSON.stringify(input)); + + transformJSONSchema(input); + + expect(input).toEqual(inputCopy); + }); + + it('should turn a discriminated union oneOf into an anyOf', () => { + const input = { + type: 'object', + oneOf: [ + { + type: 'object', + properties: { + bonus: { + type: 'integer', + }, + }, + }, + { + type: 'object', + properties: { + salary: { + type: 'integer', + }, + }, + }, + ], + }; + + const expected = { + type: 'object', + anyOf: [ + { + type: 'object', + properties: { + bonus: { + type: 'integer', + }, + }, + }, + { + type: 'object', + properties: { + salary: { + type: 'integer', + }, + }, + }, + ], + }; + + const transformedSchema = transformJSONSchema(input); + + expect(transformedSchema).toEqual(expected); + }); + + it('should turn oneOf into anyOf in recursive object in list', () => { + const input = { + type: 'object', + properties: { + employees: { + type: 'array', + items: { + type: 'object', + oneOf: [ + { + type: 'object', + properties: { + bonus: { + type: 'integer', + }, + }, + }, + { + type: 'object', + properties: { + salary: { + type: 'integer', + }, + }, + }, + ], + }, + }, + }, + }; + + const expected = { + type: 'object', + properties: { + employees: { + type: 'array', + items: { + type: 'object', + anyOf: [ + { + type: 'object', + properties: { + bonus: { + type: 'integer', + }, + }, + }, + { + type: 'object', + properties: { + salary: { + type: 'integer', + }, + }, + }, + ], + }, + }, + }, + }; + + const transformedSchema = transformJSONSchema(input); + + expect(transformedSchema).toEqual(expected); + }); + + it('throws when not anyOf/oneOf/allOf and type not defined', () => { + const input = { + type: 'object', + properties: { + employees: { + type: 'array', + items: { + properties: { + bonus: { + type: 'integer', + }, + }, + }, + }, + }, + }; + + expect(() => transformJSONSchema(input)).toThrow( + 'JSON schema must have a type defined if anyOf/oneOf/allOf are not used', + ); + }); + + it('should preserve additionalProperties recursively', () => { + const input = { + type: 'object', + properties: { + employees: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + metadata: { + type: 'object', + properties: { + id: { type: 'string' }, + }, + additionalProperties: { + type: 'string', + maxLength: 100, + }, + }, + }, + additionalProperties: true, + }, + }, + }, + additionalProperties: false, + }; + + const expected = structuredClone(input); + + const transformedSchema = transformJSONSchema(input); + + expect(transformedSchema).toEqual(expected); + }); +}); diff --git a/tests/lib/__snapshots__/parser.test.ts.snap b/tests/lib/__snapshots__/parser.test.ts.snap index 11d68ab4e..9f176ff6d 100644 --- a/tests/lib/__snapshots__/parser.test.ts.snap +++ b/tests/lib/__snapshots__/parser.test.ts.snap @@ -1,172 +1,81 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`.parse() zod deserialises response_format 1`] = ` +exports[`.parse() allows zod v4 discriminated unions 1`] = ` "{ - "id": "chatcmpl-9uLhvwLPvKOZoJ7hwaa666fYuxYif", + "id": "chatcmpl-Cj6L9EhAnLN4VdmS3WOlztNifBTib", "object": "chat.completion", - "created": 1723216839, + "created": 1764865167, "model": "gpt-4o-2024-08-06", "choices": [ { "index": 0, "message": { "role": "assistant", - "content": "{\\"city\\":\\"San Francisco\\",\\"units\\":\\"c\\"}", - "refusal": null + "content": "{\\"data\\":{\\"type\\":\\"a\\"}}", + "refusal": null, + "annotations": [] }, "logprobs": null, "finish_reason": "stop" } ], "usage": { - "prompt_tokens": 17, - "completion_tokens": 10, - "total_tokens": 27 - }, - "system_fingerprint": "fp_2a322c9ffc" -} -" -`; - -exports[`.parse() zod merged schemas 2`] = ` -"{ - "id": "chatcmpl-9uLi0HJ6HYH0FM1VI1N6XCREiGvX1", - "object": "chat.completion", - "created": 1723216844, - "model": "gpt-4o-2024-08-06", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": "{\\"person1\\":{\\"name\\":\\"Jane Doe\\",\\"phone_number\\":\\".\\",\\"roles\\":[\\"other\\"],\\"description\\":\\"Engineer at OpenAI, born Nov 16, contact email: jane@openai.com\\"},\\"person2\\":{\\"name\\":\\"John Smith\\",\\"phone_number\\":\\"john@openai.com\\",\\"differentField\\":\\"Engineer at OpenAI, born March 1.\\"}}", - "refusal": null - }, - "logprobs": null, - "finish_reason": "stop" - } - ], - "usage": { - "prompt_tokens": 61, - "completion_tokens": 72, - "total_tokens": 133 - }, - "system_fingerprint": "fp_2a322c9ffc" -} -" -`; - -exports[`.parse() zod nested schema extraction 2`] = ` -"{ - "id": "chatcmpl-9uLi6hkH6VcoaYiNEzy3h56QRAyns", - "object": "chat.completion", - "created": 1723216850, - "model": "gpt-4o-2024-08-06", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": "{\\"name\\":\\"TodoApp\\",\\"fields\\":[{\\"type\\":\\"string\\",\\"name\\":\\"taskId\\",\\"metadata\\":{\\"foo\\":\\"unique identifier for each task\\"}},{\\"type\\":\\"string\\",\\"name\\":\\"title\\",\\"metadata\\":{\\"foo\\":\\"title of the task\\"}},{\\"type\\":\\"string\\",\\"name\\":\\"description\\",\\"metadata\\":{\\"foo\\":\\"detailed description of the task. This is optional.\\"}},{\\"type\\":\\"string\\",\\"name\\":\\"status\\",\\"metadata\\":{\\"foo\\":\\"status of the task, e.g., pending, completed, etc.\\"}},{\\"type\\":\\"string\\",\\"name\\":\\"dueDate\\",\\"metadata\\":null},{\\"type\\":\\"string\\",\\"name\\":\\"priority\\",\\"metadata\\":{\\"foo\\":\\"priority level of the task, e.g., low, medium, high\\"}},{\\"type\\":\\"string\\",\\"name\\":\\"creationDate\\",\\"metadata\\":{\\"foo\\":\\"date when the task was created\\"}},{\\"type\\":\\"string\\",\\"name\\":\\"lastModifiedDate\\",\\"metadata\\":{\\"foo\\":\\"date when the task was last modified\\"}},{\\"type\\":\\"string\\",\\"name\\":\\"tags\\",\\"metadata\\":{\\"foo\\":\\"tags associated with the task, for categorization\\"}}]}", - "refusal": null - }, - "logprobs": null, - "finish_reason": "stop" - } - ], - "usage": { - "prompt_tokens": 36, - "completion_tokens": 208, - "total_tokens": 244 - }, - "system_fingerprint": "fp_2a322c9ffc" -} -" -`; - -exports[`.parse() zod recursive schema extraction 2`] = ` -"{ - "id": "chatcmpl-9vdbw9dekyUSEsSKVQDhTxA2RCxcK", - "object": "chat.completion", - "created": 1723523988, - "model": "gpt-4o-2024-08-06", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": "{\\"linked_list\\":{\\"value\\":1,\\"next\\":{\\"value\\":2,\\"next\\":{\\"value\\":3,\\"next\\":{\\"value\\":4,\\"next\\":{\\"value\\":5,\\"next\\":null}}}}}}", - "refusal": null - }, - "logprobs": null, - "finish_reason": "stop" + "prompt_tokens": 115, + "completion_tokens": 7, + "total_tokens": 122, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 } - ], - "usage": { - "prompt_tokens": 40, - "completion_tokens": 38, - "total_tokens": 78 }, - "system_fingerprint": "fp_2a322c9ffc" + "service_tier": "default", + "system_fingerprint": "fp_cbf1785567" } " `; -exports[`.parse() zod ref schemas with \`.transform()\` 2`] = ` +exports[`.parse() allows zod v4 discriminated unions 3`] = ` "{ - "id": "chatcmpl-A6zyLEtubMlUvGplOmr92S0mK0kiG", + "id": "chatcmpl-Cj6LAo9OYmTKdatrog7EX6Eza5sWm", "object": "chat.completion", - "created": 1726231553, + "created": 1764865168, "model": "gpt-4o-2024-08-06", "choices": [ { "index": 0, "message": { "role": "assistant", - "content": "{\\"first\\":{\\"baz\\":true},\\"second\\":{\\"baz\\":false}}", - "refusal": null + "content": "{\\"data\\":{\\"type\\":\\"a\\"}}", + "refusal": null, + "annotations": [] }, "logprobs": null, "finish_reason": "stop" } ], "usage": { - "prompt_tokens": 167, - "completion_tokens": 13, - "total_tokens": 180, + "prompt_tokens": 115, + "completion_tokens": 7, + "total_tokens": 122, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, "completion_tokens_details": { - "reasoning_tokens": 0 + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 } }, - "system_fingerprint": "fp_143bb8492c" -} -" -`; - -exports[`.parse() zod top-level recursive schemas 1`] = ` -"{ - "id": "chatcmpl-9uLhw79ArBF4KsQQOlsoE68m6vh6v", - "object": "chat.completion", - "created": 1723216840, - "model": "gpt-4o-2024-08-06", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": "{\\"type\\":\\"form\\",\\"label\\":\\"User Profile Form\\",\\"children\\":[{\\"type\\":\\"field\\",\\"label\\":\\"First Name\\",\\"children\\":[],\\"attributes\\":[{\\"name\\":\\"type\\",\\"value\\":\\"text\\"},{\\"name\\":\\"name\\",\\"value\\":\\"firstName\\"},{\\"name\\":\\"placeholder\\",\\"value\\":\\"Enter your first name\\"}]},{\\"type\\":\\"field\\",\\"label\\":\\"Last Name\\",\\"children\\":[],\\"attributes\\":[{\\"name\\":\\"type\\",\\"value\\":\\"text\\"},{\\"name\\":\\"name\\",\\"value\\":\\"lastName\\"},{\\"name\\":\\"placeholder\\",\\"value\\":\\"Enter your last name\\"}]},{\\"type\\":\\"field\\",\\"label\\":\\"Email Address\\",\\"children\\":[],\\"attributes\\":[{\\"name\\":\\"type\\",\\"value\\":\\"email\\"},{\\"name\\":\\"name\\",\\"value\\":\\"email\\"},{\\"name\\":\\"placeholder\\",\\"value\\":\\"Enter your email address\\"}]},{\\"type\\":\\"button\\",\\"label\\":\\"Submit\\",\\"children\\":[],\\"attributes\\":[{\\"name\\":\\"type\\",\\"value\\":\\"submit\\"}]}],\\"attributes\\":[]}", - "refusal": null - }, - "logprobs": null, - "finish_reason": "stop" - } - ], - "usage": { - "prompt_tokens": 38, - "completion_tokens": 175, - "total_tokens": 213 - }, - "system_fingerprint": "fp_845eaabc1f" + "service_tier": "default", + "system_fingerprint": "fp_cbf1785567" } " `; diff --git a/tests/lib/parser.test.ts b/tests/lib/parser.test.ts index 1aa33acf0..04e58de3c 100644 --- a/tests/lib/parser.test.ts +++ b/tests/lib/parser.test.ts @@ -1405,4 +1405,43 @@ describe.each([ `); }); }); + + it('allows zod v4 discriminated unions', async () => { + const completion = await makeSnapshotRequest( + (openai) => + openai.chat.completions.parse({ + model: 'gpt-4o', + messages: [ + { + role: 'user', + content: 'can you generate fake data matching the given response format? choose a', + }, + ], + response_format: zodResponseFormat( + z4.object({ + data: z4.discriminatedUnion('type', [ + z4.object({ type: z4.literal('a') }), + z4.object({ type: z4.literal('b') }), + ]), + }), + 'data', + ), + }), + 2, + ); + + expect(completion.choices[0]?.message).toMatchInlineSnapshot(` + { + "annotations": [], + "content": "{"data":{"type":"a"}}", + "parsed": { + "data": { + "type": "a", + }, + }, + "refusal": null, + "role": "assistant", + } + `); + }); });