diff --git a/src/_vendor/zod-to-json-schema/parseDef.ts b/src/_vendor/zod-to-json-schema/parseDef.ts index 91c3334d5..779c8b050 100644 --- a/src/_vendor/zod-to-json-schema/parseDef.ts +++ b/src/_vendor/zod-to-json-schema/parseDef.ts @@ -139,7 +139,13 @@ 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) + // The first part is either the root schema name or an extracted definition + // name that is being materialized. Keep it stable so recursive definitions + // do not generate a new name each time they are resolved. + .map((part, index) => (index === 0 ? part : encodeDefinitionPathPart(part))) + .join('_'); // we don't need to extract the root schema in this case, as it's already // been added to the definitions @@ -166,6 +172,20 @@ const get$ref = ( } }; +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; for (; i < pathA.length && i < pathB.length; i++) { diff --git a/tests/helpers/zod.test.ts b/tests/helpers/zod.test.ts index 04627b88c..943ba9b7b 100644 --- a/tests/helpers/zod.test.ts +++ b/tests/helpers/zod.test.ts @@ -2,6 +2,19 @@ import { zodResponseFormat, zodTextFormat } 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; +} + function expectDefinitionRefsToResolve(schema: Record) { const definitions = (schema['definitions'] ?? {}) as Record; @@ -91,6 +104,45 @@ describe.each([ `); }); + it('does not emit whitespace in extracted definition refs', () => { + const ThingWithSpaces = z.object({ spaced: z.string() }); + const ThingWithUnderscores = z.object({ underscored: z.string() }); + const Root = z.object({ + group: z.object({ + '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 refs = collectRefs(schema); + const definitionNames = Object.keys(definitions); + + expect(refs).not.toContainEqual(expect.stringMatching(/\s/)); + expect(definitionNames).not.toContainEqual(expect.stringMatching(/\s/)); + if (version === 'v3') { + 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('/').pop(); + expect(definitionName).toBeDefined(); + expect(definitions).toHaveProperty(definitionName as string); + } + }); + it('automatically adds optional properties to `required`', () => { expect( zodResponseFormat(