From 0a3b077faf3207da4df696a825279f6c2501c1e8 Mon Sep 17 00:00:00 2001 From: Wolf Mermelstein Date: Thu, 4 Dec 2025 10:45:52 -0500 Subject: [PATCH 1/8] add json schema transformation logic --- src/helpers/zod.ts | 29 +- src/internal/utils/values.ts | 7 + src/lib/transform-json-schema.ts | 124 +++++ tests/helpers/transform-json-schema.test.ts | 483 ++++++++++++++++++++ 4 files changed, 631 insertions(+), 12 deletions(-) create mode 100644 src/lib/transform-json-schema.ts create mode 100644 tests/helpers/transform-json-schema.test.ts diff --git a/src/helpers/zod.ts b/src/helpers/zod.ts index 400b448fd..f4587b0e4 100644 --- a/src/helpers/zod.ts +++ b/src/helpers/zod.ts @@ -14,6 +14,7 @@ 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 extends z4.ZodType ? z4.infer @@ -21,21 +22,25 @@ type InferZodType = : never; function zodV3ToJsonSchema(schema: z3.ZodType, options: { name: string }): Record { - return _zodToJsonSchema(schema, { - openaiStrictMode: true, - name: options.name, - nameStrategy: 'duplicate-ref', - $refStrategy: 'extract-to-root', - nullableStrategy: 'property', - }); + return transformJSONSchema( + _zodToJsonSchema(schema, { + openaiStrictMode: true, + name: options.name, + nameStrategy: 'duplicate-ref', + $refStrategy: 'extract-to-root', + nullableStrategy: 'property', + }), + ); } function zodV4ToJsonSchema(schema: z4.ZodType): Record { - return toStrictJsonSchema( - z4.toJSONSchema(schema, { - target: 'draft-7', - }) as JSONSchema, - ) as Record; + return transformJSONSchema( + toStrictJsonSchema( + z4.toJSONSchema(schema, { + target: 'draft-7', + }) as JSONSchema, + ) as Record, + ); } function isZodV4(zodObject: z3.ZodType | z4.ZodType): zodObject is z4.ZodType { diff --git a/src/internal/utils/values.ts b/src/internal/utils/values.ts index 284ff5cde..d3bce3e1b 100644 --- a/src/internal/utils/values.ts +++ b/src/internal/utils/values.ts @@ -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 = , K extends string>(obj: T, key: K): T[K] => { + const value = obj[key]; + delete obj[key]; + return value; +}; diff --git a/src/lib/transform-json-schema.ts b/src/lib/transform-json-schema.ts new file mode 100644 index 000000000..b7132bb68 --- /dev/null +++ b/src/lib/transform-json-schema.ts @@ -0,0 +1,124 @@ +import { pop } from '../internal/utils'; + +// Supported string formats +const SUPPORTED_STRING_FORMATS = new Set([ + 'date-time', + 'time', + 'date', + 'duration', + 'email', + 'hostname', + 'uri', + 'ipv4', + 'ipv6', + 'uuid', +]); + +export type JSONSchema = Record; + +function deepClone(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 = {}; + 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; + } + } + + 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; +} diff --git a/tests/helpers/transform-json-schema.test.ts b/tests/helpers/transform-json-schema.test.ts new file mode 100644 index 000000000..bbd5df522 --- /dev/null +++ b/tests/helpers/transform-json-schema.test.ts @@ -0,0 +1,483 @@ +import { detailedDiff } from 'deep-object-diff'; +import { transformJSONSchema } from '../../src/lib/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 remove unsupported properties and add them to description', () => { + const input = { + type: 'object', + properties: { + bonus: { + type: 'integer', + default: 100000, + minimum: 100000, + title: 'Bonus', + description: 'Annual bonus in USD', + }, + }, + title: 'Employee', + }; + + const result = transformJSONSchema(input); + const diff = detailedDiff(input, result); + expect(diff).toMatchInlineSnapshot(` +{ + "added": { + "additionalProperties": false, + }, + "deleted": { + "properties": { + "bonus": { + "default": undefined, + "minimum": undefined, + }, + }, + }, + "updated": { + "properties": { + "bonus": { + "description": "Annual bonus in USD + +{default: 100000, minimum: 100000}", + }, + }, + }, +} +`); + }); + + it('should handle objects without existing description', () => { + const input = { + type: 'object', + properties: { + count: { + type: 'integer', + maximum: 10, + minimum: 1, + }, + }, + }; + + const result = transformJSONSchema(input); + const diff = detailedDiff(input, result); + expect(diff).toMatchInlineSnapshot(` +{ + "added": { + "additionalProperties": false, + "properties": { + "count": { + "description": "{maximum: 10, minimum: 1}", + }, + }, + }, + "deleted": { + "properties": { + "count": { + "maximum": undefined, + "minimum": undefined, + }, + }, + }, + "updated": {}, +} +`); + }); + + it('should preserve supported minItems values (0 and 1)', () => { + const input = { + type: 'array', + items: { type: 'string' }, + minItems: 1, + }; + + const result = transformJSONSchema(input); + const diff = detailedDiff(input, result); + expect(diff).toMatchInlineSnapshot(` +{ + "added": {}, + "deleted": {}, + "updated": {}, +} +`); + }); + + it('should remove unsupported minItems values (> 1)', () => { + const input = { + type: 'array', + items: { type: 'string' }, + minItems: 3, + description: 'List of items', + }; + + const result = transformJSONSchema(input); + const diff = detailedDiff(input, result); + expect(diff).toMatchInlineSnapshot(` +{ + "added": {}, + "deleted": { + "minItems": undefined, + }, + "updated": { + "description": "List of items + +{minItems: 3}", + }, +} +`); + }); + + it('should handle nested objects recursively', () => { + const input = { + type: 'object', + properties: { + user: { + type: 'object', + properties: { + age: { + type: 'integer', + minimum: 0, + maximum: 120, + }, + }, + }, + }, + }; + + const result = transformJSONSchema(input); + const diff = detailedDiff(input, result); + expect(diff).toMatchInlineSnapshot(` +{ + "added": { + "additionalProperties": false, + "properties": { + "user": { + "additionalProperties": false, + "properties": { + "age": { + "description": "{minimum: 0, maximum: 120}", + }, + }, + }, + }, + }, + "deleted": { + "properties": { + "user": { + "properties": { + "age": { + "maximum": undefined, + "minimum": undefined, + }, + }, + }, + }, + }, + "updated": {}, +} +`); + }); + + it('should handle $defs and definitions recursively', () => { + const input = { + type: 'object', + $defs: { + Person: { + type: 'object', + properties: { + name: { + type: 'string', + pattern: '^[A-Za-z]+$', + }, + }, + }, + }, + properties: { + person: { + $ref: '#/$defs/Person', + }, + }, + }; + + const result = transformJSONSchema(input); + const diff = detailedDiff(input, result); + expect(diff).toMatchInlineSnapshot(` +{ + "added": { + "$defs": { + "Person": { + "additionalProperties": false, + "properties": { + "name": { + "description": "{pattern: "^[A-Za-z]+$"}", + }, + }, + }, + }, + "additionalProperties": false, + }, + "deleted": { + "$defs": { + "Person": { + "properties": { + "name": { + "pattern": undefined, + }, + }, + }, + }, + }, + "updated": {}, +} +`); + }); + + it('should remove additionalProperties: true', () => { + const input = { + type: 'object', + properties: { + name: { type: 'string' }, + }, + additionalProperties: true, + }; + + const result = transformJSONSchema(input); + const diff = detailedDiff(input, result); + expect(diff).toMatchInlineSnapshot(` +{ + "added": {}, + "deleted": {}, + "updated": { + "additionalProperties": false, + }, +} +`); + }); + + it('should set additionalProperties when missing', () => { + const input = { + type: 'object', + properties: { + name: { type: 'string' }, + }, + }; + + const result = transformJSONSchema(input); + const diff = detailedDiff(input, result); + expect(diff).toMatchInlineSnapshot(` +{ + "added": { + "additionalProperties": false, + }, + "deleted": {}, + "updated": {}, +} +`); + }); + + it('should preserve supported string formats', () => { + const input = { + type: 'object', + properties: { + email: { type: 'string', format: 'email' }, + date: { type: 'string', format: 'date-time' }, + website: { type: 'string', format: 'uri' }, + }, + }; + + const result = transformJSONSchema(input); + const diff = detailedDiff(input, result); + expect(diff).toMatchInlineSnapshot(` +{ + "added": { + "additionalProperties": false, + }, + "deleted": {}, + "updated": {}, +} +`); + }); + + it('should remove unsupported string formats', () => { + const input = { + type: 'object', + properties: { + password: { + type: 'string', + format: 'password', + description: 'User password', + }, + customField: { + type: 'string', + format: 'custom-format', + }, + }, + }; + + const result = transformJSONSchema(input); + const diff = detailedDiff(input, result); + expect(diff).toMatchInlineSnapshot(` +{ + "added": { + "additionalProperties": false, + "properties": { + "customField": { + "description": "{format: "custom-format"}", + }, + }, + }, + "deleted": { + "properties": { + "customField": { + "format": undefined, + }, + "password": { + "format": undefined, + }, + }, + }, + "updated": { + "properties": { + "password": { + "description": "User password + +{format: "password"}", + }, + }, + }, +} +`); + }); + + it('should transform all subschemas in allOf recursively', () => { + const input = { + allOf: [ + { + type: 'object', + properties: { + id: { + type: 'integer', + minimum: 1, + maximum: 999, + }, + }, + }, + { + type: 'object', + properties: { + name: { + type: 'string', + pattern: '^[A-Z]', + minLength: 2, + }, + }, + additionalProperties: true, + }, + { + type: 'object', + properties: { + tags: { + type: 'array', + items: { type: 'string' }, + minItems: 5, + }, + }, + }, + ], + }; + + const result = transformJSONSchema(input); + const diff = detailedDiff(input, result); + expect(diff).toMatchInlineSnapshot(` +{ + "added": { + "allOf": { + "0": { + "additionalProperties": false, + "properties": { + "id": { + "description": "{minimum: 1, maximum: 999}", + }, + }, + }, + "1": { + "properties": { + "name": { + "description": "{pattern: "^[A-Z]", minLength: 2}", + }, + }, + }, + "2": { + "additionalProperties": false, + "properties": { + "tags": { + "description": "{minItems: 5}", + }, + }, + }, + }, + }, + "deleted": { + "allOf": { + "0": { + "properties": { + "id": { + "maximum": undefined, + "minimum": undefined, + }, + }, + }, + "1": { + "properties": { + "name": { + "minLength": undefined, + "pattern": undefined, + }, + }, + }, + "2": { + "properties": { + "tags": { + "minItems": undefined, + }, + }, + }, + }, + }, + "updated": { + "allOf": { + "1": { + "additionalProperties": false, + }, + }, + }, +} +`); + }); +}); From 7e22f2431aec3f0c8fcecaef700c252114d3a6dc Mon Sep 17 00:00:00 2001 From: Wolf Mermelstein Date: Thu, 4 Dec 2025 11:05:44 -0500 Subject: [PATCH 2/8] add test --- package.json | 2 +- src/lib/transform-json-schema.ts | 2 - tests/lib/__snapshots__/parser.test.ts.snap | 169 +++++--------------- tests/lib/parser.test.ts | 39 +++++ 4 files changed, 79 insertions(+), 133 deletions(-) diff --git a/package.json b/package.json index e34815dd0..949bb8aaa 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/lib/transform-json-schema.ts b/src/lib/transform-json-schema.ts index b7132bb68..2df2615dc 100644 --- a/src/lib/transform-json-schema.ts +++ b/src/lib/transform-json-schema.ts @@ -1,6 +1,5 @@ import { pop } from '../internal/utils'; -// Supported string formats const SUPPORTED_STRING_FORMATS = new Set([ 'date-time', 'time', @@ -8,7 +7,6 @@ const SUPPORTED_STRING_FORMATS = new Set([ 'duration', 'email', 'hostname', - 'uri', 'ipv4', 'ipv6', 'uuid', diff --git a/tests/lib/__snapshots__/parser.test.ts.snap b/tests/lib/__snapshots__/parser.test.ts.snap index 11d68ab4e..b0ff61d33 100644 --- a/tests/lib/__snapshots__/parser.test.ts.snap +++ b/tests/lib/__snapshots__/parser.test.ts.snap @@ -1,172 +1,81 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`.parse() zod deserialises response_format 1`] = ` +exports[`.parse() allows zod v4 discriminated unions 1`] = ` "{ - "id": "chatcmpl-9uLhvwLPvKOZoJ7hwaa666fYuxYif", + "id": "chatcmpl-Cj67GpjpWEwEp7WLJFnVdeE2Kob4C", "object": "chat.completion", - "created": 1723216839, + "created": 1764864306, "model": "gpt-4o-2024-08-06", "choices": [ { "index": 0, "message": { "role": "assistant", - "content": "{\\"city\\":\\"San Francisco\\",\\"units\\":\\"c\\"}", - "refusal": null + "content": "{\\"data\\":{\\"type\\":\\"a\\"}}", + "refusal": null, + "annotations": [] }, "logprobs": null, "finish_reason": "stop" } ], "usage": { - "prompt_tokens": 17, - "completion_tokens": 10, - "total_tokens": 27 - }, - "system_fingerprint": "fp_2a322c9ffc" -} -" -`; - -exports[`.parse() zod merged schemas 2`] = ` -"{ - "id": "chatcmpl-9uLi0HJ6HYH0FM1VI1N6XCREiGvX1", - "object": "chat.completion", - "created": 1723216844, - "model": "gpt-4o-2024-08-06", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": "{\\"person1\\":{\\"name\\":\\"Jane Doe\\",\\"phone_number\\":\\".\\",\\"roles\\":[\\"other\\"],\\"description\\":\\"Engineer at OpenAI, born Nov 16, contact email: jane@openai.com\\"},\\"person2\\":{\\"name\\":\\"John Smith\\",\\"phone_number\\":\\"john@openai.com\\",\\"differentField\\":\\"Engineer at OpenAI, born March 1.\\"}}", - "refusal": null - }, - "logprobs": null, - "finish_reason": "stop" - } - ], - "usage": { - "prompt_tokens": 61, - "completion_tokens": 72, - "total_tokens": 133 - }, - "system_fingerprint": "fp_2a322c9ffc" -} -" -`; - -exports[`.parse() zod nested schema extraction 2`] = ` -"{ - "id": "chatcmpl-9uLi6hkH6VcoaYiNEzy3h56QRAyns", - "object": "chat.completion", - "created": 1723216850, - "model": "gpt-4o-2024-08-06", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": "{\\"name\\":\\"TodoApp\\",\\"fields\\":[{\\"type\\":\\"string\\",\\"name\\":\\"taskId\\",\\"metadata\\":{\\"foo\\":\\"unique identifier for each task\\"}},{\\"type\\":\\"string\\",\\"name\\":\\"title\\",\\"metadata\\":{\\"foo\\":\\"title of the task\\"}},{\\"type\\":\\"string\\",\\"name\\":\\"description\\",\\"metadata\\":{\\"foo\\":\\"detailed description of the task. This is optional.\\"}},{\\"type\\":\\"string\\",\\"name\\":\\"status\\",\\"metadata\\":{\\"foo\\":\\"status of the task, e.g., pending, completed, etc.\\"}},{\\"type\\":\\"string\\",\\"name\\":\\"dueDate\\",\\"metadata\\":null},{\\"type\\":\\"string\\",\\"name\\":\\"priority\\",\\"metadata\\":{\\"foo\\":\\"priority level of the task, e.g., low, medium, high\\"}},{\\"type\\":\\"string\\",\\"name\\":\\"creationDate\\",\\"metadata\\":{\\"foo\\":\\"date when the task was created\\"}},{\\"type\\":\\"string\\",\\"name\\":\\"lastModifiedDate\\",\\"metadata\\":{\\"foo\\":\\"date when the task was last modified\\"}},{\\"type\\":\\"string\\",\\"name\\":\\"tags\\",\\"metadata\\":{\\"foo\\":\\"tags associated with the task, for categorization\\"}}]}", - "refusal": null - }, - "logprobs": null, - "finish_reason": "stop" - } - ], - "usage": { - "prompt_tokens": 36, - "completion_tokens": 208, - "total_tokens": 244 - }, - "system_fingerprint": "fp_2a322c9ffc" -} -" -`; - -exports[`.parse() zod recursive schema extraction 2`] = ` -"{ - "id": "chatcmpl-9vdbw9dekyUSEsSKVQDhTxA2RCxcK", - "object": "chat.completion", - "created": 1723523988, - "model": "gpt-4o-2024-08-06", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": "{\\"linked_list\\":{\\"value\\":1,\\"next\\":{\\"value\\":2,\\"next\\":{\\"value\\":3,\\"next\\":{\\"value\\":4,\\"next\\":{\\"value\\":5,\\"next\\":null}}}}}}", - "refusal": null - }, - "logprobs": null, - "finish_reason": "stop" + "prompt_tokens": 115, + "completion_tokens": 7, + "total_tokens": 122, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 } - ], - "usage": { - "prompt_tokens": 40, - "completion_tokens": 38, - "total_tokens": 78 }, - "system_fingerprint": "fp_2a322c9ffc" + "service_tier": "default", + "system_fingerprint": "fp_cbf1785567" } " `; -exports[`.parse() zod ref schemas with \`.transform()\` 2`] = ` +exports[`.parse() allows zod v4 discriminated unions 3`] = ` "{ - "id": "chatcmpl-A6zyLEtubMlUvGplOmr92S0mK0kiG", + "id": "chatcmpl-Cj67H3J7eowW1OKJP2EVjiRW5Cmlg", "object": "chat.completion", - "created": 1726231553, + "created": 1764864307, "model": "gpt-4o-2024-08-06", "choices": [ { "index": 0, "message": { "role": "assistant", - "content": "{\\"first\\":{\\"baz\\":true},\\"second\\":{\\"baz\\":false}}", - "refusal": null + "content": "{\\"data\\":{\\"type\\":\\"a\\"}}", + "refusal": null, + "annotations": [] }, "logprobs": null, "finish_reason": "stop" } ], "usage": { - "prompt_tokens": 167, - "completion_tokens": 13, - "total_tokens": 180, + "prompt_tokens": 115, + "completion_tokens": 7, + "total_tokens": 122, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, "completion_tokens_details": { - "reasoning_tokens": 0 + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 } }, - "system_fingerprint": "fp_143bb8492c" -} -" -`; - -exports[`.parse() zod top-level recursive schemas 1`] = ` -"{ - "id": "chatcmpl-9uLhw79ArBF4KsQQOlsoE68m6vh6v", - "object": "chat.completion", - "created": 1723216840, - "model": "gpt-4o-2024-08-06", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": "{\\"type\\":\\"form\\",\\"label\\":\\"User Profile Form\\",\\"children\\":[{\\"type\\":\\"field\\",\\"label\\":\\"First Name\\",\\"children\\":[],\\"attributes\\":[{\\"name\\":\\"type\\",\\"value\\":\\"text\\"},{\\"name\\":\\"name\\",\\"value\\":\\"firstName\\"},{\\"name\\":\\"placeholder\\",\\"value\\":\\"Enter your first name\\"}]},{\\"type\\":\\"field\\",\\"label\\":\\"Last Name\\",\\"children\\":[],\\"attributes\\":[{\\"name\\":\\"type\\",\\"value\\":\\"text\\"},{\\"name\\":\\"name\\",\\"value\\":\\"lastName\\"},{\\"name\\":\\"placeholder\\",\\"value\\":\\"Enter your last name\\"}]},{\\"type\\":\\"field\\",\\"label\\":\\"Email Address\\",\\"children\\":[],\\"attributes\\":[{\\"name\\":\\"type\\",\\"value\\":\\"email\\"},{\\"name\\":\\"name\\",\\"value\\":\\"email\\"},{\\"name\\":\\"placeholder\\",\\"value\\":\\"Enter your email address\\"}]},{\\"type\\":\\"button\\",\\"label\\":\\"Submit\\",\\"children\\":[],\\"attributes\\":[{\\"name\\":\\"type\\",\\"value\\":\\"submit\\"}]}],\\"attributes\\":[]}", - "refusal": null - }, - "logprobs": null, - "finish_reason": "stop" - } - ], - "usage": { - "prompt_tokens": 38, - "completion_tokens": 175, - "total_tokens": 213 - }, - "system_fingerprint": "fp_845eaabc1f" + "service_tier": "default", + "system_fingerprint": "fp_cbf1785567" } " `; diff --git a/tests/lib/parser.test.ts b/tests/lib/parser.test.ts index 1aa33acf0..04e58de3c 100644 --- a/tests/lib/parser.test.ts +++ b/tests/lib/parser.test.ts @@ -1405,4 +1405,43 @@ describe.each([ `); }); }); + + it('allows zod v4 discriminated unions', async () => { + const completion = await makeSnapshotRequest( + (openai) => + openai.chat.completions.parse({ + model: 'gpt-4o', + messages: [ + { + role: 'user', + content: 'can you generate fake data matching the given response format? choose a', + }, + ], + response_format: zodResponseFormat( + z4.object({ + data: z4.discriminatedUnion('type', [ + z4.object({ type: z4.literal('a') }), + z4.object({ type: z4.literal('b') }), + ]), + }), + 'data', + ), + }), + 2, + ); + + expect(completion.choices[0]?.message).toMatchInlineSnapshot(` + { + "annotations": [], + "content": "{"data":{"type":"a"}}", + "parsed": { + "data": { + "type": "a", + }, + }, + "refusal": null, + "role": "assistant", + } + `); + }); }); From 3de111bf2ea62dac6a3b86e072baba7cb2748172 Mon Sep 17 00:00:00 2001 From: Wolf Mermelstein Date: Thu, 4 Dec 2025 11:26:52 -0500 Subject: [PATCH 3/8] update tests --- src/lib/transform-json-schema.ts | 93 ++++++++++++++------- tests/helpers/transform-json-schema.test.ts | 51 ----------- tests/lib/__snapshots__/parser.test.ts.snap | 8 +- 3 files changed, 65 insertions(+), 87 deletions(-) diff --git a/src/lib/transform-json-schema.ts b/src/lib/transform-json-schema.ts index 2df2615dc..7f6403265 100644 --- a/src/lib/transform-json-schema.ts +++ b/src/lib/transform-json-schema.ts @@ -12,6 +12,14 @@ const SUPPORTED_STRING_FORMATS = new Set([ 'uuid', ]); +const SUPPORTED_NUMBER_PROPERTIES = new Set([ + 'multipleOf', + 'maximum', + 'exclusiveMaximum', + 'minimum', + 'exclusiveMinimum', +]); + export type JSONSchema = Record; function deepClone(obj: T): T { @@ -69,41 +77,62 @@ function _transformJSONSchema(jsonSchema: JSONSchema): JSONSchema { 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; + switch (type) { + case '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; + } + break; } - } 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; + case 'string': { + const format = pop(jsonSchema, 'format'); + if (format !== undefined && SUPPORTED_STRING_FORMATS.has(format)) { + strictSchema['format'] = format; + } else if (format !== undefined) { + jsonSchema['format'] = format; + } + break; } - } else if (type === 'array') { - const items = pop(jsonSchema, 'items'); - if (items !== undefined) { - strictSchema['items'] = _transformJSONSchema(items as JSONSchema); + case 'array': { + const minItems = pop(jsonSchema, 'minItems'); + if (minItems !== undefined) { + strictSchema['minItems'] = minItems; + } + + const maxItems = pop(jsonSchema, 'maxItems'); + if (maxItems !== undefined) { + strictSchema['maxItems'] = maxItems; + } + + const items = pop(jsonSchema, 'items'); + if (items !== undefined) { + strictSchema['items'] = _transformJSONSchema(items as JSONSchema); + } + + break; } - - const minItems = pop(jsonSchema, 'minItems'); - if (minItems !== undefined && (minItems === 0 || minItems === 1)) { - strictSchema['minItems'] = minItems; - } else if (minItems !== undefined) { - jsonSchema['minItems'] = minItems; + case 'number': { + for (const key of SUPPORTED_NUMBER_PROPERTIES) { + // only load supported properties into strictSchema + const value = pop(jsonSchema, key); + if (value !== undefined) { + strictSchema[key] = value; + } + } + break; } } diff --git a/tests/helpers/transform-json-schema.test.ts b/tests/helpers/transform-json-schema.test.ts index bbd5df522..6e79edf6f 100644 --- a/tests/helpers/transform-json-schema.test.ts +++ b/tests/helpers/transform-json-schema.test.ts @@ -128,31 +128,6 @@ describe('transformJsonSchema', () => { `); }); - it('should remove unsupported minItems values (> 1)', () => { - const input = { - type: 'array', - items: { type: 'string' }, - minItems: 3, - description: 'List of items', - }; - - const result = transformJSONSchema(input); - const diff = detailedDiff(input, result); - expect(diff).toMatchInlineSnapshot(` -{ - "added": {}, - "deleted": { - "minItems": undefined, - }, - "updated": { - "description": "List of items - -{minItems: 3}", - }, -} -`); - }); - it('should handle nested objects recursively', () => { const input = { type: 'object', @@ -307,7 +282,6 @@ describe('transformJsonSchema', () => { properties: { email: { type: 'string', format: 'email' }, date: { type: 'string', format: 'date-time' }, - website: { type: 'string', format: 'uri' }, }, }; @@ -399,16 +373,6 @@ describe('transformJsonSchema', () => { }, additionalProperties: true, }, - { - type: 'object', - properties: { - tags: { - type: 'array', - items: { type: 'string' }, - minItems: 5, - }, - }, - }, ], }; @@ -433,14 +397,6 @@ describe('transformJsonSchema', () => { }, }, }, - "2": { - "additionalProperties": false, - "properties": { - "tags": { - "description": "{minItems: 5}", - }, - }, - }, }, }, "deleted": { @@ -461,13 +417,6 @@ describe('transformJsonSchema', () => { }, }, }, - "2": { - "properties": { - "tags": { - "minItems": undefined, - }, - }, - }, }, }, "updated": { diff --git a/tests/lib/__snapshots__/parser.test.ts.snap b/tests/lib/__snapshots__/parser.test.ts.snap index b0ff61d33..9f176ff6d 100644 --- a/tests/lib/__snapshots__/parser.test.ts.snap +++ b/tests/lib/__snapshots__/parser.test.ts.snap @@ -2,9 +2,9 @@ exports[`.parse() allows zod v4 discriminated unions 1`] = ` "{ - "id": "chatcmpl-Cj67GpjpWEwEp7WLJFnVdeE2Kob4C", + "id": "chatcmpl-Cj6L9EhAnLN4VdmS3WOlztNifBTib", "object": "chat.completion", - "created": 1764864306, + "created": 1764865167, "model": "gpt-4o-2024-08-06", "choices": [ { @@ -42,9 +42,9 @@ exports[`.parse() allows zod v4 discriminated unions 1`] = ` exports[`.parse() allows zod v4 discriminated unions 3`] = ` "{ - "id": "chatcmpl-Cj67H3J7eowW1OKJP2EVjiRW5Cmlg", + "id": "chatcmpl-Cj6LAo9OYmTKdatrog7EX6Eza5sWm", "object": "chat.completion", - "created": 1764864307, + "created": 1764865168, "model": "gpt-4o-2024-08-06", "choices": [ { From 4025547e94d6e2c672cba8851f7495a8477aac9a Mon Sep 17 00:00:00 2001 From: Wolf Mermelstein Date: Thu, 4 Dec 2025 11:29:57 -0500 Subject: [PATCH 4/8] add another test case --- tests/helpers/transform-json-schema.test.ts | 53 +++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/tests/helpers/transform-json-schema.test.ts b/tests/helpers/transform-json-schema.test.ts index 6e79edf6f..65c72682d 100644 --- a/tests/helpers/transform-json-schema.test.ts +++ b/tests/helpers/transform-json-schema.test.ts @@ -429,4 +429,57 @@ describe('transformJsonSchema', () => { } `); }); + + it('should filter out unsupported number properties', () => { + const input = { + type: 'object', + properties: { + score: { + type: 'number', + // Supported properties + minimum: 0, + maximum: 100, + // Unsupported properties + precision: 2, + step: 0.5, + format: 'float', + }, + }, + }; + + const result = transformJSONSchema(input); + const diff = detailedDiff(input, result); + expect(diff).toMatchInlineSnapshot(` + { + "added": { + "additionalProperties": false, + "properties": { + "score": { + "description": "{precision: 2, step: 0.5, format: "float"}", + }, + }, + }, + "deleted": { + "properties": { + "score": { + "format": undefined, + "precision": undefined, + "step": undefined, + }, + }, + }, + "updated": {}, + } + `); + + expect(result['properties']['score'].minimum).toBe(0); + expect(result['properties']['score'].maximum).toBe(100); + + expect(result['properties']['score'].precision).toBeUndefined(); + expect(result['properties']['score'].step).toBeUndefined(); + expect(result['properties']['score'].format).toBeUndefined(); + expect(result['properties']['score'].description).toContain('precision: 2'); + expect(result['properties']['score'].description).toContain('step: 0.5'); + expect(result['properties']['score'].description).toContain('format: "float"'); + }); }); From dd4012ea7e129349a7bb63c22a28eb0a1f618a90 Mon Sep 17 00:00:00 2001 From: Wolf Mermelstein Date: Thu, 4 Dec 2025 11:50:38 -0500 Subject: [PATCH 5/8] update test --- src/lib/transform-json-schema.ts | 131 +----- tests/helpers/transform-json-schema.test.ts | 480 +++----------------- 2 files changed, 92 insertions(+), 519 deletions(-) diff --git a/src/lib/transform-json-schema.ts b/src/lib/transform-json-schema.ts index 7f6403265..9f350189f 100644 --- a/src/lib/transform-json-schema.ts +++ b/src/lib/transform-json-schema.ts @@ -1,25 +1,5 @@ import { pop } from '../internal/utils'; -const SUPPORTED_STRING_FORMATS = new Set([ - 'date-time', - 'time', - 'date', - 'duration', - 'email', - 'hostname', - 'ipv4', - 'ipv6', - 'uuid', -]); - -const SUPPORTED_NUMBER_PROPERTIES = new Set([ - 'multipleOf', - 'maximum', - 'exclusiveMaximum', - 'minimum', - 'exclusiveMinimum', -]); - export type JSONSchema = Record; function deepClone(obj: T): T { @@ -32,120 +12,51 @@ export function transformJSONSchema(jsonSchema: JSONSchema): JSONSchema { } 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 = {}; - strictSchema['$defs'] = strictDefs; + jsonSchema['$defs'] = strictDefs; for (const [name, defSchema] of Object.entries(defs)) { strictDefs[name] = _transformJSONSchema(defSchema as JSONSchema); } } - const type = pop(jsonSchema, 'type'); + const type = 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)); + jsonSchema['anyOf'] = anyOf.map((variant) => _transformJSONSchema(variant as JSONSchema)); } else if (Array.isArray(oneOf)) { - strictSchema['anyOf'] = oneOf.map((variant) => _transformJSONSchema(variant as JSONSchema)); + jsonSchema['anyOf'] = oneOf.map((variant) => _transformJSONSchema(variant as JSONSchema)); } else if (Array.isArray(allOf)) { - strictSchema['allOf'] = allOf.map((entry) => _transformJSONSchema(entry as JSONSchema)); + jsonSchema['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; - } - - switch (type) { - case '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; + switch (type) { + case 'object': { + const properties = pop(jsonSchema, 'properties') || {}; + jsonSchema['properties'] = Object.fromEntries( + Object.entries(properties).map(([key, propSchema]) => [ + key, + _transformJSONSchema(propSchema as JSONSchema), + ]), + ); + break; } - break; - } - case 'string': { - const format = pop(jsonSchema, 'format'); - if (format !== undefined && SUPPORTED_STRING_FORMATS.has(format)) { - strictSchema['format'] = format; - } else if (format !== undefined) { - jsonSchema['format'] = format; - } - break; - } - case 'array': { - const minItems = pop(jsonSchema, 'minItems'); - if (minItems !== undefined) { - strictSchema['minItems'] = minItems; - } - - const maxItems = pop(jsonSchema, 'maxItems'); - if (maxItems !== undefined) { - strictSchema['maxItems'] = maxItems; - } - - const items = pop(jsonSchema, 'items'); - if (items !== undefined) { - strictSchema['items'] = _transformJSONSchema(items as JSONSchema); - } - - break; - } - case 'number': { - for (const key of SUPPORTED_NUMBER_PROPERTIES) { - // only load supported properties into strictSchema - const value = pop(jsonSchema, key); - if (value !== undefined) { - strictSchema[key] = value; + case 'array': { + const items = pop(jsonSchema, 'items'); + if (items !== undefined) { + jsonSchema['items'] = _transformJSONSchema(items as JSONSchema); } + break; } - break; } } - 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; + return jsonSchema; } diff --git a/tests/helpers/transform-json-schema.test.ts b/tests/helpers/transform-json-schema.test.ts index 65c72682d..6a2222b82 100644 --- a/tests/helpers/transform-json-schema.test.ts +++ b/tests/helpers/transform-json-schema.test.ts @@ -1,4 +1,3 @@ -import { detailedDiff } from 'deep-object-diff'; import { transformJSONSchema } from '../../src/lib/transform-json-schema'; describe('transformJsonSchema', () => { @@ -30,456 +29,119 @@ describe('transformJsonSchema', () => { expect(input).toEqual(inputCopy); }); - it('should remove unsupported properties and add them to description', () => { + it('should turn a discriminated union oneOf into an anyOf', () => { const input = { type: 'object', - properties: { - bonus: { - type: 'integer', - default: 100000, - minimum: 100000, - title: 'Bonus', - description: 'Annual bonus in USD', - }, - }, - title: 'Employee', - }; - - const result = transformJSONSchema(input); - const diff = detailedDiff(input, result); - expect(diff).toMatchInlineSnapshot(` -{ - "added": { - "additionalProperties": false, - }, - "deleted": { - "properties": { - "bonus": { - "default": undefined, - "minimum": undefined, - }, - }, - }, - "updated": { - "properties": { - "bonus": { - "description": "Annual bonus in USD - -{default: 100000, minimum: 100000}", - }, - }, - }, -} -`); - }); - - it('should handle objects without existing description', () => { - const input = { - type: 'object', - properties: { - count: { - type: 'integer', - maximum: 10, - minimum: 1, - }, - }, - }; - - const result = transformJSONSchema(input); - const diff = detailedDiff(input, result); - expect(diff).toMatchInlineSnapshot(` -{ - "added": { - "additionalProperties": false, - "properties": { - "count": { - "description": "{maximum: 10, minimum: 1}", - }, - }, - }, - "deleted": { - "properties": { - "count": { - "maximum": undefined, - "minimum": undefined, - }, - }, - }, - "updated": {}, -} -`); - }); - - it('should preserve supported minItems values (0 and 1)', () => { - const input = { - type: 'array', - items: { type: 'string' }, - minItems: 1, - }; - - const result = transformJSONSchema(input); - const diff = detailedDiff(input, result); - expect(diff).toMatchInlineSnapshot(` -{ - "added": {}, - "deleted": {}, - "updated": {}, -} -`); - }); - - it('should handle nested objects recursively', () => { - const input = { - type: 'object', - properties: { - user: { + oneOf: [ + { type: 'object', properties: { - age: { + bonus: { type: 'integer', - minimum: 0, - maximum: 120, }, }, }, - }, - }; - - const result = transformJSONSchema(input); - const diff = detailedDiff(input, result); - expect(diff).toMatchInlineSnapshot(` -{ - "added": { - "additionalProperties": false, - "properties": { - "user": { - "additionalProperties": false, - "properties": { - "age": { - "description": "{minimum: 0, maximum: 120}", - }, - }, - }, - }, - }, - "deleted": { - "properties": { - "user": { - "properties": { - "age": { - "maximum": undefined, - "minimum": undefined, - }, - }, - }, - }, - }, - "updated": {}, -} -`); - }); - - it('should handle $defs and definitions recursively', () => { - const input = { - type: 'object', - $defs: { - Person: { + { type: 'object', properties: { - name: { - type: 'string', - pattern: '^[A-Za-z]+$', + salary: { + type: 'integer', }, }, }, - }, - properties: { - person: { - $ref: '#/$defs/Person', - }, - }, - }; - - const result = transformJSONSchema(input); - const diff = detailedDiff(input, result); - expect(diff).toMatchInlineSnapshot(` -{ - "added": { - "$defs": { - "Person": { - "additionalProperties": false, - "properties": { - "name": { - "description": "{pattern: "^[A-Za-z]+$"}", - }, - }, - }, - }, - "additionalProperties": false, - }, - "deleted": { - "$defs": { - "Person": { - "properties": { - "name": { - "pattern": undefined, - }, - }, - }, - }, - }, - "updated": {}, -} -`); - }); - - it('should remove additionalProperties: true', () => { - const input = { - type: 'object', - properties: { - name: { type: 'string' }, - }, - additionalProperties: true, - }; - - const result = transformJSONSchema(input); - const diff = detailedDiff(input, result); - expect(diff).toMatchInlineSnapshot(` -{ - "added": {}, - "deleted": {}, - "updated": { - "additionalProperties": false, - }, -} -`); - }); - - it('should set additionalProperties when missing', () => { - const input = { - type: 'object', - properties: { - name: { type: 'string' }, - }, - }; - - const result = transformJSONSchema(input); - const diff = detailedDiff(input, result); - expect(diff).toMatchInlineSnapshot(` -{ - "added": { - "additionalProperties": false, - }, - "deleted": {}, - "updated": {}, -} -`); - }); - - it('should preserve supported string formats', () => { - const input = { - type: 'object', - properties: { - email: { type: 'string', format: 'email' }, - date: { type: 'string', format: 'date-time' }, - }, + ], }; - const result = transformJSONSchema(input); - const diff = detailedDiff(input, result); - expect(diff).toMatchInlineSnapshot(` -{ - "added": { - "additionalProperties": false, - }, - "deleted": {}, - "updated": {}, -} -`); - }); - - it('should remove unsupported string formats', () => { - const input = { + const expected = { type: 'object', - properties: { - password: { - type: 'string', - format: 'password', - description: 'User password', - }, - customField: { - type: 'string', - format: 'custom-format', - }, - }, - }; - - const result = transformJSONSchema(input); - const diff = detailedDiff(input, result); - expect(diff).toMatchInlineSnapshot(` -{ - "added": { - "additionalProperties": false, - "properties": { - "customField": { - "description": "{format: "custom-format"}", - }, - }, - }, - "deleted": { - "properties": { - "customField": { - "format": undefined, - }, - "password": { - "format": undefined, - }, - }, - }, - "updated": { - "properties": { - "password": { - "description": "User password - -{format: "password"}", - }, - }, - }, -} -`); - }); - - it('should transform all subschemas in allOf recursively', () => { - const input = { - allOf: [ + anyOf: [ { type: 'object', properties: { - id: { + bonus: { type: 'integer', - minimum: 1, - maximum: 999, }, }, }, { type: 'object', properties: { - name: { - type: 'string', - pattern: '^[A-Z]', - minLength: 2, + salary: { + type: 'integer', }, }, - additionalProperties: true, }, ], }; - const result = transformJSONSchema(input); - const diff = detailedDiff(input, result); - expect(diff).toMatchInlineSnapshot(` -{ - "added": { - "allOf": { - "0": { - "additionalProperties": false, - "properties": { - "id": { - "description": "{minimum: 1, maximum: 999}", - }, - }, - }, - "1": { - "properties": { - "name": { - "description": "{pattern: "^[A-Z]", minLength: 2}", - }, - }, - }, - }, - }, - "deleted": { - "allOf": { - "0": { - "properties": { - "id": { - "maximum": undefined, - "minimum": undefined, - }, - }, - }, - "1": { - "properties": { - "name": { - "minLength": undefined, - "pattern": undefined, - }, - }, - }, - }, - }, - "updated": { - "allOf": { - "1": { - "additionalProperties": false, - }, - }, - }, -} -`); + const transformedSchema = transformJSONSchema(input); + + expect(transformedSchema).toEqual(expected); }); - it('should filter out unsupported number properties', () => { + it('should turn oneOf into anyOf in recursive object in list', () => { const input = { type: 'object', properties: { - score: { - type: 'number', - // Supported properties - minimum: 0, - maximum: 100, - // Unsupported properties - precision: 2, - step: 0.5, - format: 'float', + employees: { + type: 'array', + items: { + type: 'object', + oneOf: [ + { + type: 'object', + properties: { + bonus: { + type: 'integer', + }, + }, + }, + { + type: 'object', + properties: { + salary: { + type: 'integer', + }, + }, + }, + ], + }, }, }, }; - const result = transformJSONSchema(input); - const diff = detailedDiff(input, result); - expect(diff).toMatchInlineSnapshot(` - { - "added": { - "additionalProperties": false, - "properties": { - "score": { - "description": "{precision: 2, step: 0.5, format: "float"}", - }, - }, - }, - "deleted": { - "properties": { - "score": { - "format": undefined, - "precision": undefined, - "step": undefined, - }, - }, - }, - "updated": {}, - } - `); + const expected = { + type: 'object', + properties: { + employees: { + type: 'array', + items: { + type: 'object', + anyOf: [ + { + type: 'object', + properties: { + bonus: { + type: 'integer', + }, + }, + }, + { + type: 'object', + properties: { + salary: { + type: 'integer', + }, + }, + }, + ], + }, + }, + }, + }; - expect(result['properties']['score'].minimum).toBe(0); - expect(result['properties']['score'].maximum).toBe(100); + const transformedSchema = transformJSONSchema(input); - expect(result['properties']['score'].precision).toBeUndefined(); - expect(result['properties']['score'].step).toBeUndefined(); - expect(result['properties']['score'].format).toBeUndefined(); - expect(result['properties']['score'].description).toContain('precision: 2'); - expect(result['properties']['score'].description).toContain('step: 0.5'); - expect(result['properties']['score'].description).toContain('format: "float"'); + expect(transformedSchema).toEqual(expected); }); }); From e75e41f15cb8844f5ca846ee8830e31c4c036285 Mon Sep 17 00:00:00 2001 From: Wolf Mermelstein Date: Thu, 4 Dec 2025 11:53:07 -0500 Subject: [PATCH 6/8] add test case --- tests/helpers/transform-json-schema.test.ts | 22 +++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/helpers/transform-json-schema.test.ts b/tests/helpers/transform-json-schema.test.ts index 6a2222b82..d5be8bbd8 100644 --- a/tests/helpers/transform-json-schema.test.ts +++ b/tests/helpers/transform-json-schema.test.ts @@ -144,4 +144,26 @@ describe('transformJsonSchema', () => { 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', + ); + }); }); From 84d211d977da81950a475602e8030b27daf9f270 Mon Sep 17 00:00:00 2001 From: Wolf Mermelstein Date: Thu, 4 Dec 2025 14:34:41 -0500 Subject: [PATCH 7/8] move to internal --- src/{lib => internal}/transform-json-schema.ts | 2 +- tests/helpers/transform-json-schema.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename src/{lib => internal}/transform-json-schema.ts (97%) diff --git a/src/lib/transform-json-schema.ts b/src/internal/transform-json-schema.ts similarity index 97% rename from src/lib/transform-json-schema.ts rename to src/internal/transform-json-schema.ts index 9f350189f..3f2dff36f 100644 --- a/src/lib/transform-json-schema.ts +++ b/src/internal/transform-json-schema.ts @@ -1,4 +1,4 @@ -import { pop } from '../internal/utils'; +import { pop } from './utils'; export type JSONSchema = Record; diff --git a/tests/helpers/transform-json-schema.test.ts b/tests/helpers/transform-json-schema.test.ts index d5be8bbd8..52fc95437 100644 --- a/tests/helpers/transform-json-schema.test.ts +++ b/tests/helpers/transform-json-schema.test.ts @@ -1,4 +1,4 @@ -import { transformJSONSchema } from '../../src/lib/transform-json-schema'; +import { transformJSONSchema } from '../../src/internal/transform-json-schema'; describe('transformJsonSchema', () => { it('should not mutate the original schema', () => { From 1874dcdf3fa3461298faed0c81874403470cedf2 Mon Sep 17 00:00:00 2001 From: Wolf Mermelstein Date: Thu, 4 Dec 2025 14:55:49 -0500 Subject: [PATCH 8/8] add additionalProperties --- src/internal/transform-json-schema.ts | 75 ++++++++++++++------- tests/helpers/transform-json-schema.test.ts | 35 ++++++++++ 2 files changed, 84 insertions(+), 26 deletions(-) diff --git a/src/internal/transform-json-schema.ts b/src/internal/transform-json-schema.ts index 3f2dff36f..2200ec870 100644 --- a/src/internal/transform-json-schema.ts +++ b/src/internal/transform-json-schema.ts @@ -1,6 +1,5 @@ -import { pop } from './utils'; - -export type JSONSchema = Record; +import type { JSONSchema } from '../lib/jsonschema'; +import { pop } from '../internal/utils'; function deepClone(obj: T): T { return JSON.parse(JSON.stringify(obj)); @@ -12,49 +11,73 @@ export function transformJSONSchema(jsonSchema: JSONSchema): JSONSchema { } 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 = {}; - jsonSchema['$defs'] = strictDefs; + const strictDefs: Record = {}; + jsonSchema.$defs = strictDefs; for (const [name, defSchema] of Object.entries(defs)) { strictDefs[name] = _transformJSONSchema(defSchema as JSONSchema); } } - const type = jsonSchema['type']; + 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'); + } if (Array.isArray(anyOf)) { - jsonSchema['anyOf'] = anyOf.map((variant) => _transformJSONSchema(variant as JSONSchema)); - } else if (Array.isArray(oneOf)) { - jsonSchema['anyOf'] = oneOf.map((variant) => _transformJSONSchema(variant as JSONSchema)); - } else if (Array.isArray(allOf)) { - jsonSchema['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'); - } + 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': { - const properties = pop(jsonSchema, 'properties') || {}; - jsonSchema['properties'] = Object.fromEntries( + switch (type) { + case 'object': { + 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; + break; + } + case 'array': { + const items = pop(jsonSchema, 'items'); + if (items !== undefined) { + jsonSchema.items = _transformJSONSchema(items as JSONSchema); } + break; } } diff --git a/tests/helpers/transform-json-schema.test.ts b/tests/helpers/transform-json-schema.test.ts index 52fc95437..37b825027 100644 --- a/tests/helpers/transform-json-schema.test.ts +++ b/tests/helpers/transform-json-schema.test.ts @@ -166,4 +166,39 @@ describe('transformJsonSchema', () => { '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); + }); });