Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
29 changes: 17 additions & 12 deletions src/helpers/zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,28 +14,33 @@ 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> =
T extends z4.ZodType ? z4.infer<T>
: T extends z3.ZodType ? z3.infer<T>
: never;

function zodV3ToJsonSchema(schema: z3.ZodType, options: { name: string }): Record<string, unknown> {
return _zodToJsonSchema(schema, {
openaiStrictMode: true,
name: options.name,
nameStrategy: 'duplicate-ref',
$refStrategy: 'extract-to-root',
nullableStrategy: 'property',
});
return transformJSONSchema(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

q: is this necessary for v3? maybe it would make sense for consistency but I think we'll already do the oneOf -> anyOf transform in the vendored to-json-schema library we use (I could be wrong)

_zodToJsonSchema(schema, {
openaiStrictMode: true,
name: options.name,
nameStrategy: 'duplicate-ref',
$refStrategy: 'extract-to-root',
nullableStrategy: 'property',
}),
);
}

function zodV4ToJsonSchema(schema: z4.ZodType): Record<string, unknown> {
return toStrictJsonSchema(
z4.toJSONSchema(schema, {
target: 'draft-7',
}) as JSONSchema,
) as Record<string, unknown>;
return transformJSONSchema(
toStrictJsonSchema(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh wait we already have toStrictJsonSchema() 🤦 we should use that instead of introducing the new transformJSONSchema() function

Copy link
Author

@404Wolf 404Wolf Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Huh yeah you're right.

It looks like it also checks that the root is an object, which is slightly different behavior between v3 and v4, and if we change it users will now get vanilla Errors rather than 400s OpenAIErrors. I think that that's probably fine though

z4.toJSONSchema(schema, {
target: 'draft-7',
}) as JSONSchema,
) as Record<string, unknown>,
);
}

function isZodV4(zodObject: z3.ZodType | z4.ZodType): zodObject is z4.ZodType {
Expand Down
7 changes: 7 additions & 0 deletions src/internal/utils/values.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <T extends Record<string, any>, K extends string>(obj: T, key: K): T[K] => {
const value = obj[key];
delete obj[key];
return value;
};
122 changes: 122 additions & 0 deletions src/lib/transform-json-schema.ts
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This technically makes it part of the public API, but I don't think we should make it public until we've decided on the "unsupported properties to description" behaviour, could you move this to src/lib/internal or src/internal?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense. I'll move it!

Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { pop } from '../internal/utils';

const SUPPORTED_STRING_FORMATS = new Set([
'date-time',
'time',
'date',
'duration',
'email',
'hostname',
'ipv4',
'ipv6',
'uuid',
]);

export type JSONSchema = Record<string, any>;

function deepClone<T>(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 {
const strictSchema: JSONSchema = {};

const ref = pop(jsonSchema, '$ref');
if (ref !== undefined) {
strictSchema['$ref'] = ref;
return strictSchema;
}

const defs = pop(jsonSchema, '$defs');
if (defs !== undefined) {
const strictDefs: Record<string, any> = {};
strictSchema['$defs'] = strictDefs;
for (const [name, defSchema] of Object.entries(defs)) {
strictDefs[name] = _transformJSONSchema(defSchema as JSONSchema);
}
}

const type = pop(jsonSchema, 'type');
const anyOf = pop(jsonSchema, 'anyOf');
const oneOf = pop(jsonSchema, 'oneOf');
const allOf = pop(jsonSchema, 'allOf');

if (Array.isArray(anyOf)) {
strictSchema['anyOf'] = anyOf.map((variant) => _transformJSONSchema(variant as JSONSchema));
} else if (Array.isArray(oneOf)) {
strictSchema['anyOf'] = oneOf.map((variant) => _transformJSONSchema(variant as JSONSchema));
} else if (Array.isArray(allOf)) {
strictSchema['allOf'] = allOf.map((entry) => _transformJSONSchema(entry as JSONSchema));
} else {
if (type === undefined) {
throw new Error('JSON schema must have a type defined if anyOf/oneOf/allOf are not used');
}
strictSchema['type'] = type;
}

const description = pop(jsonSchema, 'description');
if (description !== undefined) {
strictSchema['description'] = description;
}

const title = pop(jsonSchema, 'title');
if (title !== undefined) {
strictSchema['title'] = title;
}

if (type === 'object') {
const properties = pop(jsonSchema, 'properties') || {};

strictSchema['properties'] = Object.fromEntries(
Object.entries(properties).map(([key, propSchema]) => [
key,
_transformJSONSchema(propSchema as JSONSchema),
]),
);

pop(jsonSchema, 'additionalProperties');
strictSchema['additionalProperties'] = false;

const required = pop(jsonSchema, 'required');
if (required !== undefined) {
strictSchema['required'] = required;
}
} else if (type === 'string') {
const format = pop(jsonSchema, 'format');
if (format !== undefined && SUPPORTED_STRING_FORMATS.has(format)) {
strictSchema['format'] = format;
} else if (format !== undefined) {
jsonSchema['format'] = format;
}
} else if (type === 'array') {
const items = pop(jsonSchema, 'items');
if (items !== undefined) {
strictSchema['items'] = _transformJSONSchema(items as JSONSchema);
}

const minItems = pop(jsonSchema, 'minItems');
if (minItems !== undefined && (minItems === 0 || minItems === 1)) {
strictSchema['minItems'] = minItems;
} else if (minItems !== undefined) {
jsonSchema['minItems'] = minItems;
}
}

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you're missing a case for additionalProperties as well, which is boolean | Schema

if (Object.keys(jsonSchema).length > 0) {
const existingDescription = strictSchema['description'];
strictSchema['description'] =
(existingDescription ? existingDescription + '\n\n' : '') +
'{' +
Object.entries(jsonSchema)
.map(([key, value]) => `${key}: ${JSON.stringify(value)}`)
.join(', ') +
'}';
}

return strictSchema;
}
Loading