diff --git a/src/_vendor/zod-to-json-schema/Options.ts b/src/_vendor/zod-to-json-schema/Options.ts index a83690e59..e03b09453 100644 --- a/src/_vendor/zod-to-json-schema/Options.ts +++ b/src/_vendor/zod-to-json-schema/Options.ts @@ -38,38 +38,45 @@ export type Options = { openaiStrictMode?: boolean; }; -export const defaultOptions: Options = { - name: undefined, - $refStrategy: 'root', - basePath: ['#'], - effectStrategy: 'input', - pipeStrategy: 'all', - dateStrategy: 'format:date-time', - mapStrategy: 'entries', - nullableStrategy: 'from-target', - removeAdditionalStrategy: 'passthrough', - definitionPath: 'definitions', - target: 'jsonSchema7', - strictUnions: false, - definitions: {}, - errorMessages: false, - markdownDescription: false, - patternStrategy: 'escape', - applyRegexFlags: false, - emailStrategy: 'format:email', - base64Strategy: 'contentEncoding:base64', - nameStrategy: 'ref', -}; - export const getDefaultOptions = ( options: Partial> | string | undefined, -) => - (typeof options === 'string' ? - { - ...defaultOptions, - name: options, - } - : { - ...defaultOptions, - ...options, - }) as Options; +) => { + // Move the default options into the function to prevent the 'definitions' being mutated in each run + const defaultOptions: Options = { + name: undefined, + $refStrategy: 'root', + basePath: ['#'], + effectStrategy: 'input', + pipeStrategy: 'all', + dateStrategy: 'format:date-time', + mapStrategy: 'entries', + nullableStrategy: 'from-target', + removeAdditionalStrategy: 'passthrough', + definitionPath: 'definitions', + target: 'jsonSchema7', + strictUnions: false, + definitions: {}, + errorMessages: false, + markdownDescription: false, + patternStrategy: 'escape', + applyRegexFlags: false, + emailStrategy: 'format:email', + base64Strategy: 'contentEncoding:base64', + nameStrategy: 'ref', + }; + return ( + typeof options === 'string' ? + { + ...defaultOptions, + // Create a new object to avoid mutating the default options + basePath: ['#'], + definitions: {}, + name: options, + } + : { + ...defaultOptions, + basePath: ['#'], + definitions: {}, + ...options, + }) as Options; +}; diff --git a/src/_vendor/zod-to-json-schema/zodToJsonSchema.ts b/src/_vendor/zod-to-json-schema/zodToJsonSchema.ts index 1c3290008..16178a727 100644 --- a/src/_vendor/zod-to-json-schema/zodToJsonSchema.ts +++ b/src/_vendor/zod-to-json-schema/zodToJsonSchema.ts @@ -49,14 +49,26 @@ const zodToJsonSchema = ( } const definitions: Record = {}; + const processedKeys = new Set(); - for (const [name, zodSchema] of Object.entries(refs.definitions)) { - definitions[name] = - parseDef( - zodDef(zodSchema), - { ...refs, currentPath: [...refs.basePath, refs.definitionPath, name] }, - true, - ) ?? {}; + // use while loop to add newly created definitions in `parseDef` to the list + while (true) { + const newKeys = Object.keys(refs.definitions).filter((key) => !processedKeys.has(key)); + + if (newKeys.length === 0) break; + + for (const key of newKeys) { + const schema = refs.definitions[key]; + if (schema) { + definitions[key] = + parseDef( + zodDef(schema), + { ...refs, currentPath: [...refs.basePath, refs.definitionPath, key] }, + true, + ) ?? {}; + processedKeys.add(key); + } + } } return definitions; diff --git a/tests/lib/__snapshots__/parser.test.ts.snap b/tests/lib/__snapshots__/parser.test.ts.snap index d98db2345..12e737f5c 100644 --- a/tests/lib/__snapshots__/parser.test.ts.snap +++ b/tests/lib/__snapshots__/parser.test.ts.snap @@ -84,6 +84,34 @@ exports[`.parse() zod nested schema extraction 2`] = ` " `; +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" + } + ], + "usage": { + "prompt_tokens": 40, + "completion_tokens": 38, + "total_tokens": 78 + }, + "system_fingerprint": "fp_2a322c9ffc" +} +" +`; + exports[`.parse() zod top-level recursive schemas 1`] = ` "{ "id": "chatcmpl-9uLhw79ArBF4KsQQOlsoE68m6vh6v", diff --git a/tests/lib/parser.test.ts b/tests/lib/parser.test.ts index 331b16895..cbcc2f186 100644 --- a/tests/lib/parser.test.ts +++ b/tests/lib/parser.test.ts @@ -525,13 +525,6 @@ describe('.parse()', () => { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, "definitions": { - "contactPerson_properties_person1_properties_name": { - "type": "string", - }, - "contactPerson_properties_person1_properties_phone_number": { - "nullable": true, - "type": "string", - }, "query": { "additionalProperties": false, "properties": { @@ -616,6 +609,21 @@ describe('.parse()', () => { }, ], }, + "query_properties_fields_items_anyOf_0_properties_metadata_anyOf_0": { + "additionalProperties": false, + "properties": { + "foo": { + "$ref": "#/definitions/query_properties_fields_items_anyOf_0_properties_metadata_anyOf_0_properties_foo", + }, + }, + "required": [ + "foo", + ], + "type": "object", + }, + "query_properties_fields_items_anyOf_0_properties_metadata_anyOf_0_properties_foo": { + "type": "string", + }, }, "properties": { "fields": { @@ -783,5 +791,165 @@ describe('.parse()', () => { } `); }); + + test('recursive schema extraction', async () => { + const baseLinkedListNodeSchema = z.object({ + value: z.number(), + }); + type LinkedListNode = z.infer & { + next: LinkedListNode | null; + }; + const linkedListNodeSchema: z.ZodType = baseLinkedListNodeSchema.extend({ + next: z.lazy(() => z.union([linkedListNodeSchema, z.null()])), + }); + + // Define the main schema + const mainSchema = z.object({ + linked_list: linkedListNodeSchema, + }); + + expect(zodResponseFormat(mainSchema, 'query').json_schema.schema).toMatchInlineSnapshot(` + { + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "query": { + "additionalProperties": false, + "properties": { + "linked_list": { + "additionalProperties": false, + "properties": { + "next": { + "anyOf": [ + { + "$ref": "#/definitions/query_properties_linked_list", + }, + { + "type": "null", + }, + ], + }, + "value": { + "type": "number", + }, + }, + "required": [ + "value", + "next", + ], + "type": "object", + }, + }, + "required": [ + "linked_list", + ], + "type": "object", + }, + "query_properties_linked_list": { + "additionalProperties": false, + "properties": { + "next": { + "$ref": "#/definitions/query_properties_linked_list_properties_next", + }, + "value": { + "$ref": "#/definitions/query_properties_linked_list_properties_value", + }, + }, + "required": [ + "value", + "next", + ], + "type": "object", + }, + "query_properties_linked_list_properties_next": { + "anyOf": [ + { + "$ref": "#/definitions/query_properties_linked_list", + }, + { + "type": "null", + }, + ], + }, + "query_properties_linked_list_properties_value": { + "type": "number", + }, + }, + "properties": { + "linked_list": { + "additionalProperties": false, + "properties": { + "next": { + "anyOf": [ + { + "$ref": "#/definitions/query_properties_linked_list", + }, + { + "type": "null", + }, + ], + }, + "value": { + "type": "number", + }, + }, + "required": [ + "value", + "next", + ], + "type": "object", + }, + }, + "required": [ + "linked_list", + ], + "type": "object", + } + `); + + const completion = await makeSnapshotRequest( + (openai) => + openai.beta.chat.completions.parse({ + model: 'gpt-4o-2024-08-06', + messages: [ + { + role: 'system', + content: + "You are a helpful assistant. Generate a data model according to the user's instructions.", + }, + { role: 'user', content: 'create a linklist from 1 to 5' }, + ], + response_format: zodResponseFormat(mainSchema, 'query'), + }), + 2, + ); + + expect(completion.choices[0]?.message).toMatchInlineSnapshot(` + { + "content": "{"linked_list":{"value":1,"next":{"value":2,"next":{"value":3,"next":{"value":4,"next":{"value":5,"next":null}}}}}}", + "parsed": { + "linked_list": { + "next": { + "next": { + "next": { + "next": { + "next": null, + "value": 5, + }, + "value": 4, + }, + "value": 3, + }, + "value": 2, + }, + "value": 1, + }, + }, + "refusal": null, + "role": "assistant", + "tool_calls": [], + } + `); + }); }); });