Skip to content
Draft
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
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
85 changes: 85 additions & 0 deletions src/internal/transform-json-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import type { JSONSchema } from '../lib/jsonschema';
import { pop } from '../internal/utils';

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 {
if (typeof jsonSchema !== 'object' || jsonSchema === null) {
// e.g. base case for additionalProperties
return jsonSchema;
}

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

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

const shouldHaveType = [anyOf, oneOf, allOf, not].some(Array.isArray);
if (!shouldHaveType && type === undefined) {
throw new Error('JSON schema must have a type defined if anyOf/oneOf/allOf are not used');
}
Comment on lines +34 to +37
Copy link
Collaborator

Choose a reason for hiding this comment

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

q: whats the value provided in validating this?

Copy link
Author

Choose a reason for hiding this comment

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

It was for consistency with what we do in anthropic, but yes we don't have to do this to get rid of all the anyOfs


if (Array.isArray(anyOf)) {
jsonSchema.anyOf = anyOf.map((variant) => _transformJSONSchema(variant as JSONSchema));
}

if (Array.isArray(oneOf)) {
// replace all oneOfs to anyOf
jsonSchema.anyOf = oneOf.map((variant) => _transformJSONSchema(variant as JSONSchema));
delete jsonSchema.oneOf;
}

if (Array.isArray(allOf)) {
jsonSchema.allOf = allOf.map((entry) => _transformJSONSchema(entry as JSONSchema));
}

if (not !== undefined) {
jsonSchema.not = _transformJSONSchema(not as JSONSchema);
}

const additionalProperties = pop(jsonSchema, 'additionalProperties');
if (additionalProperties !== undefined) {
jsonSchema.additionalProperties = _transformJSONSchema(additionalProperties as JSONSchema);
}

switch (type) {
case 'object': {
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: we shouldn't branch on the type here, properties could in theory be set when type is not for example, we can just ignore the type entirely imo

const properties = pop(jsonSchema, 'properties');
if (properties !== undefined) {
jsonSchema.properties = Object.fromEntries(
Object.entries(properties).map(([key, propSchema]) => [
key,
_transformJSONSchema(propSchema as JSONSchema),
]),
);
}
break;
}
case 'array': {
const items = pop(jsonSchema, 'items');
if (items !== undefined) {
jsonSchema.items = _transformJSONSchema(items as JSONSchema);
}
break;
}
}

return jsonSchema;
}
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;
};
204 changes: 204 additions & 0 deletions tests/helpers/transform-json-schema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import { transformJSONSchema } from '../../src/internal/transform-json-schema';

describe('transformJsonSchema', () => {
it('should not mutate the original schema', () => {
const input = {
type: 'object',
properties: {
bonus: {
type: 'integer',
default: 100000,
minimum: 100000,
title: 'Bonus',
description: 'Annual bonus in USD',
},
tags: {
type: 'array',
items: { type: 'string' },
minItems: 3,
},
},
title: 'Employee',
additionalProperties: true,
};

const inputCopy = JSON.parse(JSON.stringify(input));

transformJSONSchema(input);

expect(input).toEqual(inputCopy);
});

it('should turn a discriminated union oneOf into an anyOf', () => {
const input = {
type: 'object',
oneOf: [
{
type: 'object',
properties: {
bonus: {
type: 'integer',
},
},
},
{
type: 'object',
properties: {
salary: {
type: 'integer',
},
},
},
],
};

const expected = {
type: 'object',
anyOf: [
{
type: 'object',
properties: {
bonus: {
type: 'integer',
},
},
},
{
type: 'object',
properties: {
salary: {
type: 'integer',
},
},
},
],
};

const transformedSchema = transformJSONSchema(input);

expect(transformedSchema).toEqual(expected);
});

it('should turn oneOf into anyOf in recursive object in list', () => {
const input = {
type: 'object',
properties: {
employees: {
type: 'array',
items: {
type: 'object',
oneOf: [
{
type: 'object',
properties: {
bonus: {
type: 'integer',
},
},
},
{
type: 'object',
properties: {
salary: {
type: 'integer',
},
},
},
],
},
},
},
};

const expected = {
type: 'object',
properties: {
employees: {
type: 'array',
items: {
type: 'object',
anyOf: [
{
type: 'object',
properties: {
bonus: {
type: 'integer',
},
},
},
{
type: 'object',
properties: {
salary: {
type: 'integer',
},
},
},
],
},
},
},
};

const transformedSchema = transformJSONSchema(input);

expect(transformedSchema).toEqual(expected);
});

it('throws when not anyOf/oneOf/allOf and type not defined', () => {
const input = {
type: 'object',
properties: {
employees: {
type: 'array',
items: {
properties: {
bonus: {
type: 'integer',
},
},
},
},
},
};

expect(() => transformJSONSchema(input)).toThrow(
'JSON schema must have a type defined if anyOf/oneOf/allOf are not used',
);
});

it('should preserve additionalProperties recursively', () => {
const input = {
type: 'object',
properties: {
employees: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string' },
metadata: {
type: 'object',
properties: {
id: { type: 'string' },
},
additionalProperties: {
type: 'string',
maxLength: 100,
},
},
},
additionalProperties: true,
},
},
},
additionalProperties: false,
};

const expected = structuredClone(input);

const transformedSchema = transformJSONSchema(input);

expect(transformedSchema).toEqual(expected);
});
});
Loading
Loading