From 6ad9563f799efeb1b942e0af1581afad0cf62f69 Mon Sep 17 00:00:00 2001 From: FU-max-boop Date: Fri, 29 May 2026 06:44:52 +0800 Subject: [PATCH 1/3] fix: sanitize zod extracted definition refs --- src/_vendor/zod-to-json-schema/parseDef.ts | 5 ++- tests/helpers/zod.test.ts | 36 ++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/_vendor/zod-to-json-schema/parseDef.ts b/src/_vendor/zod-to-json-schema/parseDef.ts index f4dd747caa..ff1128909c 100644 --- a/src/_vendor/zod-to-json-schema/parseDef.ts +++ b/src/_vendor/zod-to-json-schema/parseDef.ts @@ -131,7 +131,10 @@ const get$ref = ( // `["#","definitions","contactPerson","properties","person1","properties","name"]` // then we'll extract it out to `contactPerson_properties_person1_properties_name` case 'extract-to-root': - const name = item.path.slice(refs.basePath.length + 1).join('_'); + const name = item.path + .slice(refs.basePath.length + 1) + .join('_') + .replace(/\s+/g, '_'); // we don't need to extract the root schema in this case, as it's already // been added to the definitions diff --git a/tests/helpers/zod.test.ts b/tests/helpers/zod.test.ts index 1bc766376c..f560cfc3a2 100644 --- a/tests/helpers/zod.test.ts +++ b/tests/helpers/zod.test.ts @@ -2,6 +2,19 @@ import { zodResponseFormat } from 'openai/helpers/zod'; import { z as zv3 } from 'zod/v3'; import { z as zv4 } from 'zod/v4'; +function collectRefs(value: unknown, refs: string[] = []): string[] { + if (!value || typeof value !== 'object') return refs; + + const maybeRef = (value as { $ref?: unknown }).$ref; + if (typeof maybeRef === 'string') refs.push(maybeRef); + + for (const child of Object.values(value)) { + collectRefs(child, refs); + } + + return refs; +} + describe.each([ { version: 'v3', z: zv3 }, { version: 'v4', z: zv4 as any as typeof zv3 }, @@ -49,6 +62,29 @@ describe.each([ `); }); + it('does not emit whitespace in extracted definition refs', () => { + const Thing = z.object({ id: z.string() }); + const Root = z.object({ + group: z.object({ + 'Thing With Spaces': Thing, + AnotherUsage: Thing, + }), + }); + + const schema = zodResponseFormat(Root, 'example-scope').json_schema.schema as Record; + const definitions = (schema.definitions ?? schema.$defs ?? {}) as Record; + const refs = collectRefs(schema); + + expect(refs).not.toContainEqual(expect.stringMatching(/\s/)); + expect(Object.keys(definitions)).not.toContainEqual(expect.stringMatching(/\s/)); + + for (const ref of refs) { + const definitionName = ref.split('/').at(-1); + expect(definitionName).toBeDefined(); + expect(definitions).toHaveProperty(definitionName as string); + } + }); + it('automatically adds optional properties to `required`', () => { expect( zodResponseFormat( From d3c808dc668126bd87d361ca3745c9e995bba077 Mon Sep 17 00:00:00 2001 From: FU-max-boop Date: Fri, 29 May 2026 12:11:02 +0800 Subject: [PATCH 2/3] Preserve distinct extracted schema refs --- src/_vendor/zod-to-json-schema/parseDef.ts | 7 +++++-- tests/helpers/zod.test.ts | 22 +++++++++++++++++----- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/_vendor/zod-to-json-schema/parseDef.ts b/src/_vendor/zod-to-json-schema/parseDef.ts index ff1128909c..c67e33e378 100644 --- a/src/_vendor/zod-to-json-schema/parseDef.ts +++ b/src/_vendor/zod-to-json-schema/parseDef.ts @@ -133,8 +133,8 @@ const get$ref = ( case 'extract-to-root': const name = item.path .slice(refs.basePath.length + 1) - .join('_') - .replace(/\s+/g, '_'); + .map(encodeDefinitionPathPart) + .join('_'); // we don't need to extract the root schema in this case, as it's already // been added to the definitions @@ -161,6 +161,9 @@ const get$ref = ( } }; +const encodeDefinitionPathPart = (part: string) => + part.replace(/[^A-Za-z0-9]/g, (char) => `_x${char.charCodeAt(0).toString(16).padStart(2, '0')}_`); + const getRelativePath = (pathA: string[], pathB: string[]) => { let i = 0; for (; i < pathA.length && i < pathB.length; i++) { diff --git a/tests/helpers/zod.test.ts b/tests/helpers/zod.test.ts index f560cfc3a2..65adaa1cfd 100644 --- a/tests/helpers/zod.test.ts +++ b/tests/helpers/zod.test.ts @@ -63,20 +63,32 @@ describe.each([ }); it('does not emit whitespace in extracted definition refs', () => { - const Thing = z.object({ id: z.string() }); + const ThingWithSpaces = z.object({ spaced: z.string() }); + const ThingWithUnderscores = z.object({ underscored: z.string() }); const Root = z.object({ group: z.object({ - 'Thing With Spaces': Thing, - AnotherUsage: Thing, + 'Thing With Spaces': ThingWithSpaces, + Thing_With_Spaces: ThingWithUnderscores, + anotherSpacedUsage: ThingWithSpaces, + anotherUnderscoredUsage: ThingWithUnderscores, }), }); const schema = zodResponseFormat(Root, 'example-scope').json_schema.schema as Record; - const definitions = (schema.definitions ?? schema.$defs ?? {}) as Record; + const definitions = (schema['definitions'] ?? schema['$defs'] ?? {}) as Record; const refs = collectRefs(schema); + const definitionNames = Object.keys(definitions); expect(refs).not.toContainEqual(expect.stringMatching(/\s/)); - expect(Object.keys(definitions)).not.toContainEqual(expect.stringMatching(/\s/)); + expect(definitionNames).not.toContainEqual(expect.stringMatching(/\s/)); + if (version === 'v3') { + expect(definitionNames).toContain( + 'example_x2d_scope_properties_group_properties_Thing_x20_With_x20_Spaces', + ); + expect(definitionNames).toContain( + 'example_x2d_scope_properties_group_properties_Thing_x5f_With_x5f_Spaces', + ); + } for (const ref of refs) { const definitionName = ref.split('/').at(-1); From b497c44f4e7910a1a6b6c076cecdc70e39d1e107 Mon Sep 17 00:00:00 2001 From: Hayden Date: Wed, 24 Jun 2026 13:06:48 -0700 Subject: [PATCH 3/3] fix: preserve safe extracted definition names --- src/_vendor/zod-to-json-schema/parseDef.ts | 15 +++++++++++++-- tests/helpers/zod.test.ts | 16 ++++++++++------ 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/_vendor/zod-to-json-schema/parseDef.ts b/src/_vendor/zod-to-json-schema/parseDef.ts index 9114e1fdc7..779c8b050e 100644 --- a/src/_vendor/zod-to-json-schema/parseDef.ts +++ b/src/_vendor/zod-to-json-schema/parseDef.ts @@ -172,8 +172,19 @@ const get$ref = ( } }; -const encodeDefinitionPathPart = (part: string) => - part.replace(/[^A-Za-z0-9]/g, (char) => `_x${char.charCodeAt(0).toString(16).padStart(2, '0')}_`); +const encodedDefinitionPathPartPrefix = '_x_'; + +const encodeDefinitionPathPart = (part: string) => { + if (/^[A-Za-z0-9_-]*$/.test(part) && !part.startsWith(encodedDefinitionPathPartPrefix)) { + return part; + } + + let encoded = encodedDefinitionPathPartPrefix; + for (let i = 0; i < part.length; i++) { + encoded += part.charCodeAt(i).toString(16).padStart(4, '0'); + } + return encoded; +}; const getRelativePath = (pathA: string[], pathB: string[]) => { let i = 0; diff --git a/tests/helpers/zod.test.ts b/tests/helpers/zod.test.ts index ceed44000c..943ba9b7b9 100644 --- a/tests/helpers/zod.test.ts +++ b/tests/helpers/zod.test.ts @@ -124,16 +124,20 @@ describe.each([ expect(refs).not.toContainEqual(expect.stringMatching(/\s/)); expect(definitionNames).not.toContainEqual(expect.stringMatching(/\s/)); if (version === 'v3') { - expect(definitionNames).toContain( - 'example-scope_properties_group_properties_Thing_x20_With_x20_Spaces', - ); - expect(definitionNames).toContain( - 'example-scope_properties_group_properties_Thing_x5f_With_x5f_Spaces', + const rootProperties = schema['properties'] as Record>; + const groupProperties = rootProperties['group']?.['properties'] as Record; + const spacedRef = groupProperties['anotherSpacedUsage']?.$ref; + const underscoredRef = groupProperties['anotherUnderscoredUsage']?.$ref; + + expect(spacedRef).toBeDefined(); + expect(underscoredRef).toBe( + '#/definitions/example-scope_properties_group_properties_Thing_With_Spaces', ); + expect(spacedRef).not.toBe(underscoredRef); } for (const ref of refs) { - const definitionName = ref.split('/').at(-1); + const definitionName = ref.split('/').pop(); expect(definitionName).toBeDefined(); expect(definitions).toHaveProperty(definitionName as string); }