Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion src/_vendor/zod-to-json-schema/parseDef.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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++) {
Expand Down
52 changes: 52 additions & 0 deletions tests/helpers/zod.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) {
const definitions = (schema['definitions'] ?? {}) as Record<string, unknown>;

Expand Down Expand Up @@ -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<string, unknown>;
const definitions = (schema['definitions'] ?? schema['$defs'] ?? {}) as Record<string, unknown>;
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<string, Record<string, unknown>>;
const groupProperties = rootProperties['group']?.['properties'] as Record<string, { $ref?: string }>;
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(
Expand Down
Loading