From c4080eb88248dfbbcdccdb241ba1946e80cfb6de Mon Sep 17 00:00:00 2001 From: Hayden Date: Mon, 13 Jul 2026 16:27:21 -0700 Subject: [PATCH 01/35] feat(helpers): add standard schema support --- jsr.json | 1 + src/helpers/standard-schema.ts | 248 ++++++++++++++++++++++++++ src/lib/transform.ts | 2 +- tests/helpers/standard-schema.test.ts | 218 ++++++++++++++++++++++ tests/helpers/zod.test.ts | 4 +- tests/lib/transform.test.ts | 2 +- 6 files changed, 471 insertions(+), 4 deletions(-) create mode 100644 src/helpers/standard-schema.ts create mode 100644 tests/helpers/standard-schema.test.ts diff --git a/jsr.json b/jsr.json index b7add0eabb..30c7a13032 100644 --- a/jsr.json +++ b/jsr.json @@ -5,6 +5,7 @@ ".": "./index.ts", "./providers/bedrock": "./providers/bedrock.ts", "./providers/bedrock/aws": "./providers/bedrock/aws.ts", + "./helpers/standard-schema": "./helpers/standard-schema.ts", "./helpers/zod": "./helpers/zod.ts", "./beta/realtime/websocket": "./beta/realtime/websocket.ts" }, diff --git a/src/helpers/standard-schema.ts b/src/helpers/standard-schema.ts new file mode 100644 index 0000000000..d6c0cd2d52 --- /dev/null +++ b/src/helpers/standard-schema.ts @@ -0,0 +1,248 @@ +import { OpenAIError } from '../error'; +import { + AutoParseableResponseFormat, + AutoParseableTextFormat, + AutoParseableTool, + makeParseableResponseFormat, + makeParseableTextFormat, + makeParseableTool, +} from '../lib/parser'; +import { AutoParseableResponseTool, makeParseableResponseTool } from '../lib/ResponsesParser'; +import { type JSONSchema } from '../lib/jsonschema'; +import { toStrictJsonSchema } from '../lib/transform'; +import { ResponseFormatJSONSchema } from '../resources/index'; +import { type ResponseFormatTextJSONSchemaConfig } from '../resources/responses/responses'; + +type StandardSchemaIssue = { + readonly message: string; + readonly path?: ReadonlyArray | undefined; +}; + +type StandardSchemaResult = + | { + readonly value: Output; + readonly issues?: undefined; + } + | { + readonly issues: ReadonlyArray; + }; + +type StandardJSONSchemaOptions = { + readonly target: string; + readonly libraryOptions?: Record | undefined; +}; + +type StandardSchemaLike = { + readonly '~standard': { + readonly version: 1; + readonly vendor: string; + readonly types?: + | { + readonly input: Input; + readonly output: Output; + } + | undefined; + readonly validate: ( + value: unknown, + ) => StandardSchemaResult | Promise>; + readonly jsonSchema?: + | { + readonly input: (options: StandardJSONSchemaOptions) => Record; + readonly output?: (options: StandardJSONSchemaOptions) => Record; + } + | undefined; + }; +}; + +type InferStandardOutput = NonNullable< + Schema['~standard']['types'] +>['output']; + +type StandardSchemaJSONSchemaProps = { + /** + * A JSON Schema override for Standard Schema implementations that do not + * expose `~standard.jsonSchema.input()`. + */ + schema?: JSONSchema | Record | undefined; +}; + +type StandardResponseFormatProps = Omit & + StandardSchemaJSONSchemaProps; + +type StandardTextFormatProps = Omit< + ResponseFormatTextJSONSchemaConfig, + 'schema' | 'type' | 'strict' | 'name' +> & + StandardSchemaJSONSchemaProps; + +type StandardToolOptions = { + name: string; + parameters: Parameters; + /** + * A JSON Schema override for Standard Schema implementations that do not + * expose `~standard.jsonSchema.input()`. + */ + schema?: JSONSchema | Record | undefined; + function?: ((args: InferStandardOutput) => unknown | Promise) | undefined; + description?: string | undefined; +}; + +function isPromiseLike(value: unknown): value is PromiseLike { + return typeof value === 'object' && value !== null && 'then' in value && typeof value.then === 'function'; +} + +function formatStandardSchemaIssues(issues: ReadonlyArray): string { + return issues + .map((issue) => { + const path = issue.path + ?.map((segment) => + typeof segment === 'object' && segment !== null && 'key' in segment ? segment.key : segment, + ) + .map(String) + .join('.'); + return path ? `${path}: ${issue.message}` : issue.message; + }) + .join('; '); +} + +function parseStandardSchema( + standardSchema: Schema, + content: string, +): InferStandardOutput { + const result = standardSchema['~standard'].validate(JSON.parse(content)); + + if (isPromiseLike(result)) { + throw new OpenAIError( + 'Standard Schema helpers only support synchronous validation. Use a schema with a synchronous `~standard.validate()` implementation.', + ); + } + + if (result.issues) { + throw new OpenAIError(`Standard Schema validation failed: ${formatStandardSchemaIssues(result.issues)}`); + } + + return result.value as InferStandardOutput; +} + +function resolveStandardJSONSchema( + standardSchema: StandardSchemaLike, + schemaOverride?: JSONSchema | Record | undefined, +): Record { + const schema = (schemaOverride ?? standardSchema['~standard'].jsonSchema?.input({ target: 'draft-07' })) as + | JSONSchema + | undefined; + + if (!schema) { + throw new OpenAIError( + 'Standard Schema helpers require a JSON Schema. Pass `schema` or use a schema that implements `~standard.jsonSchema.input()`.', + ); + } + + return toStrictJsonSchema(schema) as unknown as Record; +} + +/** + * Creates a chat completion `JSONSchema` response format from a Standard + * Schema validator. + * + * The helper uses `~standard.jsonSchema.input()` for the model-facing schema + * and `~standard.validate()` for parsed output. Validation must be + * synchronous because the SDK's parse helpers are synchronous. + */ +export function standardResponseFormat( + standardSchema: Schema, + name: string, + props?: StandardResponseFormatProps, +): AutoParseableResponseFormat> { + const { schema, ...formatProps } = props ?? {}; + + return makeParseableResponseFormat>( + { + type: 'json_schema', + json_schema: { + ...formatProps, + name, + strict: true, + schema: resolveStandardJSONSchema(standardSchema, schema), + }, + }, + (content) => parseStandardSchema(standardSchema, content), + ); +} + +/** + * Creates a Responses API `json_schema` text format from a Standard Schema + * validator. + */ +export function standardTextFormat( + standardSchema: Schema, + name: string, + props?: StandardTextFormatProps, +): AutoParseableTextFormat> { + const { schema, ...formatProps } = props ?? {}; + + return makeParseableTextFormat>( + { + type: 'json_schema', + ...formatProps, + name, + strict: true, + schema: resolveStandardJSONSchema(standardSchema, schema), + }, + (content) => parseStandardSchema(standardSchema, content), + ); +} + +/** + * Creates a chat completion `function` tool from a Standard Schema + * validator. + */ +export function standardFunction( + options: StandardToolOptions, +): AutoParseableTool<{ + arguments: InferStandardOutput; + name: string; + function: (args: InferStandardOutput) => unknown; +}> { + // @ts-expect-error TODO + return makeParseableTool( + { + type: 'function', + function: { + name: options.name, + parameters: resolveStandardJSONSchema(options.parameters, options.schema), + strict: true, + ...(options.description ? { description: options.description } : undefined), + }, + }, + { + callback: options.function, + parser: (args) => parseStandardSchema(options.parameters, args), + }, + ); +} + +/** + * Creates a Responses API `function` tool from a Standard Schema validator. + */ +export function standardResponsesFunction( + options: StandardToolOptions, +): AutoParseableResponseTool<{ + arguments: InferStandardOutput; + name: string; + function: (args: InferStandardOutput) => unknown; +}> { + return makeParseableResponseTool( + { + type: 'function', + name: options.name, + parameters: resolveStandardJSONSchema(options.parameters, options.schema), + strict: true, + ...(options.description ? { description: options.description } : undefined), + }, + { + callback: options.function, + parser: (args) => parseStandardSchema(options.parameters, args), + }, + ); +} diff --git a/src/lib/transform.ts b/src/lib/transform.ts index d241c215cb..63cc260794 100644 --- a/src/lib/transform.ts +++ b/src/lib/transform.ts @@ -78,7 +78,7 @@ function ensureStrictJsonSchema( for (const [key, value] of Object.entries(properties)) { if (!isNullable(value) && !required.includes(key)) { throw new Error( - `Zod field at \`${[...path, 'properties', key].join( + `Schema field at \`${[...path, 'properties', key].join( '/', )}\` uses \`.optional()\` without \`.nullable()\` which is not supported by the API. See: https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses#all-fields-must-be-required`, ); diff --git a/tests/helpers/standard-schema.test.ts b/tests/helpers/standard-schema.test.ts new file mode 100644 index 0000000000..4ea0cc27d5 --- /dev/null +++ b/tests/helpers/standard-schema.test.ts @@ -0,0 +1,218 @@ +import { + standardFunction, + standardResponseFormat, + standardResponsesFunction, + standardTextFormat, +} from 'openai/helpers/standard-schema'; +import type { JSONSchema } from 'openai/lib/jsonschema'; +import { compareType, expectType } from '../utils/typing'; + +type WeatherInput = { + city: string; + unit: 'c' | 'f'; +}; + +type WeatherOutput = WeatherInput & { + normalized: true; +}; + +const weatherJSONSchema: JSONSchema = { + type: 'object', + properties: { + city: { type: 'string' }, + unit: { type: 'string', enum: ['c', 'f'] }, + }, + required: ['city', 'unit'], +}; + +const strictWeatherJSONSchema: JSONSchema = { + ...weatherJSONSchema, + additionalProperties: false, +}; + +function validateWeather(value: unknown) { + if ( + typeof value === 'object' && + value !== null && + 'city' in value && + typeof value.city === 'string' && + 'unit' in value && + (value.unit === 'c' || value.unit === 'f') + ) { + return { + value: { + city: value.city, + unit: value.unit, + normalized: true as const, + }, + }; + } + + return { + issues: [{ message: 'expected weather input', path: ['city'] }], + }; +} + +function makeStandardSchema() { + const input = jest.fn(() => weatherJSONSchema as Record); + const output = jest.fn(() => ({ type: 'string' })); + + return { + input, + output, + standardSchema: { + '~standard': { + version: 1 as const, + vendor: 'test', + types: undefined as unknown as { + input: WeatherInput; + output: WeatherOutput; + }, + validate: validateWeather, + jsonSchema: { input, output }, + }, + }, + }; +} + +function makeValidationOnlySchema() { + return { + '~standard': { + version: 1 as const, + vendor: 'test', + types: undefined as unknown as { + input: WeatherInput; + output: WeatherOutput; + }, + validate: validateWeather, + }, + }; +} + +describe('Standard Schema helpers', () => { + it('uses the input JSON Schema for parseable response formats', () => { + const { standardSchema, input, output } = makeStandardSchema(); + + const responseFormat = standardResponseFormat(standardSchema, 'weather'); + const textFormat = standardTextFormat(standardSchema, 'weather'); + + expect(responseFormat.json_schema.schema).toEqual(strictWeatherJSONSchema); + expect(textFormat.schema).toEqual(strictWeatherJSONSchema); + expect(responseFormat.$parseRaw('{"city":"Paris","unit":"c"}')).toEqual({ + city: 'Paris', + unit: 'c', + normalized: true, + }); + expect(textFormat.$parseRaw('{"city":"Paris","unit":"c"}')).toEqual({ + city: 'Paris', + unit: 'c', + normalized: true, + }); + expect(input).toHaveBeenCalledTimes(2); + expect(input).toHaveBeenCalledWith({ target: 'draft-07' }); + expect(output).not.toHaveBeenCalled(); + }); + + it('builds parseable function tools', () => { + const { standardSchema } = makeStandardSchema(); + + const chatTool = standardFunction({ + name: 'get_weather', + description: 'Get the current weather', + parameters: standardSchema, + }); + const responseTool = standardResponsesFunction({ + name: 'get_weather', + description: 'Get the current weather', + parameters: standardSchema, + }); + + expect(chatTool.function).toEqual({ + name: 'get_weather', + description: 'Get the current weather', + parameters: strictWeatherJSONSchema, + strict: true, + }); + expect(responseTool).toMatchObject({ + type: 'function', + name: 'get_weather', + description: 'Get the current weather', + parameters: strictWeatherJSONSchema, + strict: true, + }); + expect(chatTool.$parseRaw('{"city":"Paris","unit":"c"}')).toEqual({ + city: 'Paris', + unit: 'c', + normalized: true, + }); + expect(responseTool.$parseRaw('{"city":"Paris","unit":"c"}')).toEqual({ + city: 'Paris', + unit: 'c', + normalized: true, + }); + }); + + it('accepts a caller-supplied JSON Schema when no converter is available', () => { + const standardSchema = makeValidationOnlySchema(); + + const format = standardResponseFormat(standardSchema, 'weather', { + schema: weatherJSONSchema, + }); + + expect(format.json_schema.schema).toEqual(strictWeatherJSONSchema); + }); + + it('throws an actionable error when no JSON Schema is available', () => { + const standardSchema = makeValidationOnlySchema(); + + expect(() => standardResponseFormat(standardSchema, 'weather')).toThrow( + 'Standard Schema helpers require a JSON Schema', + ); + }); + + it('surfaces validation issues', () => { + const { standardSchema } = makeStandardSchema(); + const format = standardResponseFormat(standardSchema, 'weather'); + + expect(() => format.$parseRaw('{"city":123,"unit":"c"}')).toThrow( + 'Standard Schema validation failed: city: expected weather input', + ); + }); + + it('rejects asynchronous validators', () => { + const { standardSchema } = makeStandardSchema(); + const asyncSchema = { + ...standardSchema, + '~standard': { + ...standardSchema['~standard'], + validate: async (value: unknown) => validateWeather(value), + }, + }; + const format = standardResponseFormat(asyncSchema, 'weather'); + + expect(() => format.$parseRaw('{"city":"Paris","unit":"c"}')).toThrow( + 'Standard Schema helpers only support synchronous validation', + ); + }); +}); + +function _typeTests() { + const { standardSchema } = makeStandardSchema(); + + expectType(standardResponseFormat(standardSchema, 'weather').__output); + expectType(standardTextFormat(standardSchema, 'weather').__output); + + const chatTool = standardFunction({ + name: 'get_weather', + parameters: standardSchema, + function: (args) => expectType(args), + }); + const responseTool = standardResponsesFunction({ + name: 'get_weather', + parameters: standardSchema, + function: (args) => expectType(args), + }); + + compareType, WeatherOutput>(true); + compareType, WeatherOutput>(true); +} diff --git a/tests/helpers/zod.test.ts b/tests/helpers/zod.test.ts index 74c2055f4a..d9f9434c84 100644 --- a/tests/helpers/zod.test.ts +++ b/tests/helpers/zod.test.ts @@ -463,7 +463,7 @@ describe.each([ 'schema', ), ).toThrowErrorMatchingInlineSnapshot( - `"Zod field at \`properties/optional\` uses \`.optional()\` without \`.nullable()\` which is not supported by the API. See: https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses#all-fields-must-be-required"`, + `"Schema field at \`properties/optional\` uses \`.optional()\` without \`.nullable()\` which is not supported by the API. See: https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses#all-fields-must-be-required"`, ); } }); @@ -489,7 +489,7 @@ describe.each([ 'schema', ), ).toThrowErrorMatchingInlineSnapshot( - `"Zod field at \`properties/foo/properties/bar/items/properties/can_be_missing\` uses \`.optional()\` without \`.nullable()\` which is not supported by the API. See: https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses#all-fields-must-be-required"`, + `"Schema field at \`properties/foo/properties/bar/items/properties/can_be_missing\` uses \`.optional()\` without \`.nullable()\` which is not supported by the API. See: https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses#all-fields-must-be-required"`, ); } }); diff --git a/tests/lib/transform.test.ts b/tests/lib/transform.test.ts index 87195d998d..4c1e81a2de 100644 --- a/tests/lib/transform.test.ts +++ b/tests/lib/transform.test.ts @@ -139,7 +139,7 @@ describe('toStrictJsonSchema', () => { }; expect(() => toStrictJsonSchema(schema)).toThrow( - 'Zod field at `properties/name` uses `.optional()` without `.nullable()` which is not supported by the API', + 'Schema field at `properties/name` uses `.optional()` without `.nullable()` which is not supported by the API', ); }); }); From 86fdd75d102d54d8c25ea0d75b31c100da88e63a Mon Sep 17 00:00:00 2001 From: Hayden Date: Tue, 14 Jul 2026 21:00:51 +0000 Subject: [PATCH 02/35] fix(helpers): normalize standard schema unions --- src/helpers/standard-schema.ts | 32 ++++++++++++++- tests/helpers/standard-schema.test.ts | 58 ++++++++++++++++++++++++++- 2 files changed, 87 insertions(+), 3 deletions(-) diff --git a/src/helpers/standard-schema.ts b/src/helpers/standard-schema.ts index d6c0cd2d52..c46c2a6b48 100644 --- a/src/helpers/standard-schema.ts +++ b/src/helpers/standard-schema.ts @@ -105,6 +105,36 @@ function formatStandardSchemaIssues(issues: ReadonlyArray): .join('; '); } +function normalizeStructuredOutputSchema(schema: JSONSchema): JSONSchema { + const normalizedSchema = structuredClone(schema); + + const visit = (value: unknown): void => { + if (!value || typeof value !== 'object') return; + + if (Array.isArray(value)) { + for (const child of value) visit(child); + return; + } + + const record = value as Record; + if (Array.isArray(record['oneOf'])) { + if (record['anyOf'] !== undefined) { + throw new OpenAIError( + 'Standard JSON Schema generated both `anyOf` and `oneOf`, which cannot be represented in an OpenAI strict schema', + ); + } + + record['anyOf'] = record['oneOf']; + delete record['oneOf']; + } + + for (const child of Object.values(record)) visit(child); + }; + + visit(normalizedSchema); + return normalizedSchema; +} + function parseStandardSchema( standardSchema: Schema, content: string, @@ -138,7 +168,7 @@ function resolveStandardJSONSchema( ); } - return toStrictJsonSchema(schema) as unknown as Record; + return toStrictJsonSchema(normalizeStructuredOutputSchema(schema)) as unknown as Record; } /** diff --git a/tests/helpers/standard-schema.test.ts b/tests/helpers/standard-schema.test.ts index 4ea0cc27d5..940699bebe 100644 --- a/tests/helpers/standard-schema.test.ts +++ b/tests/helpers/standard-schema.test.ts @@ -53,8 +53,10 @@ function validateWeather(value: unknown) { }; } -function makeStandardSchema() { - const input = jest.fn(() => weatherJSONSchema as Record); +function makeStandardSchema( + jsonSchema: Record = weatherJSONSchema as unknown as Record, +) { + const input = jest.fn(() => jsonSchema); const output = jest.fn(() => ({ type: 'string' })); return { @@ -162,6 +164,58 @@ describe('Standard Schema helpers', () => { expect(format.json_schema.schema).toEqual(strictWeatherJSONSchema); }); + it('normalizes oneOf to anyOf before strictifying schemas', () => { + const oneOfSchema = { + type: 'object', + properties: { + choice: { + oneOf: [ + { + type: 'object', + properties: { foo: { type: 'string' } }, + required: ['foo'], + }, + { + type: 'object', + properties: { bar: { type: 'number' } }, + required: ['bar'], + }, + ], + }, + }, + required: ['choice'], + }; + const { standardSchema } = makeStandardSchema(oneOfSchema); + + const schema = standardResponseFormat(standardSchema, 'choice').json_schema.schema; + + expect(schema).toEqual({ + type: 'object', + properties: { + choice: { + anyOf: [ + { + type: 'object', + properties: { foo: { type: 'string' } }, + required: ['foo'], + additionalProperties: false, + }, + { + type: 'object', + properties: { bar: { type: 'number' } }, + required: ['bar'], + additionalProperties: false, + }, + ], + }, + }, + required: ['choice'], + additionalProperties: false, + }); + expect(JSON.stringify(schema)).not.toContain('"oneOf"'); + expect(oneOfSchema.properties.choice).toHaveProperty('oneOf'); + }); + it('throws an actionable error when no JSON Schema is available', () => { const standardSchema = makeValidationOnlySchema(); From 0cb7812e21e65e799cd76f8ac0a8c426d8a95eda Mon Sep 17 00:00:00 2001 From: Hayden Date: Mon, 20 Jul 2026 20:14:17 +0000 Subject: [PATCH 03/35] fix standard schema review findings --- src/helpers/standard-schema.ts | 107 ++++++++++++++++++++------ src/lib/ResponsesParser.ts | 13 ++-- src/lib/transform.ts | 3 + tests/helpers/standard-schema.test.ts | 68 ++++++++++++++++ tests/lib/ResponsesParser.test.ts | 43 ++++++++++- 5 files changed, 205 insertions(+), 29 deletions(-) diff --git a/src/helpers/standard-schema.ts b/src/helpers/standard-schema.ts index c46c2a6b48..3e36b3417a 100644 --- a/src/helpers/standard-schema.ts +++ b/src/helpers/standard-schema.ts @@ -28,7 +28,7 @@ type StandardSchemaResult = }; type StandardJSONSchemaOptions = { - readonly target: string; + readonly target: 'draft-07'; readonly libraryOptions?: Record | undefined; }; @@ -75,6 +75,10 @@ type StandardTextFormatProps = Omit< > & StandardSchemaJSONSchemaProps; +type StandardToolFunction = ( + args: InferStandardOutput, +) => unknown | Promise; + type StandardToolOptions = { name: string; parameters: Parameters; @@ -83,10 +87,19 @@ type StandardToolOptions = { * expose `~standard.jsonSchema.input()`. */ schema?: JSONSchema | Record | undefined; - function?: ((args: InferStandardOutput) => unknown | Promise) | undefined; + function?: StandardToolFunction | undefined; description?: string | undefined; }; +type StandardToolReturnOptions< + Parameters extends StandardSchemaLike, + Function extends StandardToolFunction | undefined, +> = { + arguments: InferStandardOutput; + name: string; + function: Function; +}; + function isPromiseLike(value: unknown): value is PromiseLike { return typeof value === 'object' && value !== null && 'then' in value && typeof value.then === 'function'; } @@ -108,14 +121,8 @@ function formatStandardSchemaIssues(issues: ReadonlyArray): function normalizeStructuredOutputSchema(schema: JSONSchema): JSONSchema { const normalizedSchema = structuredClone(schema); - const visit = (value: unknown): void => { - if (!value || typeof value !== 'object') return; - - if (Array.isArray(value)) { - for (const child of value) visit(child); - return; - } - + const visitSchema = (value: unknown): void => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return; const record = value as Record; if (Array.isArray(record['oneOf'])) { if (record['anyOf'] !== undefined) { @@ -128,10 +135,47 @@ function normalizeStructuredOutputSchema(schema: JSONSchema): JSONSchema { delete record['oneOf']; } - for (const child of Object.values(record)) visit(child); + for (const keyword of [ + 'additionalItems', + 'additionalProperties', + 'contains', + 'contentSchema', + 'else', + 'if', + 'not', + 'propertyNames', + 'then', + 'unevaluatedItems', + 'unevaluatedProperties', + ]) { + visitSchema(record[keyword]); + } + + for (const keyword of ['allOf', 'anyOf', 'items', 'prefixItems']) { + const children = record[keyword]; + if (Array.isArray(children)) { + for (const child of children) visitSchema(child); + } else { + visitSchema(children); + } + } + + for (const keyword of [ + '$defs', + 'definitions', + 'dependentSchemas', + 'dependencies', + 'patternProperties', + 'properties', + ]) { + const children = record[keyword]; + if (children && typeof children === 'object' && !Array.isArray(children)) { + for (const child of Object.values(children)) visitSchema(child); + } + } }; - visit(normalizedSchema); + visitSchema(normalizedSchema); return normalizedSchema; } @@ -227,14 +271,21 @@ export function standardTextFormat( * Creates a chat completion `function` tool from a Standard Schema * validator. */ +export function standardFunction< + Parameters extends StandardSchemaLike, + Function extends StandardToolFunction, +>( + options: StandardToolOptions & { function: Function }, +): AutoParseableTool>; +export function standardFunction( + options: StandardToolOptions & { function?: undefined }, +): AutoParseableTool>; export function standardFunction( options: StandardToolOptions, -): AutoParseableTool<{ - arguments: InferStandardOutput; - name: string; - function: (args: InferStandardOutput) => unknown; -}> { - // @ts-expect-error TODO +): AutoParseableTool | undefined>>; +export function standardFunction( + options: StandardToolOptions, +) { return makeParseableTool( { type: 'function', @@ -255,13 +306,23 @@ export function standardFunction( /** * Creates a Responses API `function` tool from a Standard Schema validator. */ +export function standardResponsesFunction< + Parameters extends StandardSchemaLike, + Function extends StandardToolFunction, +>( + options: StandardToolOptions & { function: Function }, +): AutoParseableResponseTool>; +export function standardResponsesFunction( + options: StandardToolOptions & { function?: undefined }, +): AutoParseableResponseTool>; export function standardResponsesFunction( options: StandardToolOptions, -): AutoParseableResponseTool<{ - arguments: InferStandardOutput; - name: string; - function: (args: InferStandardOutput) => unknown; -}> { +): AutoParseableResponseTool< + StandardToolReturnOptions | undefined> +>; +export function standardResponsesFunction( + options: StandardToolOptions, +) { return makeParseableResponseTool( { type: 'function', diff --git a/src/lib/ResponsesParser.ts b/src/lib/ResponsesParser.ts index 5bcf7ae66d..bc13cf751d 100644 --- a/src/lib/ResponsesParser.ts +++ b/src/lib/ResponsesParser.ts @@ -74,10 +74,7 @@ export function parseResponse< const output: Array> = response.output.map( (item): ParsedResponseOutputItem => { if (item.type === 'function_call') { - return { - ...item, - parsed_arguments: shouldParse ? parseToolCall(params, item) : null, - }; + return shouldParse ? parseToolCall(params, item) : { ...item, parsed_arguments: null }; } if (item.type === 'message') { const content: Array> = item.content.map((content) => { @@ -149,7 +146,13 @@ export function hasAutoParseableInput(params: ResponseCreateParamsWithTools): bo return true; } - return false; + return ( + (Array.isArray(params.tools) && + params.tools.some( + (tool) => isAutoParsableTool(tool) || (tool.type === 'function' && tool.strict === true), + )) || + false + ); } type ToolOptions = { diff --git a/src/lib/transform.ts b/src/lib/transform.ts index 63cc260794..f1ff9ff87a 100644 --- a/src/lib/transform.ts +++ b/src/lib/transform.ts @@ -18,6 +18,9 @@ function isNullable(schema: JSONSchemaDefinition): boolean { if (schema.type === 'null') { return true; } + if (Array.isArray(schema.type) && schema.type.includes('null')) { + return true; + } for (const oneOfVariant of schema.oneOf ?? []) { if (isNullable(oneOfVariant)) { return true; diff --git a/tests/helpers/standard-schema.test.ts b/tests/helpers/standard-schema.test.ts index 940699bebe..319b43e579 100644 --- a/tests/helpers/standard-schema.test.ts +++ b/tests/helpers/standard-schema.test.ts @@ -4,6 +4,7 @@ import { standardResponsesFunction, standardTextFormat, } from 'openai/helpers/standard-schema'; +import type OpenAI from 'openai'; import type { JSONSchema } from 'openai/lib/jsonschema'; import { compareType, expectType } from '../utils/typing'; @@ -216,6 +217,53 @@ describe('Standard Schema helpers', () => { expect(oneOfSchema.properties.choice).toHaveProperty('oneOf'); }); + it('does not normalize oneOf keys inside literal schema values', () => { + const schemaWithLiterals = { + type: 'object', + properties: { + choice: { + type: 'string', + enum: [{ oneOf: ['literal enum value'] }], + const: { oneOf: ['literal const value'] }, + default: { oneOf: ['literal default value'] }, + }, + }, + required: ['choice'], + }; + const { standardSchema } = makeStandardSchema(schemaWithLiterals); + + const schema = standardResponseFormat(standardSchema, 'choice').json_schema.schema; + + expect(schema).toMatchObject({ + properties: { + choice: { + enum: [{ oneOf: ['literal enum value'] }], + const: { oneOf: ['literal const value'] }, + default: { oneOf: ['literal default value'] }, + }, + }, + }); + }); + + it('accepts nullable type arrays for optional properties', () => { + const nullableTypeArraySchema = { + type: 'object', + properties: { + city: { type: ['string', 'null'] }, + }, + }; + const { standardSchema } = makeStandardSchema(nullableTypeArraySchema); + + expect(standardResponseFormat(standardSchema, 'weather').json_schema.schema).toEqual({ + type: 'object', + properties: { + city: { type: ['string', 'null'] }, + }, + required: ['city'], + additionalProperties: false, + }); + }); + it('throws an actionable error when no JSON Schema is available', () => { const standardSchema = makeValidationOnlySchema(); @@ -266,7 +314,27 @@ function _typeTests() { parameters: standardSchema, function: (args) => expectType(args), }); + const callbacklessChatTool = standardFunction({ + name: 'get_weather', + parameters: standardSchema, + }); + const standardTargetSchema = { + ...standardSchema, + '~standard': { + ...standardSchema['~standard'], + jsonSchema: { + input: (_options: { readonly target: 'draft-07' | 'draft-2020-12' }) => + weatherJSONSchema as unknown as Record, + }, + }, + }; compareType, WeatherOutput>(true); compareType, WeatherOutput>(true); + expectType(chatTool.__hasFunction); + expectType(callbacklessChatTool.__hasFunction); + const openai = null as unknown as OpenAI; + // @ts-expect-error callback-less tools cannot be passed to runTools + openai.chat.completions.runTools({ model: 'gpt-4o', messages: [], tools: [callbacklessChatTool] }); + standardResponseFormat(standardTargetSchema, 'weather'); } diff --git a/tests/lib/ResponsesParser.test.ts b/tests/lib/ResponsesParser.test.ts index c3605d3aba..006fe23d8a 100644 --- a/tests/lib/ResponsesParser.test.ts +++ b/tests/lib/ResponsesParser.test.ts @@ -1,4 +1,4 @@ -import { parseResponse } from '../../src/lib/ResponsesParser'; +import { makeParseableResponseTool, maybeParseResponse, parseResponse } from '../../src/lib/ResponsesParser'; import type { Response, ResponseCreateParamsBase } from '../../src/resources/responses/responses'; const structuredTextParams = { @@ -49,6 +49,21 @@ function makeResponse(status: Response['status'], text: string): Response { } as Response; } +function makeToolCallResponse(arguments_: string): Response { + const response = makeResponse('completed', ''); + response.output = [ + { + type: 'function_call', + id: 'fc_123', + call_id: 'call_123', + name: 'get_weather', + arguments: arguments_, + status: 'completed', + }, + ]; + return response; +} + describe('ResponsesParser', () => { it('parses structured output for completed responses', () => { const response = parseResponse( @@ -98,4 +113,30 @@ describe('ResponsesParser', () => { expect(response.output.slice(1)).toEqual(raw.output.slice(1)); }); + + it('auto-parses response tools when finalizing streamed responses', () => { + const tool = makeParseableResponseTool( + { + type: 'function', + name: 'get_weather', + parameters: { type: 'object' }, + strict: true, + }, + { + callback: undefined, + parser: (content) => JSON.parse(content), + }, + ); + + const response = maybeParseResponse(makeToolCallResponse('{"city":"Paris"}'), { + model: 'gpt-5.4-mini', + input: 'What is the weather?', + tools: [tool], + }); + + expect(response.output[0]).toMatchObject({ + type: 'function_call', + parsed_arguments: { city: 'Paris' }, + }); + }); }); From cc85ea2ca292c46ed298e02c54c32dacb1cf8823 Mon Sep 17 00:00:00 2001 From: Hayden Date: Mon, 20 Jul 2026 20:19:25 +0000 Subject: [PATCH 04/35] fix standard schema type test --- tests/helpers/standard-schema.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/helpers/standard-schema.test.ts b/tests/helpers/standard-schema.test.ts index 319b43e579..33205147b9 100644 --- a/tests/helpers/standard-schema.test.ts +++ b/tests/helpers/standard-schema.test.ts @@ -8,6 +8,8 @@ import type OpenAI from 'openai'; import type { JSONSchema } from 'openai/lib/jsonschema'; import { compareType, expectType } from '../utils/typing'; +declare const runTools: OpenAI['chat']['completions']['runTools']; + type WeatherInput = { city: string; unit: 'c' | 'f'; @@ -333,8 +335,7 @@ function _typeTests() { compareType, WeatherOutput>(true); expectType(chatTool.__hasFunction); expectType(callbacklessChatTool.__hasFunction); - const openai = null as unknown as OpenAI; // @ts-expect-error callback-less tools cannot be passed to runTools - openai.chat.completions.runTools({ model: 'gpt-4o', messages: [], tools: [callbacklessChatTool] }); + runTools({ model: 'gpt-4o', messages: [], tools: [callbacklessChatTool] }); standardResponseFormat(standardTargetSchema, 'weather'); } From b5e73ff3cc8a26f1e9d7d3a9defe507342382e09 Mon Sep 17 00:00:00 2001 From: Hayden Date: Mon, 20 Jul 2026 20:59:42 +0000 Subject: [PATCH 05/35] fix(helpers): address standard schema review feedback --- src/helpers/standard-schema.ts | 135 ++++++++++++++++++++++++++ src/lib/transform.ts | 5 +- tests/helpers/standard-schema.test.ts | 62 ++++++++++-- tests/lib/transform.test.ts | 32 ++++++ 4 files changed, 224 insertions(+), 10 deletions(-) diff --git a/src/helpers/standard-schema.ts b/src/helpers/standard-schema.ts index 3e36b3417a..12bf62ac7a 100644 --- a/src/helpers/standard-schema.ts +++ b/src/helpers/standard-schema.ts @@ -118,6 +118,135 @@ function formatStandardSchemaIssues(issues: ReadonlyArray): .join('; '); } +const JSON_SCHEMA_TYPES = new Set(['string', 'number', 'integer', 'boolean', 'object', 'array', 'null']); + +type JSONPrimitive = string | number | boolean | null; + +function getSchemaTypes(schema: unknown): Set | undefined { + if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return undefined; + + const type = (schema as Record)['type']; + const types = Array.isArray(type) ? type : [type]; + if ( + types.length === 0 || + !types.every((value) => typeof value === 'string' && JSON_SCHEMA_TYPES.has(value)) + ) { + return undefined; + } + + return new Set(types); +} + +function isJSONPrimitive(value: unknown): value is JSONPrimitive { + return ( + value === null || + typeof value === 'string' || + typeof value === 'boolean' || + (typeof value === 'number' && Number.isFinite(value)) + ); +} + +function getLiteralValues(schema: unknown): JSONPrimitive[] | undefined { + if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return undefined; + + const record = schema as Record; + if ('const' in record && isJSONPrimitive(record['const'])) { + return [record['const']]; + } + + const enumValues = record['enum']; + if (Array.isArray(enumValues) && enumValues.length > 0 && enumValues.every(isJSONPrimitive)) { + return enumValues; + } + + return undefined; +} + +function haveDisjointLiteralValues(left: unknown, right: unknown): boolean { + const leftValues = getLiteralValues(left); + const rightValues = getLiteralValues(right); + if (!leftValues || !rightValues) return false; + + return leftValues.every((leftValue) => !rightValues.some((rightValue) => leftValue === rightValue)); +} + +function schemaTypesOverlap(left: string, right: string): boolean { + return ( + left === right || (left === 'integer' && right === 'number') || (left === 'number' && right === 'integer') + ); +} + +function isObjectOnlySchema(schema: unknown): boolean { + const types = getSchemaTypes(schema); + return types?.size === 1 && types.has('object'); +} + +function haveDisjointObjectDiscriminator(left: unknown, right: unknown): boolean { + if (!isObjectOnlySchema(left) || !isObjectOnlySchema(right)) return false; + + const leftRecord = left as Record; + const rightRecord = right as Record; + const leftProperties = leftRecord['properties']; + const rightProperties = rightRecord['properties']; + const leftRequired = leftRecord['required']; + const rightRequired = rightRecord['required']; + if ( + !leftProperties || + typeof leftProperties !== 'object' || + Array.isArray(leftProperties) || + !rightProperties || + typeof rightProperties !== 'object' || + Array.isArray(rightProperties) || + !Array.isArray(leftRequired) || + !Array.isArray(rightRequired) + ) { + return false; + } + + for (const property of leftRequired) { + if ( + typeof property === 'string' && + rightRequired.includes(property) && + haveDisjointLiteralValues( + (leftProperties as Record)[property], + (rightProperties as Record)[property], + ) + ) { + return true; + } + } + + return false; +} + +function areMutuallyExclusive(left: unknown, right: unknown): boolean { + const leftTypes = getSchemaTypes(left); + const rightTypes = getSchemaTypes(right); + if ( + leftTypes && + rightTypes && + [...leftTypes].every((leftType) => + [...rightTypes].every((rightType) => !schemaTypesOverlap(leftType, rightType)), + ) + ) { + return true; + } + + return haveDisjointLiteralValues(left, right) || haveDisjointObjectDiscriminator(left, right); +} + +function areOneOfBranchesMutuallyExclusive(branches: unknown[]): boolean { + for (let index = 0; index < branches.length; index++) { + for (let otherIndex = index + 1; otherIndex < branches.length; otherIndex++) { + if (!areMutuallyExclusive(branches[index], branches[otherIndex])) { + return false; + } + } + } + + return true; +} + function normalizeStructuredOutputSchema(schema: JSONSchema): JSONSchema { const normalizedSchema = structuredClone(schema); @@ -130,6 +259,11 @@ function normalizeStructuredOutputSchema(schema: JSONSchema): JSONSchema { 'Standard JSON Schema generated both `anyOf` and `oneOf`, which cannot be represented in an OpenAI strict schema', ); } + if (!areOneOfBranchesMutuallyExclusive(record['oneOf'])) { + throw new OpenAIError( + 'Standard JSON Schema generated a `oneOf` whose branches are not provably mutually exclusive. OpenAI strict schemas do not support `oneOf`; use `anyOf` or add a discriminator with distinct literal values.', + ); + } record['anyOf'] = record['oneOf']; delete record['oneOf']; @@ -186,6 +320,7 @@ function parseStandardSchema( const result = standardSchema['~standard'].validate(JSON.parse(content)); if (isPromiseLike(result)) { + void Promise.resolve(result).catch(() => undefined); throw new OpenAIError( 'Standard Schema helpers only support synchronous validation. Use a schema with a synchronous `~standard.validate()` implementation.', ); diff --git a/src/lib/transform.ts b/src/lib/transform.ts index f1ff9ff87a..f0bb6b6ad7 100644 --- a/src/lib/transform.ts +++ b/src/lib/transform.ts @@ -69,7 +69,10 @@ function ensureStrictJsonSchema( // Add additionalProperties: false to object types const typ = jsonSchema.type; - if (typ === 'object' && !('additionalProperties' in jsonSchema)) { + if ( + (typ === 'object' || (Array.isArray(typ) && typ.includes('object'))) && + !('additionalProperties' in jsonSchema) + ) { jsonSchema.additionalProperties = false; } diff --git a/tests/helpers/standard-schema.test.ts b/tests/helpers/standard-schema.test.ts index 33205147b9..a098deed98 100644 --- a/tests/helpers/standard-schema.test.ts +++ b/tests/helpers/standard-schema.test.ts @@ -167,7 +167,7 @@ describe('Standard Schema helpers', () => { expect(format.json_schema.schema).toEqual(strictWeatherJSONSchema); }); - it('normalizes oneOf to anyOf before strictifying schemas', () => { + it('normalizes provably exclusive oneOf branches before strictifying schemas', () => { const oneOfSchema = { type: 'object', properties: { @@ -175,13 +175,13 @@ describe('Standard Schema helpers', () => { oneOf: [ { type: 'object', - properties: { foo: { type: 'string' } }, - required: ['foo'], + properties: { kind: { type: 'string', const: 'foo' }, foo: { type: 'string' } }, + required: ['kind', 'foo'], }, { type: 'object', - properties: { bar: { type: 'number' } }, - required: ['bar'], + properties: { kind: { type: 'string', const: 'bar' }, bar: { type: 'number' } }, + required: ['kind', 'bar'], }, ], }, @@ -199,14 +199,14 @@ describe('Standard Schema helpers', () => { anyOf: [ { type: 'object', - properties: { foo: { type: 'string' } }, - required: ['foo'], + properties: { kind: { type: 'string', const: 'foo' }, foo: { type: 'string' } }, + required: ['kind', 'foo'], additionalProperties: false, }, { type: 'object', - properties: { bar: { type: 'number' } }, - required: ['bar'], + properties: { kind: { type: 'string', const: 'bar' }, bar: { type: 'number' } }, + required: ['kind', 'bar'], additionalProperties: false, }, ], @@ -219,6 +219,26 @@ describe('Standard Schema helpers', () => { expect(oneOfSchema.properties.choice).toHaveProperty('oneOf'); }); + it('rejects oneOf branches that may overlap', () => { + const overlappingOneOfSchema = { + type: 'object', + properties: { + choice: { + oneOf: [ + { type: 'string', enum: ['left', 'shared'] }, + { type: 'string', enum: ['shared', 'right'] }, + ], + }, + }, + required: ['choice'], + }; + const { standardSchema } = makeStandardSchema(overlappingOneOfSchema); + + expect(() => standardResponseFormat(standardSchema, 'choice')).toThrow( + 'Standard JSON Schema generated a `oneOf` whose branches are not provably mutually exclusive', + ); + }); + it('does not normalize oneOf keys inside literal schema values', () => { const schemaWithLiterals = { type: 'object', @@ -298,6 +318,30 @@ describe('Standard Schema helpers', () => { 'Standard Schema helpers only support synchronous validation', ); }); + + it('observes rejected asynchronous validators', async () => { + const { standardSchema } = makeStandardSchema(); + const unhandledRejection = jest.fn(); + const rejectingAsyncSchema = { + ...standardSchema, + '~standard': { + ...standardSchema['~standard'], + validate: () => Promise.reject(new Error('validation rejected')), + }, + }; + const format = standardResponseFormat(rejectingAsyncSchema, 'weather'); + process.once('unhandledRejection', unhandledRejection); + + try { + expect(() => format.$parseRaw('{"city":"Paris","unit":"c"}')).toThrow( + 'Standard Schema helpers only support synchronous validation', + ); + await new Promise((resolve) => setImmediate(resolve)); + expect(unhandledRejection).not.toHaveBeenCalled(); + } finally { + process.removeListener('unhandledRejection', unhandledRejection); + } + }); }); function _typeTests() { diff --git a/tests/lib/transform.test.ts b/tests/lib/transform.test.ts index 4c1e81a2de..eca292891b 100644 --- a/tests/lib/transform.test.ts +++ b/tests/lib/transform.test.ts @@ -100,6 +100,38 @@ describe('toStrictJsonSchema', () => { } `); }); + + test('adds additionalProperties: false to nullable object type arrays', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + user: { + type: ['object', 'null'], + properties: { + name: { type: 'string' }, + }, + required: ['name'], + }, + }, + required: ['user'], + }; + + expect(toStrictJsonSchema(schema)).toEqual({ + type: 'object', + properties: { + user: { + type: ['object', 'null'], + properties: { + name: { type: 'string' }, + }, + required: ['name'], + additionalProperties: false, + }, + }, + required: ['user'], + additionalProperties: false, + }); + }); }); describe('Required Properties', () => { From 7c3e07c75e4afe3fcea938106d479b56a92b6617 Mon Sep 17 00:00:00 2001 From: Hayden Date: Mon, 20 Jul 2026 22:18:55 +0000 Subject: [PATCH 06/35] fix(helpers): handle nullable literals and local refs --- src/helpers/standard-schema.ts | 38 +++++++++++++-- src/lib/transform.ts | 6 +++ tests/helpers/standard-schema.test.ts | 67 +++++++++++++++++++++++++++ tests/lib/transform.test.ts | 19 ++++++++ 4 files changed, 127 insertions(+), 3 deletions(-) diff --git a/src/helpers/standard-schema.ts b/src/helpers/standard-schema.ts index 12bf62ac7a..c7b758985b 100644 --- a/src/helpers/standard-schema.ts +++ b/src/helpers/standard-schema.ts @@ -235,10 +235,42 @@ function areMutuallyExclusive(left: unknown, right: unknown): boolean { return haveDisjointLiteralValues(left, right) || haveDisjointObjectDiscriminator(left, right); } -function areOneOfBranchesMutuallyExclusive(branches: unknown[]): boolean { +function resolveLocalRefForExclusivity( + schema: unknown, + root: JSONSchema, + seenRefs: Set = new Set(), +): unknown | undefined { + if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return schema; + + const record = schema as Record; + const ref = record['$ref']; + if (ref === undefined) return schema; + + // Keep the proof conservative when a ref has sibling constraints. Resolving + // those correctly would require combining the referenced schema and siblings. + if (typeof ref !== 'string' || Object.keys(record).length !== 1 || !ref.startsWith('#/')) { + return undefined; + } + if (seenRefs.has(ref)) return undefined; + + let resolved: unknown = root; + for (const encodedPart of ref.slice(2).split('/')) { + const part = encodedPart.replace(/~1/g, '/').replace(/~0/g, '~'); + if (!resolved || typeof resolved !== 'object' || Array.isArray(resolved) || !(part in resolved)) { + return undefined; + } + resolved = (resolved as Record)[part]; + } + + return resolveLocalRefForExclusivity(resolved, root, new Set([...seenRefs, ref])); +} + +function areOneOfBranchesMutuallyExclusive(branches: unknown[], root: JSONSchema): boolean { for (let index = 0; index < branches.length; index++) { for (let otherIndex = index + 1; otherIndex < branches.length; otherIndex++) { - if (!areMutuallyExclusive(branches[index], branches[otherIndex])) { + const left = resolveLocalRefForExclusivity(branches[index], root); + const right = resolveLocalRefForExclusivity(branches[otherIndex], root); + if (left === undefined || right === undefined || !areMutuallyExclusive(left, right)) { return false; } } @@ -259,7 +291,7 @@ function normalizeStructuredOutputSchema(schema: JSONSchema): JSONSchema { 'Standard JSON Schema generated both `anyOf` and `oneOf`, which cannot be represented in an OpenAI strict schema', ); } - if (!areOneOfBranchesMutuallyExclusive(record['oneOf'])) { + if (!areOneOfBranchesMutuallyExclusive(record['oneOf'], normalizedSchema)) { throw new OpenAIError( 'Standard JSON Schema generated a `oneOf` whose branches are not provably mutually exclusive. OpenAI strict schemas do not support `oneOf`; use `anyOf` or add a discriminator with distinct literal values.', ); diff --git a/src/lib/transform.ts b/src/lib/transform.ts index f0bb6b6ad7..a1788aac74 100644 --- a/src/lib/transform.ts +++ b/src/lib/transform.ts @@ -21,6 +21,12 @@ function isNullable(schema: JSONSchemaDefinition): boolean { if (Array.isArray(schema.type) && schema.type.includes('null')) { return true; } + if (schema.const === null) { + return true; + } + if (Array.isArray(schema.enum) && schema.enum.includes(null)) { + return true; + } for (const oneOfVariant of schema.oneOf ?? []) { if (isNullable(oneOfVariant)) { return true; diff --git a/tests/helpers/standard-schema.test.ts b/tests/helpers/standard-schema.test.ts index a098deed98..b34a9c5454 100644 --- a/tests/helpers/standard-schema.test.ts +++ b/tests/helpers/standard-schema.test.ts @@ -239,6 +239,73 @@ describe('Standard Schema helpers', () => { ); }); + it('normalizes provably exclusive oneOf branches behind local refs', () => { + const referencedOneOfSchema = { + type: 'object', + $defs: { + foo: { + type: 'object', + properties: { kind: { type: 'string', const: 'foo' }, foo: { type: 'string' } }, + required: ['kind', 'foo'], + }, + bar: { + type: 'object', + properties: { kind: { type: 'string', const: 'bar' }, bar: { type: 'number' } }, + required: ['kind', 'bar'], + }, + }, + properties: { + choice: { + oneOf: [{ $ref: '#/$defs/foo' }, { $ref: '#/$defs/bar' }], + }, + }, + required: ['choice'], + }; + const { standardSchema } = makeStandardSchema(referencedOneOfSchema); + + expect(standardResponseFormat(standardSchema, 'choice').json_schema.schema).toEqual({ + type: 'object', + $defs: { + foo: { + type: 'object', + properties: { kind: { type: 'string', const: 'foo' }, foo: { type: 'string' } }, + required: ['kind', 'foo'], + additionalProperties: false, + }, + bar: { + type: 'object', + properties: { kind: { type: 'string', const: 'bar' }, bar: { type: 'number' } }, + required: ['kind', 'bar'], + additionalProperties: false, + }, + }, + properties: { + choice: { + anyOf: [{ $ref: '#/$defs/foo' }, { $ref: '#/$defs/bar' }], + }, + }, + required: ['choice'], + additionalProperties: false, + }); + }); + + it('rejects oneOf refs that cannot be resolved locally', () => { + for (const branches of [ + [{ $ref: '#/$defs/missing' }, { type: 'string' }], + [{ $ref: 'https://example.com/schema.json#/$defs/foo' }, { type: 'string' }], + ]) { + const { standardSchema } = makeStandardSchema({ + type: 'object', + properties: { choice: { oneOf: branches } }, + required: ['choice'], + }); + + expect(() => standardResponseFormat(standardSchema, 'choice')).toThrow( + 'Standard JSON Schema generated a `oneOf` whose branches are not provably mutually exclusive', + ); + } + }); + it('does not normalize oneOf keys inside literal schema values', () => { const schemaWithLiterals = { type: 'object', diff --git a/tests/lib/transform.test.ts b/tests/lib/transform.test.ts index eca292891b..b3e2f87c7e 100644 --- a/tests/lib/transform.test.ts +++ b/tests/lib/transform.test.ts @@ -162,6 +162,25 @@ describe('toStrictJsonSchema', () => { `); }); + test('makes properties with null-valued literals required', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + constNull: { const: null }, + enumNull: { enum: [null] }, + nestedConstNull: { anyOf: [{ type: 'string' }, { const: null }] }, + nestedEnumNull: { anyOf: [{ type: 'string' }, { enum: [null] }] }, + }, + }; + + expect(toStrictJsonSchema(schema).required).toEqual([ + 'constNull', + 'enumNull', + 'nestedConstNull', + 'nestedEnumNull', + ]); + }); + test('throws error for optional properties without nullable', () => { const schema: JSONSchema = { type: 'object', From a856f891c28231ee1a325e637070a088a9d25422 Mon Sep 17 00:00:00 2001 From: Hayden Date: Mon, 20 Jul 2026 22:53:03 +0000 Subject: [PATCH 07/35] fix(helpers): enforce strict schema compatibility --- src/lib/transform.ts | 122 +++++++++++++++++++++----- tests/helpers/standard-schema.test.ts | 18 ++++ tests/lib/transform.test.ts | 98 ++++++++++++++++++--- 3 files changed, 204 insertions(+), 34 deletions(-) diff --git a/src/lib/transform.ts b/src/lib/transform.ts index a1788aac74..eab5daf617 100644 --- a/src/lib/transform.ts +++ b/src/lib/transform.ts @@ -11,33 +11,81 @@ export function toStrictJsonSchema(schema: JSONSchema): JSONSchema { return ensureStrictJsonSchema(schemaCopy, [], schemaCopy); } -function isNullable(schema: JSONSchemaDefinition): boolean { +function isNullable( + schema: JSONSchemaDefinition, + root: JSONSchema, + seenRefs: Set = new Set(), +): boolean { if (typeof schema === 'boolean') { - return false; + return schema; } - if (schema.type === 'null') { - return true; + + const ref = schema.$ref; + if (ref !== undefined) { + // Keep the proof conservative when a ref has sibling constraints. Resolving + // those correctly would require combining the referenced schema and siblings. + if (typeof ref !== 'string' || hasMoreThanNKeys(schema, 1) || seenRefs.has(ref)) { + return false; + } + + const resolved = resolveLocalRef(root, ref); + if (resolved === undefined) { + return false; + } + + return isNullable(resolved, root, new Set([...seenRefs, ref])); } - if (Array.isArray(schema.type) && schema.type.includes('null')) { - return true; + + if ( + schema.type !== undefined && + schema.type !== 'null' && + !(Array.isArray(schema.type) && schema.type.includes('null')) + ) { + return false; } - if (schema.const === null) { - return true; + + if ('const' in schema && schema.const !== null) { + return false; } - if (Array.isArray(schema.enum) && schema.enum.includes(null)) { - return true; + + if (schema.enum !== undefined && (!Array.isArray(schema.enum) || !schema.enum.includes(null))) { + return false; } - for (const oneOfVariant of schema.oneOf ?? []) { - if (isNullable(oneOfVariant)) { - return true; + + if (schema.allOf !== undefined) { + if (!Array.isArray(schema.allOf) || !schema.allOf.every((variant) => isNullable(variant, root))) { + return false; } } - for (const allOfVariant of schema.anyOf ?? []) { - if (isNullable(allOfVariant)) { - return true; + + if (schema.anyOf !== undefined) { + if (!Array.isArray(schema.anyOf) || !schema.anyOf.some((variant) => isNullable(variant, root))) { + return false; } } - return false; + + if (schema.oneOf !== undefined) { + if ( + !Array.isArray(schema.oneOf) || + schema.oneOf.filter((variant) => isNullable(variant, root)).length !== 1 + ) { + return false; + } + } + + // Conditional and negated schemas need a full JSON Schema evaluator to prove + // that null is allowed. Treat them conservatively instead of accepting an + // optional field that may not actually accept null. + if ( + schema.not !== undefined || + schema.if !== undefined || + schema.then !== undefined || + schema.else !== undefined + ) { + return false; + } + + return true; } /** @@ -73,13 +121,19 @@ function ensureStrictJsonSchema( } } - // Add additionalProperties: false to object types + // Add additionalProperties: false to object types. Explicitly open object + // schemas cannot be represented in Structured Outputs strict mode. const typ = jsonSchema.type; - if ( - (typ === 'object' || (Array.isArray(typ) && typ.includes('object'))) && - !('additionalProperties' in jsonSchema) - ) { - jsonSchema.additionalProperties = false; + if (typ === 'object' || (Array.isArray(typ) && typ.includes('object'))) { + if (!('additionalProperties' in jsonSchema)) { + jsonSchema.additionalProperties = false; + } else if (jsonSchema.additionalProperties !== false) { + throw new Error( + `Object schema at \`${ + path.join('/') || '' + }\` must set \`additionalProperties: false\` to be compatible with strict Structured Outputs.`, + ); + } } const required = jsonSchema.required ?? []; @@ -88,7 +142,7 @@ function ensureStrictJsonSchema( const properties = jsonSchema.properties; if (isObject(properties)) { for (const [key, value] of Object.entries(properties)) { - if (!isNullable(value) && !required.includes(key)) { + if (!isNullable(value, root) && !required.includes(key)) { throw new Error( `Schema field at \`${[...path, 'properties', key].join( '/', @@ -189,6 +243,26 @@ function resolveRef(root: JSONSchema, ref: string): JSONSchemaDefinition { return resolved; } +function resolveLocalRef(root: JSONSchema, ref: string): JSONSchemaDefinition | undefined { + if (ref === '#') { + return root; + } + if (!ref.startsWith('#/')) { + return undefined; + } + + let resolved: unknown = root; + for (const encodedPart of ref.slice(2).split('/')) { + const part = encodedPart.replace(/~1/g, '/').replace(/~0/g, '~'); + if (!isObject(resolved) || !(part in resolved)) { + return undefined; + } + resolved = resolved[part]; + } + + return resolved as JSONSchemaDefinition; +} + function isObject(obj: T | Array): obj is Extract> { return typeof obj === 'object' && obj !== null && !Array.isArray(obj); } diff --git a/tests/helpers/standard-schema.test.ts b/tests/helpers/standard-schema.test.ts index b34a9c5454..838d374760 100644 --- a/tests/helpers/standard-schema.test.ts +++ b/tests/helpers/standard-schema.test.ts @@ -353,6 +353,24 @@ describe('Standard Schema helpers', () => { }); }); + it.each([ + ['true', true], + ['schema-valued', { type: 'string' }], + ])('rejects %s additionalProperties before returning a strict schema', (_name, additionalProperties) => { + const { standardSchema } = makeStandardSchema({ + type: 'object', + properties: { + city: { type: 'string' }, + }, + required: ['city'], + additionalProperties, + }); + + expect(() => standardResponseFormat(standardSchema, 'weather')).toThrow( + 'must set `additionalProperties: false` to be compatible with strict Structured Outputs', + ); + }); + it('throws an actionable error when no JSON Schema is available', () => { const standardSchema = makeValidationOnlySchema(); diff --git a/tests/lib/transform.test.ts b/tests/lib/transform.test.ts index b3e2f87c7e..3df5858999 100644 --- a/tests/lib/transform.test.ts +++ b/tests/lib/transform.test.ts @@ -45,7 +45,7 @@ describe('toStrictJsonSchema', () => { `); }); - test('preserves existing additionalProperties value', () => { + test('rejects additionalProperties: true', () => { const schema: JSONSchema = { type: 'object', properties: { @@ -55,16 +55,24 @@ describe('toStrictJsonSchema', () => { additionalProperties: true, }; - const strict = toStrictJsonSchema(schema); - const diff = detailedDiff(schema, strict); + expect(() => toStrictJsonSchema(schema)).toThrow( + 'must set `additionalProperties: false` to be compatible with strict Structured Outputs', + ); + }); - expect(diff).toMatchInlineSnapshot(` - { - "added": {}, - "deleted": {}, - "updated": {}, - } - `); + test('rejects schema-valued additionalProperties', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + name: { type: 'string' }, + }, + required: ['name'], + additionalProperties: { type: 'string' }, + }; + + expect(() => toStrictJsonSchema(schema)).toThrow( + 'must set `additionalProperties: false` to be compatible with strict Structured Outputs', + ); }); test('adds additionalProperties: false to nested objects', () => { @@ -181,6 +189,76 @@ describe('toStrictJsonSchema', () => { ]); }); + test('only makes literal-constrained properties required when null satisfies the whole schema', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + validConstNull: { type: ['string', 'null'], const: null }, + validEnumNull: { type: ['string', 'null'], enum: ['ready', null] }, + invalidConstNull: { type: 'string', const: null }, + invalidEnumNull: { type: 'string', enum: ['ready', null] }, + }, + required: ['invalidConstNull', 'invalidEnumNull'], + }; + + expect(toStrictJsonSchema(schema).required).toEqual([ + 'validConstNull', + 'validEnumNull', + 'invalidConstNull', + 'invalidEnumNull', + ]); + }); + + test('rejects optional literal-constrained properties when another keyword excludes null', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + status: { type: 'string', enum: ['ready', null] }, + }, + }; + + expect(() => toStrictJsonSchema(schema)).toThrow( + 'Schema field at `properties/status` uses `.optional()` without `.nullable()`', + ); + }); + + test('resolves pure local refs when checking nullable optional properties', () => { + const schema: JSONSchema = { + type: 'object', + $defs: { + NullableString: { type: ['string', 'null'] }, + Alias: { $ref: '#/$defs/NullableString' }, + }, + properties: { + nickname: { $ref: '#/$defs/Alias' }, + }, + }; + + expect(toStrictJsonSchema(schema).required).toEqual(['nickname']); + }); + + test.each([ + ['missing', { $ref: '#/$defs/Missing' }], + ['external', { $ref: 'https://example.com/schema.json#/$defs/NullableString' }], + ['cyclic', { $ref: '#/$defs/Cyclic' }], + ['sibling-constrained', { $ref: '#/$defs/NullableString', type: 'string' }], + ])('conservatively rejects %s refs when checking nullable optional properties', (_name, property) => { + const schema: JSONSchema = { + type: 'object', + $defs: { + NullableString: { type: ['string', 'null'] }, + Cyclic: { $ref: '#/$defs/Cyclic' }, + }, + properties: { + nickname: property as JSONSchema, + }, + }; + + expect(() => toStrictJsonSchema(schema)).toThrow( + 'Schema field at `properties/nickname` uses `.optional()` without `.nullable()`', + ); + }); + test('throws error for optional properties without nullable', () => { const schema: JSONSchema = { type: 'object', From 483a809d60311848d2fc44d8d02c1e02e3dd7fdf Mon Sep 17 00:00:00 2001 From: Hayden Date: Mon, 20 Jul 2026 23:25:09 +0000 Subject: [PATCH 08/35] fix(helpers): strictify draft 7 schema edges --- src/lib/transform.ts | 68 ++++++++++++-- tests/lib/transform.test.ts | 181 ++++++++++++++++++++++++++++++++++++ 2 files changed, 240 insertions(+), 9 deletions(-) diff --git a/src/lib/transform.ts b/src/lib/transform.ts index eab5daf617..532be8ccf2 100644 --- a/src/lib/transform.ts +++ b/src/lib/transform.ts @@ -1,5 +1,25 @@ import type { JSONSchema, JSONSchemaDefinition } from './jsonschema'; +const JSON_SCHEMA_ANNOTATION_KEYWORDS = new Set([ + 'default', + 'description', + 'examples', + 'readOnly', + 'title', + 'writeOnly', +]); + +const JSON_SCHEMA_OBJECT_KEYWORDS = new Set([ + 'additionalProperties', + 'dependencies', + 'maxProperties', + 'minProperties', + 'patternProperties', + 'properties', + 'propertyNames', + 'required', +]); + export function toStrictJsonSchema(schema: JSONSchema): JSONSchema { if (schema.type !== 'object') { throw new Error( @@ -22,9 +42,11 @@ function isNullable( const ref = schema.$ref; if (ref !== undefined) { - // Keep the proof conservative when a ref has sibling constraints. Resolving - // those correctly would require combining the referenced schema and siblings. - if (typeof ref !== 'string' || hasMoreThanNKeys(schema, 1) || seenRefs.has(ref)) { + // Annotation keywords do not constrain validation, so they are safe beside + // a local ref. Keep the proof conservative for every other sibling because + // resolving those correctly would require intersecting the referenced + // schema and its sibling constraints. + if (typeof ref !== 'string' || !hasOnlyRefAndAnnotations(schema) || seenRefs.has(ref)) { return false; } @@ -121,10 +143,16 @@ function ensureStrictJsonSchema( } } - // Add additionalProperties: false to object types. Explicitly open object - // schemas cannot be represented in Structured Outputs strict mode. + // Add additionalProperties: false to object schemas. Draft 7 permits object + // keywords without an explicit type, so those implicit object shapes need + // the same strict handling as type: 'object'. Explicitly open object schemas + // cannot be represented in Structured Outputs strict mode. const typ = jsonSchema.type; - if (typ === 'object' || (Array.isArray(typ) && typ.includes('object'))) { + if ( + typ === 'object' || + (Array.isArray(typ) && typ.includes('object')) || + (typ === undefined && hasObjectKeywords(jsonSchema)) + ) { if (!('additionalProperties' in jsonSchema)) { jsonSchema.additionalProperties = false; } else if (jsonSchema.additionalProperties !== false) { @@ -161,7 +189,11 @@ function ensureStrictJsonSchema( // Handle arrays const items = jsonSchema.items; - if (isObject(items)) { + if (Array.isArray(items)) { + jsonSchema.items = items.map((item, i) => + ensureStrictJsonSchema(item, [...path, 'items', String(i)], root), + ); + } else if (items !== undefined) { jsonSchema.items = ensureStrictJsonSchema(items, [...path, 'items'], root); } @@ -176,9 +208,11 @@ function ensureStrictJsonSchema( // Handle intersections (allOf) const allOf = jsonSchema.allOf; if (Array.isArray(allOf)) { - if (allOf.length === 1) { + if (allOf.length === 1 && hasOnlyAnnotationSiblings(jsonSchema, 'allOf')) { const resolved = ensureStrictJsonSchema(allOf[0]!, [...path, 'allOf', '0'], root); - Object.assign(jsonSchema, resolved); + const annotations = { ...jsonSchema }; + delete annotations.allOf; + Object.assign(jsonSchema, resolved, annotations); delete jsonSchema.allOf; } else { jsonSchema.allOf = allOf.map((entry, i) => @@ -267,6 +301,22 @@ function isObject(obj: T | Array): obj is Extract return typeof obj === 'object' && obj !== null && !Array.isArray(obj); } +function hasOnlyRefAndAnnotations(schema: JSONSchema): boolean { + return Object.keys(schema).every( + (keyword) => keyword === '$ref' || JSON_SCHEMA_ANNOTATION_KEYWORDS.has(keyword), + ); +} + +function hasOnlyAnnotationSiblings(schema: JSONSchema, keyword: string): boolean { + return Object.keys(schema).every( + (schemaKeyword) => schemaKeyword === keyword || JSON_SCHEMA_ANNOTATION_KEYWORDS.has(schemaKeyword), + ); +} + +function hasObjectKeywords(schema: JSONSchema): boolean { + return Object.keys(schema).some((keyword) => JSON_SCHEMA_OBJECT_KEYWORDS.has(keyword)); +} + function hasMoreThanNKeys(obj: Record, n: number): boolean { let i = 0; for (const _ in obj) { diff --git a/tests/lib/transform.test.ts b/tests/lib/transform.test.ts index 3df5858999..f79c4e1438 100644 --- a/tests/lib/transform.test.ts +++ b/tests/lib/transform.test.ts @@ -140,6 +140,59 @@ describe('toStrictJsonSchema', () => { additionalProperties: false, }); }); + + test('adds additionalProperties: false to implicit object schemas', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + user: { + properties: { + name: { type: 'string' }, + }, + required: ['name'], + }, + }, + required: ['user'], + }; + + expect(toStrictJsonSchema(schema)).toEqual({ + type: 'object', + properties: { + user: { + properties: { + name: { type: 'string' }, + }, + required: ['name'], + additionalProperties: false, + }, + }, + required: ['user'], + additionalProperties: false, + }); + }); + + test.each([ + ['true', true], + ['schema-valued', { type: 'string' }], + ])('rejects %s additionalProperties on implicit object schemas', (_name, additionalProperties) => { + const schema: JSONSchema = { + type: 'object', + properties: { + user: { + properties: { + name: { type: 'string' }, + }, + required: ['name'], + additionalProperties, + }, + }, + required: ['user'], + }; + + expect(() => toStrictJsonSchema(schema)).toThrow( + 'must set `additionalProperties: false` to be compatible with strict Structured Outputs', + ); + }); }); describe('Required Properties', () => { @@ -237,6 +290,34 @@ describe('toStrictJsonSchema', () => { expect(toStrictJsonSchema(schema).required).toEqual(['nickname']); }); + test('resolves local refs with annotation-only siblings when checking nullable optional properties', () => { + const schema: JSONSchema = { + type: 'object', + $defs: { + NullableString: { type: ['string', 'null'] }, + }, + properties: { + nickname: { + $ref: '#/$defs/NullableString', + title: 'Nickname', + description: 'A preferred name', + default: null, + examples: [null], + }, + }, + }; + + const strict = toStrictJsonSchema(schema); + + expect(strict.required).toEqual(['nickname']); + expect(strict.properties?.['nickname']).toMatchObject({ + type: ['string', 'null'], + title: 'Nickname', + description: 'A preferred name', + examples: [null], + }); + }); + test.each([ ['missing', { $ref: '#/$defs/Missing' }], ['external', { $ref: 'https://example.com/schema.json#/$defs/NullableString' }], @@ -350,6 +431,56 @@ describe('toStrictJsonSchema', () => { } `); }); + + test('processes tuple item schemas recursively', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + tuple: { + type: 'array', + items: [ + { type: 'string' }, + { + type: 'array', + items: [ + { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + }, + ], + }, + ], + }, + }, + required: ['tuple'], + }; + + expect(toStrictJsonSchema(schema)).toEqual({ + type: 'object', + properties: { + tuple: { + type: 'array', + items: [ + { type: 'string' }, + { + type: 'array', + items: [ + { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + additionalProperties: false, + }, + ], + }, + ], + }, + }, + required: ['tuple'], + additionalProperties: false, + }); + }); }); describe('anyOf Handling', () => { @@ -454,6 +585,56 @@ describe('toStrictJsonSchema', () => { `); }); + test('inlines a single allOf variant with annotation siblings', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + value: { + description: 'A string value', + allOf: [{ type: 'string' }], + }, + }, + required: ['value'], + }; + + expect(toStrictJsonSchema(schema)).toEqual({ + type: 'object', + properties: { + value: { + type: 'string', + description: 'A string value', + }, + }, + required: ['value'], + additionalProperties: false, + }); + }); + + test('does not flatten a single allOf variant across sibling constraints', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + value: { + type: 'string', + allOf: [{ type: 'number' }], + }, + }, + required: ['value'], + }; + + expect(toStrictJsonSchema(schema)).toEqual({ + type: 'object', + properties: { + value: { + type: 'string', + allOf: [{ type: 'number' }], + }, + }, + required: ['value'], + additionalProperties: false, + }); + }); + test('processes multiple allOf variants', () => { const schema: JSONSchema = { type: 'object', From b5e0c456cc6287fd762bd8be8e63c88eab404a80 Mon Sep 17 00:00:00 2001 From: Hayden Date: Tue, 21 Jul 2026 00:00:22 +0000 Subject: [PATCH 09/35] fix(helpers): preserve draft 7 strict semantics --- src/lib/transform.ts | 333 +++++++++++++++++++++++++++++++++--- tests/lib/transform.test.ts | 141 ++++++++++++--- 2 files changed, 426 insertions(+), 48 deletions(-) diff --git a/src/lib/transform.ts b/src/lib/transform.ts index 532be8ccf2..522a61fcf7 100644 --- a/src/lib/transform.ts +++ b/src/lib/transform.ts @@ -20,6 +20,39 @@ const JSON_SCHEMA_OBJECT_KEYWORDS = new Set([ 'required', ]); +const JSON_SCHEMA_SINGLE_SCHEMA_KEYWORDS = [ + 'additionalItems', + 'additionalProperties', + 'contains', + 'contentSchema', + 'else', + 'if', + 'not', + 'propertyNames', + 'then', + 'unevaluatedItems', + 'unevaluatedProperties', +]; + +const JSON_SCHEMA_ARRAY_SCHEMA_KEYWORDS = ['allOf', 'anyOf', 'items', 'oneOf', 'prefixItems']; + +const JSON_SCHEMA_MAP_SCHEMA_KEYWORDS = [ + '$defs', + 'definitions', + 'dependentSchemas', + 'dependencies', + 'patternProperties', + 'properties', +]; + +const MERGEABLE_OBJECT_ALL_OF_KEYWORDS = new Set([ + ...JSON_SCHEMA_ANNOTATION_KEYWORDS, + 'additionalProperties', + 'properties', + 'required', + 'type', +]); + export function toStrictJsonSchema(schema: JSONSchema): JSONSchema { if (schema.type !== 'object') { throw new Error( @@ -28,6 +61,7 @@ export function toStrictJsonSchema(schema: JSONSchema): JSONSchema { } const schemaCopy = structuredClone(schema); + validateRefSchemas(schemaCopy, []); return ensureStrictJsonSchema(schemaCopy, [], schemaCopy); } @@ -143,16 +177,19 @@ function ensureStrictJsonSchema( } } + // Closing each object branch in an allOf independently changes the + // intersection: sibling branches' properties become forbidden extras. Merge + // the small object-intersection subset that can be represented exactly before + // applying strict object closure, and fail closed for the rest. + if (mergeObjectAllOf(jsonSchema, path)) { + return ensureStrictJsonSchema(jsonSchema, path, root); + } + // Add additionalProperties: false to object schemas. Draft 7 permits object // keywords without an explicit type, so those implicit object shapes need // the same strict handling as type: 'object'. Explicitly open object schemas // cannot be represented in Structured Outputs strict mode. - const typ = jsonSchema.type; - if ( - typ === 'object' || - (Array.isArray(typ) && typ.includes('object')) || - (typ === undefined && hasObjectKeywords(jsonSchema)) - ) { + if (hasObjectShape(jsonSchema)) { if (!('additionalProperties' in jsonSchema)) { jsonSchema.additionalProperties = false; } else if (jsonSchema.additionalProperties !== false) { @@ -165,9 +202,25 @@ function ensureStrictJsonSchema( } const required = jsonSchema.required ?? []; + if (!Array.isArray(required) || required.some((key) => typeof key !== 'string')) { + throw new TypeError( + `Expected \`required\` to be an array of strings; path=${path.join('/') || ''}`, + ); + } // Handle object properties const properties = jsonSchema.properties; + if (hasObjectShape(jsonSchema)) { + for (const key of required) { + if (!isObject(properties) || !Object.prototype.hasOwnProperty.call(properties, key)) { + throw new Error( + `Object schema at \`${ + path.join('/') || '' + }\` requires property \`${key}\` but does not declare it in \`properties\`.`, + ); + } + } + } if (isObject(properties)) { for (const [key, value] of Object.entries(properties)) { if (!isNullable(value, root) && !required.includes(key)) { @@ -197,6 +250,28 @@ function ensureStrictJsonSchema( jsonSchema.items = ensureStrictJsonSchema(items, [...path, 'items'], root); } + // Draft 7 only applies additionalItems to tuple-style items arrays. Recurse + // into schema-valued extras so nested objects are strictified too; reject an + // ignored additionalItems rather than sending a keyword whose semantics may + // differ under Structured Outputs. + const additionalItems = jsonSchema.additionalItems; + if (additionalItems !== undefined) { + if (!Array.isArray(items)) { + throw new Error( + `Schema at \`${ + path.join('/') || '' + }\` uses \`additionalItems\` without tuple \`items\`, which cannot be represented in strict Structured Outputs.`, + ); + } + if (typeof additionalItems !== 'boolean') { + jsonSchema.additionalItems = ensureStrictJsonSchema( + additionalItems, + [...path, 'additionalItems'], + root, + ); + } + } + // Handle unions (anyOf) const anyOf = jsonSchema.anyOf; if (Array.isArray(anyOf)) { @@ -229,10 +304,6 @@ function ensureStrictJsonSchema( // Handle $ref with additional properties const ref = (jsonSchema as any).$ref; if (ref && hasMoreThanNKeys(jsonSchema, 1)) { - if (typeof ref !== 'string') { - throw new TypeError(`Received non-string $ref - ${ref}; path=${path.join('/')}`); - } - const resolved = resolveRef(root, ref); if (typeof resolved === 'boolean') { throw new Error(`Expected \`$ref: ${ref}\` to resolve to an object schema but got boolean`); @@ -256,22 +327,13 @@ function ensureStrictJsonSchema( } function resolveRef(root: JSONSchema, ref: string): JSONSchemaDefinition { - if (!ref.startsWith('#/')) { - throw new Error(`Unexpected $ref format ${JSON.stringify(ref)}; Does not start with #/`); + if (ref === '#') { + throw new Error('Cannot inline a root `$ref` with sibling annotations'); } - const pathParts = ref.slice(2).split('/'); - let resolved: any = root; - - for (const key of pathParts) { - if (!isObject(resolved)) { - throw new Error(`encountered non-object entry while resolving ${ref} - ${JSON.stringify(resolved)}`); - } - const value = resolved[key]; - if (value === undefined) { - throw new Error(`Key ${key} not found while resolving ${ref}`); - } - resolved = value; + const resolved = resolveLocalRef(root, ref); + if (resolved === undefined) { + throw new Error(`Key not found while resolving ${ref}`); } return resolved; @@ -317,6 +379,229 @@ function hasObjectKeywords(schema: JSONSchema): boolean { return Object.keys(schema).some((keyword) => JSON_SCHEMA_OBJECT_KEYWORDS.has(keyword)); } +function hasObjectShape(schema: JSONSchema): boolean { + const typ = schema.type; + return ( + typ === 'object' || + (Array.isArray(typ) && typ.includes('object')) || + (typ === undefined && hasObjectKeywords(schema)) + ); +} + +function validateRefSchemas(schema: JSONSchemaDefinition, path: string[]): void { + if (typeof schema === 'boolean' || !isObject(schema)) { + return; + } + + const ref = schema.$ref; + if (ref !== undefined) { + if (typeof ref !== 'string') { + throw new TypeError(`Received non-string $ref - ${ref}; path=${path.join('/')}`); + } + if (ref !== '#' && !ref.startsWith('#/')) { + throw new Error( + `External $ref at \`${ + path.join('/') || '' + }\` is not supported in strict Structured Outputs: ${JSON.stringify(ref)}`, + ); + } + if (!hasOnlyRefAndAnnotations(schema)) { + throw new Error( + `Schema $ref at \`${ + path.join('/') || '' + }\` has non-annotation siblings that Draft 7 ignores and cannot be represented in strict Structured Outputs.`, + ); + } + } + + for (const keyword of JSON_SCHEMA_SINGLE_SCHEMA_KEYWORDS) { + validateRefSchemas((schema as any)[keyword], [...path, keyword]); + } + + for (const keyword of JSON_SCHEMA_ARRAY_SCHEMA_KEYWORDS) { + const children = (schema as any)[keyword]; + if (Array.isArray(children)) { + for (const [index, child] of children.entries()) { + validateRefSchemas(child, [...path, keyword, String(index)]); + } + } else { + validateRefSchemas(children, [...path, keyword]); + } + } + + for (const keyword of JSON_SCHEMA_MAP_SCHEMA_KEYWORDS) { + const children = (schema as any)[keyword]; + if (isObject(children)) { + for (const [key, child] of Object.entries(children)) { + validateRefSchemas(child as JSONSchemaDefinition, [...path, keyword, key]); + } + } + } +} + +function mergeObjectAllOf(jsonSchema: JSONSchema, path: string[]): boolean { + const allOf = jsonSchema.allOf; + if (!Array.isArray(allOf) || allOf.length === 0) { + return false; + } + + const parentHasObjectShape = hasObjectShapeWithoutAllOf(jsonSchema); + const objectBranches = allOf.filter( + (entry): entry is JSONSchema => isObject(entry) && hasObjectShapeWithoutAllOf(entry), + ); + if (!parentHasObjectShape && objectBranches.length === 0) { + return false; + } + // A lone object branch with no object-valued parent is handled by the + // existing safe single-allOf flattening path below. + if (!parentHasObjectShape && allOf.length === 1) { + return false; + } + + const fail = (): never => { + throw new Error( + `Object allOf at \`${ + path.join('/') || '' + }\` cannot be merged without changing Draft 7 validation.`, + ); + }; + + if ( + !parentHasObjectShape && + ['additionalProperties', 'properties', 'required', 'type'].some((keyword) => keyword in jsonSchema) + ) { + fail(); + } + + for (const keyword of Object.keys(jsonSchema)) { + if ( + keyword !== 'allOf' && + keyword !== '$defs' && + keyword !== 'definitions' && + !MERGEABLE_OBJECT_ALL_OF_KEYWORDS.has(keyword) + ) { + fail(); + } + } + + const branches: JSONSchema[] = []; + if (parentHasObjectShape) { + branches.push(jsonSchema); + } + for (const entry of allOf) { + if (!isObject(entry)) { + fail(); + } + const branch = entry as JSONSchema; + if (hasObjectShapeWithoutAllOf(branch)) { + branches.push(branch); + } else if (!Object.keys(branch).every((keyword) => JSON_SCHEMA_ANNOTATION_KEYWORDS.has(keyword))) { + fail(); + } + } + + const merged: JSONSchema = {}; + for (const keyword of ['$defs', 'definitions'] as const) { + if (jsonSchema[keyword] !== undefined) { + merged[keyword] = jsonSchema[keyword]; + } + } + + const mergedProperties: Record = {}; + const mergedRequired = new Set(); + const closedPropertySets: Set[] = []; + let sawProperties = false; + let sawRequired = false; + let hasExplicitObjectType = false; + + const mergeAnnotations = (schema: JSONSchema) => { + for (const keyword of JSON_SCHEMA_ANNOTATION_KEYWORDS) { + if (!(keyword in schema)) continue; + if (keyword in merged && !schemasEqual((merged as any)[keyword], (schema as any)[keyword])) { + fail(); + } + (merged as any)[keyword] = (schema as any)[keyword]; + } + }; + + mergeAnnotations(jsonSchema); + for (const entry of allOf) { + if (isObject(entry)) mergeAnnotations(entry); + } + + for (const branch of branches) { + for (const keyword of Object.keys(branch)) { + if (keyword === 'allOf' && branch === jsonSchema) continue; + if ((keyword === '$defs' || keyword === 'definitions') && branch === jsonSchema) continue; + if (!MERGEABLE_OBJECT_ALL_OF_KEYWORDS.has(keyword)) { + fail(); + } + } + + if (branch.type !== undefined) { + if (branch.type !== 'object') { + fail(); + } + hasExplicitObjectType = true; + } + + if (branch.properties !== undefined) { + if (!isObject(branch.properties)) { + fail(); + } + sawProperties = true; + for (const [key, propertySchema] of Object.entries(branch.properties)) { + if (key in mergedProperties && !schemasEqual(mergedProperties[key], propertySchema)) { + fail(); + } + mergedProperties[key] = propertySchema; + } + } + + if (branch.required !== undefined) { + if (!Array.isArray(branch.required) || branch.required.some((key) => typeof key !== 'string')) { + fail(); + } + sawRequired = true; + for (const key of branch.required) mergedRequired.add(key); + } + + if ('additionalProperties' in branch) { + if (branch.additionalProperties !== false) { + fail(); + } + closedPropertySets.push(new Set(Object.keys(branch.properties ?? {}))); + } + } + + const mergedPropertyNames = Object.keys(mergedProperties); + if (closedPropertySets.some((keys) => mergedPropertyNames.some((key) => !keys.has(key)))) { + fail(); + } + + if (hasExplicitObjectType) merged.type = 'object'; + if (sawProperties) merged.properties = mergedProperties; + if (sawRequired) merged.required = [...mergedRequired]; + if (closedPropertySets.length > 0) merged.additionalProperties = false; + + for (const keyword of Object.keys(jsonSchema)) { + delete (jsonSchema as any)[keyword]; + } + Object.assign(jsonSchema, merged); + return true; +} + +function hasObjectShapeWithoutAllOf(schema: JSONSchema): boolean { + if (schema.type !== undefined) { + return schema.type === 'object'; + } + return Object.keys(schema).some((keyword) => JSON_SCHEMA_OBJECT_KEYWORDS.has(keyword)); +} + +function schemasEqual(left: unknown, right: unknown): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + function hasMoreThanNKeys(obj: Record, n: number): boolean { let i = 0; for (const _ in obj) { diff --git a/tests/lib/transform.test.ts b/tests/lib/transform.test.ts index f79c4e1438..5214141126 100644 --- a/tests/lib/transform.test.ts +++ b/tests/lib/transform.test.ts @@ -320,9 +320,7 @@ describe('toStrictJsonSchema', () => { test.each([ ['missing', { $ref: '#/$defs/Missing' }], - ['external', { $ref: 'https://example.com/schema.json#/$defs/NullableString' }], ['cyclic', { $ref: '#/$defs/Cyclic' }], - ['sibling-constrained', { $ref: '#/$defs/NullableString', type: 'string' }], ])('conservatively rejects %s refs when checking nullable optional properties', (_name, property) => { const schema: JSONSchema = { type: 'object', @@ -340,6 +338,49 @@ describe('toStrictJsonSchema', () => { ); }); + test('rejects external refs before returning a strict schema', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + nickname: { $ref: 'https://example.com/schema.json#/$defs/NullableString' }, + }, + required: ['nickname'], + }; + + expect(() => toStrictJsonSchema(schema)).toThrow( + 'External $ref at `properties/nickname` is not supported in strict Structured Outputs', + ); + }); + + test.each([false, true])('rejects Draft 7 validation siblings on %s $ref properties', (isRequired) => { + const schema: JSONSchema = { + type: 'object', + $defs: { + Text: { type: 'string' }, + }, + properties: { + value: { $ref: '#/$defs/Text', type: 'number' }, + }, + ...(isRequired ? { required: ['value'] } : {}), + }; + + expect(() => toStrictJsonSchema(schema)).toThrow('has non-annotation siblings that Draft 7 ignores'); + }); + + test('rejects required properties missing from properties', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + name: { type: 'string' }, + }, + required: ['name', 'age'], + }; + + expect(() => toStrictJsonSchema(schema)).toThrow( + 'requires property `age` but does not declare it in `properties`', + ); + }); + test('throws error for optional properties without nullable', () => { const schema: JSONSchema = { type: 'object', @@ -481,6 +522,42 @@ describe('toStrictJsonSchema', () => { additionalProperties: false, }); }); + + test('processes schema-valued additionalItems recursively', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + tuple: { + type: 'array', + items: [{ type: 'string' }], + additionalItems: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + }, + }, + }, + required: ['tuple'], + }; + + expect(toStrictJsonSchema(schema)).toEqual({ + type: 'object', + properties: { + tuple: { + type: 'array', + items: [{ type: 'string' }], + additionalItems: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + additionalProperties: false, + }, + }, + }, + required: ['tuple'], + additionalProperties: false, + }); + }); }); describe('anyOf Handling', () => { @@ -635,7 +712,7 @@ describe('toStrictJsonSchema', () => { }); }); - test('processes multiple allOf variants', () => { + test('merges compatible object allOf variants before closing them', () => { const schema: JSONSchema = { type: 'object', properties: { @@ -657,30 +734,46 @@ describe('toStrictJsonSchema', () => { required: ['value'], }; - const strict = toStrictJsonSchema(schema); - const diff = detailedDiff(schema, strict); + expect(toStrictJsonSchema(schema)).toEqual({ + type: 'object', + properties: { + value: { + type: 'object', + properties: { name: { type: 'string' }, age: { type: 'number' } }, + required: ['name', 'age'], + additionalProperties: false, + }, + }, + required: ['value'], + additionalProperties: false, + }); + }); - expect(diff).toMatchInlineSnapshot(` - { - "added": { - "additionalProperties": false, - "properties": { - "value": { - "allOf": { - "0": { - "additionalProperties": false, - }, - "1": { - "additionalProperties": false, - }, - }, + test('rejects object allOf variants that cannot be merged exactly', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + value: { + allOf: [ + { + type: 'object', + properties: { shared: { type: 'string' } }, + required: ['shared'], }, - }, + { + type: 'object', + properties: { shared: { type: 'number' } }, + required: ['shared'], + }, + ], }, - "deleted": {}, - "updated": {}, - } - `); + }, + required: ['value'], + }; + + expect(() => toStrictJsonSchema(schema)).toThrow( + 'cannot be merged without changing Draft 7 validation', + ); }); }); From 20005e852e397895ccb8b2a8664df0f77b094a42 Mon Sep 17 00:00:00 2001 From: Hayden Date: Tue, 21 Jul 2026 00:38:40 +0000 Subject: [PATCH 10/35] fix(helpers): cover draft 7 schema traversal --- src/helpers/standard-schema.ts | 41 +----- src/lib/transform.ts | 202 +++++++++++--------------- tests/helpers/standard-schema.test.ts | 53 +++++++ tests/lib/transform.test.ts | 172 +++++++++++++++++++++- 4 files changed, 310 insertions(+), 158 deletions(-) diff --git a/src/helpers/standard-schema.ts b/src/helpers/standard-schema.ts index c7b758985b..1cc9d7406a 100644 --- a/src/helpers/standard-schema.ts +++ b/src/helpers/standard-schema.ts @@ -9,7 +9,7 @@ import { } from '../lib/parser'; import { AutoParseableResponseTool, makeParseableResponseTool } from '../lib/ResponsesParser'; import { type JSONSchema } from '../lib/jsonschema'; -import { toStrictJsonSchema } from '../lib/transform'; +import { forEachJSONSchemaChild, toStrictJsonSchema } from '../lib/transform'; import { ResponseFormatJSONSchema } from '../resources/index'; import { type ResponseFormatTextJSONSchemaConfig } from '../resources/responses/responses'; @@ -301,44 +301,7 @@ function normalizeStructuredOutputSchema(schema: JSONSchema): JSONSchema { delete record['oneOf']; } - for (const keyword of [ - 'additionalItems', - 'additionalProperties', - 'contains', - 'contentSchema', - 'else', - 'if', - 'not', - 'propertyNames', - 'then', - 'unevaluatedItems', - 'unevaluatedProperties', - ]) { - visitSchema(record[keyword]); - } - - for (const keyword of ['allOf', 'anyOf', 'items', 'prefixItems']) { - const children = record[keyword]; - if (Array.isArray(children)) { - for (const child of children) visitSchema(child); - } else { - visitSchema(children); - } - } - - for (const keyword of [ - '$defs', - 'definitions', - 'dependentSchemas', - 'dependencies', - 'patternProperties', - 'properties', - ]) { - const children = record[keyword]; - if (children && typeof children === 'object' && !Array.isArray(children)) { - for (const child of Object.values(children)) visitSchema(child); - } - } + forEachJSONSchemaChild(record, [], (child) => visitSchema(child)); }; visitSchema(normalizedSchema); diff --git a/src/lib/transform.ts b/src/lib/transform.ts index 522a61fcf7..8a7d238242 100644 --- a/src/lib/transform.ts +++ b/src/lib/transform.ts @@ -45,6 +45,21 @@ const JSON_SCHEMA_MAP_SCHEMA_KEYWORDS = [ 'properties', ]; +const JSON_SCHEMA_UNSUPPORTED_SCHEMA_KEYWORDS = new Set([ + 'contains', + 'contentSchema', + 'dependentSchemas', + 'dependencies', + 'else', + 'if', + 'not', + 'prefixItems', + 'propertyNames', + 'then', + 'unevaluatedItems', + 'unevaluatedProperties', +]); + const MERGEABLE_OBJECT_ALL_OF_KEYWORDS = new Set([ ...JSON_SCHEMA_ANNOTATION_KEYWORDS, 'additionalProperties', @@ -53,6 +68,50 @@ const MERGEABLE_OBJECT_ALL_OF_KEYWORDS = new Set([ 'type', ]); +type JSONSchemaChildVisitor = (schema: unknown, path: string[], keyword: string) => void; + +/** + * Visits only values carried by JSON Schema keywords that contain schemas. + * Literal payloads such as enum, const, and default deliberately do not + * participate. + */ +export function forEachJSONSchemaChild( + schema: JSONSchema | Record, + path: string[], + visit: JSONSchemaChildVisitor, +): void { + const record = schema as Record; + + for (const keyword of JSON_SCHEMA_SINGLE_SCHEMA_KEYWORDS) { + if (keyword in record) { + visit(record[keyword], [...path, keyword], keyword); + } + } + + for (const keyword of JSON_SCHEMA_ARRAY_SCHEMA_KEYWORDS) { + const children = record[keyword]; + if (Array.isArray(children)) { + for (const [index, child] of children.entries()) { + visit(child, [...path, keyword, String(index)], keyword); + } + } else if (children !== undefined) { + visit(children, [...path, keyword], keyword); + } + } + + for (const keyword of JSON_SCHEMA_MAP_SCHEMA_KEYWORDS) { + const children = record[keyword]; + if (!isObject(children)) continue; + + for (const [key, child] of Object.entries(children)) { + // Draft 7 dependencies also permits property dependency arrays. They + // are not schemas and must not be traversed as literal JSON payloads. + if (keyword === 'dependencies' && !isSchemaDefinition(child)) continue; + visit(child, [...path, keyword, key], keyword); + } + } +} + export function toStrictJsonSchema(schema: JSONSchema): JSONSchema { if (schema.type !== 'object') { throw new Error( @@ -161,22 +220,6 @@ function ensureStrictJsonSchema( throw new TypeError(`Expected ${JSON.stringify(jsonSchema)} to be an object; path=${path.join('/')}`); } - // Handle $defs (non-standard but sometimes used) - const defs = (jsonSchema as any).$defs; - if (isObject(defs)) { - for (const [defName, defSchema] of Object.entries(defs)) { - ensureStrictJsonSchema(defSchema as JSONSchema, [...path, '$defs', defName], root); - } - } - - // Handle definitions (draft-04 style, deprecated in draft-07 but still used) - const definitions = (jsonSchema as any).definitions; - if (isObject(definitions)) { - for (const [definitionName, definitionSchema] of Object.entries(definitions)) { - ensureStrictJsonSchema(definitionSchema as JSONSchema, [...path, 'definitions', definitionName], root); - } - } - // Closing each object branch in an allOf independently changes the // intersection: sibling branches' properties become forbidden extras. Merge // the small object-intersection subset that can be represented exactly before @@ -232,28 +275,13 @@ function ensureStrictJsonSchema( } } jsonSchema.required = Object.keys(properties); - jsonSchema.properties = Object.fromEntries( - Object.entries(properties).map(([key, propSchema]) => [ - key, - ensureStrictJsonSchema(propSchema, [...path, 'properties', key], root), - ]), - ); - } - - // Handle arrays - const items = jsonSchema.items; - if (Array.isArray(items)) { - jsonSchema.items = items.map((item, i) => - ensureStrictJsonSchema(item, [...path, 'items', String(i)], root), - ); - } else if (items !== undefined) { - jsonSchema.items = ensureStrictJsonSchema(items, [...path, 'items'], root); } // Draft 7 only applies additionalItems to tuple-style items arrays. Recurse // into schema-valued extras so nested objects are strictified too; reject an // ignored additionalItems rather than sending a keyword whose semantics may // differ under Structured Outputs. + const items = jsonSchema.items; const additionalItems = jsonSchema.additionalItems; if (additionalItems !== undefined) { if (!Array.isArray(items)) { @@ -263,21 +291,6 @@ function ensureStrictJsonSchema( }\` uses \`additionalItems\` without tuple \`items\`, which cannot be represented in strict Structured Outputs.`, ); } - if (typeof additionalItems !== 'boolean') { - jsonSchema.additionalItems = ensureStrictJsonSchema( - additionalItems, - [...path, 'additionalItems'], - root, - ); - } - } - - // Handle unions (anyOf) - const anyOf = jsonSchema.anyOf; - if (Array.isArray(anyOf)) { - jsonSchema.anyOf = anyOf.map((variant, i) => - ensureStrictJsonSchema(variant, [...path, 'anyOf', String(i)], root), - ); } // Handle intersections (allOf) @@ -289,54 +302,36 @@ function ensureStrictJsonSchema( delete annotations.allOf; Object.assign(jsonSchema, resolved, annotations); delete jsonSchema.allOf; - } else { - jsonSchema.allOf = allOf.map((entry, i) => - ensureStrictJsonSchema(entry, [...path, 'allOf', String(i)], root), - ); } } - // Strip `null` defaults as there's no meaningful distinction - if (jsonSchema.default === null) { - delete jsonSchema.default; - } - - // Handle $ref with additional properties - const ref = (jsonSchema as any).$ref; - if (ref && hasMoreThanNKeys(jsonSchema, 1)) { - const resolved = resolveRef(root, ref); - if (typeof resolved === 'boolean') { - throw new Error(`Expected \`$ref: ${ref}\` to resolve to an object schema but got boolean`); - } - if (!isObject(resolved)) { + for (const keyword of JSON_SCHEMA_UNSUPPORTED_SCHEMA_KEYWORDS) { + if (keyword in jsonSchema) { throw new Error( - `Expected \`$ref: ${ref}\` to resolve to an object but got ${JSON.stringify(resolved)}`, + `Schema at \`${ + path.join('/') || '' + }\` uses unsupported keyword \`${keyword}\` and cannot be represented in strict Structured Outputs.`, ); } - - // Properties from the json schema take priority over the ones on the `$ref` - Object.assign(jsonSchema, { ...resolved, ...jsonSchema }); - delete (jsonSchema as any).$ref; - - // Since the schema expanded from `$ref` might not have `additionalProperties: false` applied, - // we call `ensureStrictJsonSchema` again to fix the inlined schema and ensure it's valid. - return ensureStrictJsonSchema(jsonSchema, path, root); } - return jsonSchema; -} + forEachJSONSchemaChild(jsonSchema, path, (child, childPath, keyword) => { + // These boolean forms are already handled as parent-keyword semantics: + // additionalProperties: false closes objects, while boolean + // additionalItems does not contain a nested schema to strictify. + if (typeof child === 'boolean' && (keyword === 'additionalProperties' || keyword === 'additionalItems')) { + return; + } -function resolveRef(root: JSONSchema, ref: string): JSONSchemaDefinition { - if (ref === '#') { - throw new Error('Cannot inline a root `$ref` with sibling annotations'); - } + ensureStrictJsonSchema(child as JSONSchemaDefinition, childPath, root); + }); - const resolved = resolveLocalRef(root, ref); - if (resolved === undefined) { - throw new Error(`Key not found while resolving ${ref}`); + // Strip `null` defaults as there's no meaningful distinction + if (jsonSchema.default === null) { + delete jsonSchema.default; } - return resolved; + return jsonSchema; } function resolveLocalRef(root: JSONSchema, ref: string): JSONSchemaDefinition | undefined { @@ -363,6 +358,10 @@ function isObject(obj: T | Array): obj is Extract return typeof obj === 'object' && obj !== null && !Array.isArray(obj); } +function isSchemaDefinition(value: unknown): value is JSONSchemaDefinition { + return typeof value === 'boolean' || isObject(value); +} + function hasOnlyRefAndAnnotations(schema: JSONSchema): boolean { return Object.keys(schema).every( (keyword) => keyword === '$ref' || JSON_SCHEMA_ANNOTATION_KEYWORDS.has(keyword), @@ -414,29 +413,9 @@ function validateRefSchemas(schema: JSONSchemaDefinition, path: string[]): void } } - for (const keyword of JSON_SCHEMA_SINGLE_SCHEMA_KEYWORDS) { - validateRefSchemas((schema as any)[keyword], [...path, keyword]); - } - - for (const keyword of JSON_SCHEMA_ARRAY_SCHEMA_KEYWORDS) { - const children = (schema as any)[keyword]; - if (Array.isArray(children)) { - for (const [index, child] of children.entries()) { - validateRefSchemas(child, [...path, keyword, String(index)]); - } - } else { - validateRefSchemas(children, [...path, keyword]); - } - } - - for (const keyword of JSON_SCHEMA_MAP_SCHEMA_KEYWORDS) { - const children = (schema as any)[keyword]; - if (isObject(children)) { - for (const [key, child] of Object.entries(children)) { - validateRefSchemas(child as JSONSchemaDefinition, [...path, keyword, key]); - } - } - } + forEachJSONSchemaChild(schema, path, (child, childPath) => { + validateRefSchemas(child as JSONSchemaDefinition, childPath); + }); } function mergeObjectAllOf(jsonSchema: JSONSchema, path: string[]): boolean { @@ -601,14 +580,3 @@ function hasObjectShapeWithoutAllOf(schema: JSONSchema): boolean { function schemasEqual(left: unknown, right: unknown): boolean { return JSON.stringify(left) === JSON.stringify(right); } - -function hasMoreThanNKeys(obj: Record, n: number): boolean { - let i = 0; - for (const _ in obj) { - i++; - if (i > n) { - return true; - } - } - return false; -} diff --git a/tests/helpers/standard-schema.test.ts b/tests/helpers/standard-schema.test.ts index 838d374760..0b9824ad53 100644 --- a/tests/helpers/standard-schema.test.ts +++ b/tests/helpers/standard-schema.test.ts @@ -353,6 +353,59 @@ describe('Standard Schema helpers', () => { }); }); + it('strictifies object schemas nested under patternProperties', () => { + const { standardSchema } = makeStandardSchema({ + type: 'object', + properties: { + metadata: { + type: 'object', + patternProperties: { + '^x-': { + type: 'object', + properties: { value: { type: 'string' } }, + required: ['value'], + }, + }, + additionalProperties: false, + }, + }, + required: ['metadata'], + }); + + expect(standardResponseFormat(standardSchema, 'metadata').json_schema.schema).toMatchObject({ + properties: { + metadata: { + patternProperties: { + '^x-': { + additionalProperties: false, + }, + }, + }, + }, + }); + }); + + it('rejects unsupported nested schema keywords before returning a strict schema', () => { + const { standardSchema } = makeStandardSchema({ + type: 'object', + properties: { + values: { + type: 'array', + contains: { + type: 'object', + properties: { value: { type: 'string' } }, + required: ['value'], + }, + }, + }, + required: ['values'], + }); + + expect(() => standardResponseFormat(standardSchema, 'values')).toThrow( + 'uses unsupported keyword `contains`', + ); + }); + it.each([ ['true', true], ['schema-valued', { type: 'string' }], diff --git a/tests/lib/transform.test.ts b/tests/lib/transform.test.ts index 5214141126..5946a3701a 100644 --- a/tests/lib/transform.test.ts +++ b/tests/lib/transform.test.ts @@ -310,8 +310,8 @@ describe('toStrictJsonSchema', () => { const strict = toStrictJsonSchema(schema); expect(strict.required).toEqual(['nickname']); - expect(strict.properties?.['nickname']).toMatchObject({ - type: ['string', 'null'], + expect(strict.properties?.['nickname']).toEqual({ + $ref: '#/$defs/NullableString', title: 'Nickname', description: 'A preferred name', examples: [null], @@ -558,6 +558,114 @@ describe('toStrictJsonSchema', () => { additionalProperties: false, }); }); + + test('processes patternProperties schemas recursively', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + metadata: { + type: 'object', + patternProperties: { + '^x-': { + type: 'object', + properties: { value: { type: 'string' } }, + required: ['value'], + }, + }, + additionalProperties: false, + }, + }, + required: ['metadata'], + }; + + expect(toStrictJsonSchema(schema)).toMatchObject({ + properties: { + metadata: { + patternProperties: { + '^x-': { + additionalProperties: false, + }, + }, + }, + }, + }); + }); + + test.each([ + [ + 'contains', + { + type: 'array', + contains: { + type: 'object', + properties: { value: { type: 'string' } }, + required: ['value'], + }, + }, + ], + [ + 'propertyNames', + { + type: 'object', + properties: {}, + propertyNames: { type: 'string' }, + additionalProperties: false, + }, + ], + [ + 'if/then/else', + { + type: 'object', + properties: {}, + if: { type: 'object' }, + then: { type: 'object' }, + else: { type: 'object' }, + additionalProperties: false, + }, + ], + [ + 'schema-valued dependencies', + { + type: 'object', + properties: { value: { type: 'string' } }, + required: ['value'], + dependencies: { + value: { + type: 'object', + properties: { dependent: { type: 'string' } }, + required: ['dependent'], + }, + }, + additionalProperties: false, + }, + ], + [ + 'dependentSchemas', + { + type: 'object', + properties: { value: { type: 'string' } }, + required: ['value'], + dependentSchemas: { + value: { + type: 'object', + properties: { dependent: { type: 'string' } }, + required: ['dependent'], + }, + }, + additionalProperties: false, + }, + ], + ])('rejects unsupported nested %s schemas', (_name, propertySchema) => { + const schema: JSONSchema = { + type: 'object', + properties: { + value: propertySchema as JSONSchema, + }, + required: ['value'], + }; + + expect(() => toStrictJsonSchema(schema)).toThrow('uses unsupported keyword'); + }); }); describe('anyOf Handling', () => { @@ -778,6 +886,66 @@ describe('toStrictJsonSchema', () => { }); describe('$ref Resolution', () => { + test('retains annotation-only recursive refs without expanding them', () => { + const schema: JSONSchema = { + type: 'object', + $defs: { + Node: { + type: 'object', + properties: { + value: { type: 'string' }, + next: { + anyOf: [{ $ref: '#/$defs/Node', description: 'The next node' }, { type: 'null' }], + }, + }, + required: ['value', 'next'], + }, + }, + properties: { + root: { $ref: '#/$defs/Node', description: 'The root node' }, + }, + required: ['root'], + }; + + expect(toStrictJsonSchema(schema)).toMatchObject({ + $defs: { + Node: { + additionalProperties: false, + properties: { + next: { + anyOf: [{ $ref: '#/$defs/Node', description: 'The next node' }, { type: 'null' }], + }, + }, + }, + }, + properties: { + root: { $ref: '#/$defs/Node', description: 'The root node' }, + }, + }); + }); + + test('retains validation through annotated local reference chains', () => { + const schema: JSONSchema = { + type: 'object', + $defs: { + Text: { type: 'string' }, + Alias: { $ref: '#/$defs/Text' }, + }, + properties: { + value: { $ref: '#/$defs/Alias', description: 'A text value' }, + }, + required: ['value'], + }; + + const strict = toStrictJsonSchema(schema); + + expect(strict.$defs?.['Alias']).toEqual({ $ref: '#/$defs/Text' }); + expect(strict.properties?.['value']).toEqual({ + $ref: '#/$defs/Alias', + description: 'A text value', + }); + }); + test('processes definitions', () => { const schema: JSONSchema = { type: 'object', From 93654ed26a2b792354b9e63cdc102d30ddb18581 Mon Sep 17 00:00:00 2001 From: Hayden Date: Tue, 21 Jul 2026 00:49:08 +0000 Subject: [PATCH 11/35] fix(helpers): validate strict schema refs --- src/lib/transform.ts | 24 +++++++++--- tests/lib/transform.test.ts | 76 ++++++++++++++++++++++++++++--------- 2 files changed, 77 insertions(+), 23 deletions(-) diff --git a/src/lib/transform.ts b/src/lib/transform.ts index 8a7d238242..36959e4295 100644 --- a/src/lib/transform.ts +++ b/src/lib/transform.ts @@ -46,6 +46,7 @@ const JSON_SCHEMA_MAP_SCHEMA_KEYWORDS = [ ]; const JSON_SCHEMA_UNSUPPORTED_SCHEMA_KEYWORDS = new Set([ + 'allOf', 'contains', 'contentSchema', 'dependentSchemas', @@ -120,7 +121,7 @@ export function toStrictJsonSchema(schema: JSONSchema): JSONSchema { } const schemaCopy = structuredClone(schema); - validateRefSchemas(schemaCopy, []); + validateRefSchemas(schemaCopy, [], schemaCopy); return ensureStrictJsonSchema(schemaCopy, [], schemaCopy); } @@ -387,7 +388,7 @@ function hasObjectShape(schema: JSONSchema): boolean { ); } -function validateRefSchemas(schema: JSONSchemaDefinition, path: string[]): void { +function validateRefSchemas(schema: JSONSchemaDefinition, path: string[], root: JSONSchema): void { if (typeof schema === 'boolean' || !isObject(schema)) { return; } @@ -404,6 +405,14 @@ function validateRefSchemas(schema: JSONSchemaDefinition, path: string[]): void }\` is not supported in strict Structured Outputs: ${JSON.stringify(ref)}`, ); } + const resolved = resolveLocalRef(root, ref); + if (resolved === undefined || !isSchemaDefinition(resolved)) { + throw new Error( + `Local $ref at \`${ + path.join('/') || '' + }\` does not resolve to an object or boolean schema: ${JSON.stringify(ref)}`, + ); + } if (!hasOnlyRefAndAnnotations(schema)) { throw new Error( `Schema $ref at \`${ @@ -414,7 +423,7 @@ function validateRefSchemas(schema: JSONSchemaDefinition, path: string[]): void } forEachJSONSchemaChild(schema, path, (child, childPath) => { - validateRefSchemas(child as JSONSchemaDefinition, childPath); + validateRefSchemas(child as JSONSchemaDefinition, childPath, root); }); } @@ -486,7 +495,7 @@ function mergeObjectAllOf(jsonSchema: JSONSchema, path: string[]): boolean { } } - const mergedProperties: Record = {}; + const mergedProperties = Object.create(null) as Record; const mergedRequired = new Set(); const closedPropertySets: Set[] = []; let sawProperties = false; @@ -530,7 +539,10 @@ function mergeObjectAllOf(jsonSchema: JSONSchema, path: string[]): boolean { } sawProperties = true; for (const [key, propertySchema] of Object.entries(branch.properties)) { - if (key in mergedProperties && !schemasEqual(mergedProperties[key], propertySchema)) { + if ( + Object.prototype.hasOwnProperty.call(mergedProperties, key) && + !schemasEqual(mergedProperties[key], propertySchema) + ) { fail(); } mergedProperties[key] = propertySchema; @@ -559,7 +571,7 @@ function mergeObjectAllOf(jsonSchema: JSONSchema, path: string[]): boolean { } if (hasExplicitObjectType) merged.type = 'object'; - if (sawProperties) merged.properties = mergedProperties; + if (sawProperties) merged.properties = Object.fromEntries(Object.entries(mergedProperties)); if (sawRequired) merged.required = [...mergedRequired]; if (closedPropertySets.length > 0) merged.additionalProperties = false; diff --git a/tests/lib/transform.test.ts b/tests/lib/transform.test.ts index 5946a3701a..05984a17c7 100644 --- a/tests/lib/transform.test.ts +++ b/tests/lib/transform.test.ts @@ -318,10 +318,7 @@ describe('toStrictJsonSchema', () => { }); }); - test.each([ - ['missing', { $ref: '#/$defs/Missing' }], - ['cyclic', { $ref: '#/$defs/Cyclic' }], - ])('conservatively rejects %s refs when checking nullable optional properties', (_name, property) => { + test('conservatively rejects cyclic refs when checking nullable optional properties', () => { const schema: JSONSchema = { type: 'object', $defs: { @@ -329,7 +326,7 @@ describe('toStrictJsonSchema', () => { Cyclic: { $ref: '#/$defs/Cyclic' }, }, properties: { - nickname: property as JSONSchema, + nickname: { $ref: '#/$defs/Cyclic' }, }, }; @@ -338,6 +335,20 @@ describe('toStrictJsonSchema', () => { ); }); + test('rejects unresolved local refs before returning a strict schema', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + nickname: { $ref: '#/$defs/Missing' }, + }, + required: ['nickname'], + }; + + expect(() => toStrictJsonSchema(schema)).toThrow( + 'Local $ref at `properties/nickname` does not resolve to an object or boolean schema', + ); + }); + test('rejects external refs before returning a strict schema', () => { const schema: JSONSchema = { type: 'object', @@ -795,7 +806,7 @@ describe('toStrictJsonSchema', () => { }); }); - test('does not flatten a single allOf variant across sibling constraints', () => { + test('rejects a single allOf variant with sibling constraints', () => { const schema: JSONSchema = { type: 'object', properties: { @@ -807,17 +818,7 @@ describe('toStrictJsonSchema', () => { required: ['value'], }; - expect(toStrictJsonSchema(schema)).toEqual({ - type: 'object', - properties: { - value: { - type: 'string', - allOf: [{ type: 'number' }], - }, - }, - required: ['value'], - additionalProperties: false, - }); + expect(() => toStrictJsonSchema(schema)).toThrow('uses unsupported keyword `allOf`'); }); test('merges compatible object allOf variants before closing them', () => { @@ -857,6 +858,47 @@ describe('toStrictJsonSchema', () => { }); }); + test('merges object allOf properties that match Object prototype names', () => { + const specialProperties = Object.fromEntries([ + ['constructor', { type: 'string' }], + ['toString', { type: 'string' }], + ['__proto__', { type: 'string' }], + ]); + const schema: JSONSchema = { + type: 'object', + properties: { + value: { + allOf: [ + { + type: 'object', + properties: specialProperties, + required: ['constructor', 'toString', '__proto__'], + }, + { + type: 'object', + properties: { other: { type: 'number' } }, + required: ['other'], + }, + ], + }, + }, + required: ['value'], + }; + + const strict = toStrictJsonSchema(schema); + const properties = (strict.properties?.['value'] as JSONSchema).properties; + + expect(properties).toEqual( + Object.fromEntries([ + ['constructor', { type: 'string' }], + ['toString', { type: 'string' }], + ['__proto__', { type: 'string' }], + ['other', { type: 'number' }], + ]), + ); + expect(Object.prototype.hasOwnProperty.call(properties, '__proto__')).toBe(true); + }); + test('rejects object allOf variants that cannot be merged exactly', () => { const schema: JSONSchema = { type: 'object', From a80c7c0b31e8d5eb56bcf0931453c56006b48538 Mon Sep 17 00:00:00 2001 From: Hayden Date: Tue, 21 Jul 2026 01:26:26 +0000 Subject: [PATCH 12/35] fix(helpers): preserve draft 7 ref integrity --- src/helpers/standard-schema.ts | 26 ++-- src/lib/transform.ts | 180 ++++++++++++++++++++++++-- tests/helpers/standard-schema.test.ts | 147 +++++++++++++++++++++ tests/lib/transform.test.ts | 102 +++++++++++++++ 4 files changed, 435 insertions(+), 20 deletions(-) diff --git a/src/helpers/standard-schema.ts b/src/helpers/standard-schema.ts index 1cc9d7406a..372fe68f0c 100644 --- a/src/helpers/standard-schema.ts +++ b/src/helpers/standard-schema.ts @@ -9,7 +9,13 @@ import { } from '../lib/parser'; import { AutoParseableResponseTool, makeParseableResponseTool } from '../lib/ResponsesParser'; import { type JSONSchema } from '../lib/jsonschema'; -import { forEachJSONSchemaChild, toStrictJsonSchema } from '../lib/transform'; +import { + assertNoNestedSchemaIds, + forEachJSONSchemaChild, + hasOnlyRefAndAnnotations, + resolveLocalRef, + toStrictJsonSchema, +} from '../lib/transform'; import { ResponseFormatJSONSchema } from '../resources/index'; import { type ResponseFormatTextJSONSchemaConfig } from '../resources/responses/responses'; @@ -246,21 +252,16 @@ function resolveLocalRefForExclusivity( const ref = record['$ref']; if (ref === undefined) return schema; - // Keep the proof conservative when a ref has sibling constraints. Resolving - // those correctly would require combining the referenced schema and siblings. - if (typeof ref !== 'string' || Object.keys(record).length !== 1 || !ref.startsWith('#/')) { + // Annotation keywords do not affect Draft 7 validation, so they are safe to + // retain while proving the referenced branches are mutually exclusive. + // Keep the proof conservative for every other sibling constraint. + if (typeof ref !== 'string' || !hasOnlyRefAndAnnotations(record as JSONSchema)) { return undefined; } if (seenRefs.has(ref)) return undefined; - let resolved: unknown = root; - for (const encodedPart of ref.slice(2).split('/')) { - const part = encodedPart.replace(/~1/g, '/').replace(/~0/g, '~'); - if (!resolved || typeof resolved !== 'object' || Array.isArray(resolved) || !(part in resolved)) { - return undefined; - } - resolved = (resolved as Record)[part]; - } + const resolved = resolveLocalRef(root, ref); + if (resolved === undefined) return undefined; return resolveLocalRefForExclusivity(resolved, root, new Set([...seenRefs, ref])); } @@ -280,6 +281,7 @@ function areOneOfBranchesMutuallyExclusive(branches: unknown[], root: JSONSchema } function normalizeStructuredOutputSchema(schema: JSONSchema): JSONSchema { + assertNoNestedSchemaIds(schema); const normalizedSchema = structuredClone(schema); const visitSchema = (value: unknown): void => { diff --git a/src/lib/transform.ts b/src/lib/transform.ts index 36959e4295..a37d8b60f5 100644 --- a/src/lib/transform.ts +++ b/src/lib/transform.ts @@ -121,8 +121,13 @@ export function toStrictJsonSchema(schema: JSONSchema): JSONSchema { } const schemaCopy = structuredClone(schema); + assertNoNestedSchemaIds(schemaCopy); validateRefSchemas(schemaCopy, [], schemaCopy); - return ensureStrictJsonSchema(schemaCopy, [], schemaCopy); + preserveAllOfRefTargets(schemaCopy); + validateRefSchemas(schemaCopy, [], schemaCopy); + const strictSchema = ensureStrictJsonSchema(schemaCopy, [], schemaCopy); + validateRefSchemas(strictSchema, [], strictSchema); + return strictSchema; } function isNullable( @@ -335,21 +340,65 @@ function ensureStrictJsonSchema( return jsonSchema; } -function resolveLocalRef(root: JSONSchema, ref: string): JSONSchemaDefinition | undefined { +function parseLocalRef(ref: string): string[] | undefined { if (ref === '#') { - return root; + return []; } if (!ref.startsWith('#/')) { return undefined; } - let resolved: unknown = root; + const parts: string[] = []; for (const encodedPart of ref.slice(2).split('/')) { - const part = encodedPart.replace(/~1/g, '/').replace(/~0/g, '~'); - if (!isObject(resolved) || !(part in resolved)) { + let decodedPart: string; + try { + decodedPart = decodeURIComponent(encodedPart); + } catch { + return undefined; + } + + // JSON Pointer only defines ~0 and ~1 escapes. Reject malformed escape + // sequences instead of looking up a different literal key. + if (/~(?:[^01]|$)/.test(decodedPart)) { + return undefined; + } + parts.push(decodedPart.replace(/~1/g, '/').replace(/~0/g, '~')); + } + + return parts; +} + +function resolvePointerPart(resolved: unknown, part: string): unknown | undefined { + if (Array.isArray(resolved)) { + if (!/^(?:0|[1-9]\d*)$/.test(part)) { + return undefined; + } + + const index = Number(part); + if (!Object.prototype.hasOwnProperty.call(resolved, index)) { + return undefined; + } + return resolved[index]; + } + + if (!isObject(resolved) || !Object.prototype.hasOwnProperty.call(resolved, part)) { + return undefined; + } + return resolved[part]; +} + +export function resolveLocalRef(root: JSONSchema, ref: string): JSONSchemaDefinition | undefined { + const parts = parseLocalRef(ref); + if (parts === undefined) { + return undefined; + } + + let resolved: unknown = root; + for (const part of parts) { + resolved = resolvePointerPart(resolved, part); + if (resolved === undefined) { return undefined; } - resolved = resolved[part]; } return resolved as JSONSchemaDefinition; @@ -363,7 +412,7 @@ function isSchemaDefinition(value: unknown): value is JSONSchemaDefinition { return typeof value === 'boolean' || isObject(value); } -function hasOnlyRefAndAnnotations(schema: JSONSchema): boolean { +export function hasOnlyRefAndAnnotations(schema: JSONSchema): boolean { return Object.keys(schema).every( (keyword) => keyword === '$ref' || JSON_SCHEMA_ANNOTATION_KEYWORDS.has(keyword), ); @@ -388,6 +437,121 @@ function hasObjectShape(schema: JSONSchema): boolean { ); } +export function assertNoNestedSchemaIds(schema: JSONSchema): void { + const visit = (value: JSONSchemaDefinition, path: string[]): void => { + if (typeof value === 'boolean' || !isObject(value)) { + return; + } + + if (path.length > 0 && '$id' in value) { + throw new Error( + 'Nested $id at ' + + JSON.stringify(path.join('/')) + + ' establishes a separate JSON Schema resource scope and cannot be represented in strict Structured Outputs.', + ); + } + + forEachJSONSchemaChild(value, path, (child, childPath) => { + visit(child as JSONSchemaDefinition, childPath); + }); + }; + + visit(schema, []); +} + +function refTargetsAllOfBranch(root: JSONSchema, ref: string): boolean { + const parts = parseLocalRef(ref); + if (parts === undefined) { + return false; + } + + let resolved: unknown = root; + for (const [index, part] of parts.entries()) { + if ( + part === 'allOf' && + isObject(resolved) && + Array.isArray(resolved['allOf']) && + index < parts.length - 1 + ) { + return true; + } + + resolved = resolvePointerPart(resolved, part); + if (resolved === undefined) { + return false; + } + } + + return false; +} + +function escapeJSONPointerToken(token: string): string { + return token.replace(/~/g, '~0').replace(/\//g, '~1'); +} + +/** + * Strictification removes every representable allOf. Preserve any schema + * referenced through an allOf branch under a stable root definition first so + * structural flattening cannot leave a dangling local pointer behind. + */ +function preserveAllOfRefTargets(root: JSONSchema): void { + const refsToPreserve = new Set(); + const collectRefs = (value: JSONSchemaDefinition): void => { + if (typeof value === 'boolean' || !isObject(value)) { + return; + } + + if (typeof value.$ref === 'string' && refTargetsAllOfBranch(root, value.$ref)) { + refsToPreserve.add(value.$ref); + } + + forEachJSONSchemaChild(value, [], (child) => { + collectRefs(child as JSONSchemaDefinition); + }); + }; + collectRefs(root); + + if (refsToPreserve.size === 0) { + return; + } + + if (root.$defs !== undefined && !isObject(root.$defs)) { + throw new Error('Root schema has invalid $defs and cannot preserve local allOf references.'); + } + const definitions = (root.$defs ??= {}); + const rewrittenRefs = new Map(); + let aliasIndex = 0; + + for (const ref of refsToPreserve) { + const target = resolveLocalRef(root, ref); + if (!isSchemaDefinition(target)) { + throw new Error('Local $ref cannot be preserved before allOf flattening: ' + JSON.stringify(ref)); + } + + let alias = '__openai_strict_allOf_ref_' + aliasIndex++; + while (Object.prototype.hasOwnProperty.call(definitions, alias)) { + alias = '__openai_strict_allOf_ref_' + aliasIndex++; + } + definitions[alias] = structuredClone(target); + rewrittenRefs.set(ref, '#/$defs/' + escapeJSONPointerToken(alias)); + } + + const rewriteRefs = (value: JSONSchemaDefinition): void => { + if (typeof value === 'boolean' || !isObject(value)) { + return; + } + + if (typeof value.$ref === 'string') { + value.$ref = rewrittenRefs.get(value.$ref) ?? value.$ref; + } + + forEachJSONSchemaChild(value, [], (child) => { + rewriteRefs(child as JSONSchemaDefinition); + }); + }; + rewriteRefs(root); +} + function validateRefSchemas(schema: JSONSchemaDefinition, path: string[], root: JSONSchema): void { if (typeof schema === 'boolean' || !isObject(schema)) { return; diff --git a/tests/helpers/standard-schema.test.ts b/tests/helpers/standard-schema.test.ts index 0b9824ad53..a5aaf3be85 100644 --- a/tests/helpers/standard-schema.test.ts +++ b/tests/helpers/standard-schema.test.ts @@ -289,6 +289,153 @@ describe('Standard Schema helpers', () => { }); }); + it('normalizes annotated oneOf ref branches but rejects validation siblings', () => { + const definitions = { + foo: { + type: 'object', + properties: { kind: { type: 'string', const: 'foo' }, foo: { type: 'string' } }, + required: ['kind', 'foo'], + }, + bar: { + type: 'object', + properties: { kind: { type: 'string', const: 'bar' }, bar: { type: 'number' } }, + required: ['kind', 'bar'], + }, + }; + const { standardSchema } = makeStandardSchema({ + type: 'object', + $defs: definitions, + properties: { + choice: { + oneOf: [ + { $ref: '#/$defs/foo', description: 'Foo choice' }, + { $ref: '#/$defs/bar', title: 'Bar choice' }, + ], + }, + }, + required: ['choice'], + }); + + expect(standardResponseFormat(standardSchema, 'choice').json_schema.schema).toMatchObject({ + properties: { + choice: { + anyOf: [ + { $ref: '#/$defs/foo', description: 'Foo choice' }, + { $ref: '#/$defs/bar', title: 'Bar choice' }, + ], + }, + }, + }); + + const { standardSchema: constrainedRefSchema } = makeStandardSchema({ + type: 'object', + $defs: definitions, + properties: { + choice: { + oneOf: [{ $ref: '#/$defs/foo', minProperties: 1 }, { $ref: '#/$defs/bar' }], + }, + }, + required: ['choice'], + }); + expect(() => standardResponseFormat(constrainedRefSchema, 'choice')).toThrow( + 'whose branches are not provably mutually exclusive', + ); + }); + + it('resolves URI-escaped oneOf refs and rejects malformed encodings', () => { + const definitions = { + 'foo branch': { + type: 'object', + properties: { kind: { type: 'string', const: 'foo' }, foo: { type: 'string' } }, + required: ['kind', 'foo'], + }, + 'bar branch': { + type: 'object', + properties: { kind: { type: 'string', const: 'bar' }, bar: { type: 'number' } }, + required: ['kind', 'bar'], + }, + }; + const { standardSchema } = makeStandardSchema({ + type: 'object', + $defs: definitions, + properties: { + choice: { + oneOf: [{ $ref: '#/$defs/foo%20branch' }, { $ref: '#/$defs/bar%20branch' }], + }, + }, + required: ['choice'], + }); + + expect(standardResponseFormat(standardSchema, 'choice').json_schema.schema).toMatchObject({ + properties: { + choice: { + anyOf: [{ $ref: '#/$defs/foo%20branch' }, { $ref: '#/$defs/bar%20branch' }], + }, + }, + }); + + const { standardSchema: malformedRefSchema } = makeStandardSchema({ + type: 'object', + $defs: definitions, + properties: { + choice: { + oneOf: [{ $ref: '#/$defs/foo%2' }, { $ref: '#/$defs/bar%20branch' }], + }, + }, + required: ['choice'], + }); + expect(() => standardResponseFormat(malformedRefSchema, 'choice')).toThrow( + 'whose branches are not provably mutually exclusive', + ); + }); + + it('allows root $id scopes but rejects nested $id before oneOf normalization', () => { + const rootScopedSchema = { + $id: 'https://example.com/root.json', + type: 'object', + properties: { value: { type: 'string' } }, + required: ['value'], + }; + const { standardSchema: rootScopedStandardSchema } = makeStandardSchema(rootScopedSchema); + expect(standardResponseFormat(rootScopedStandardSchema, 'value').json_schema.schema).toMatchObject({ + $id: 'https://example.com/root.json', + }); + + const { standardSchema: nestedScopedStandardSchema } = makeStandardSchema({ + type: 'object', + $defs: { + outer: { + type: 'object', + properties: { kind: { type: 'string', const: 'outer' } }, + required: ['kind'], + }, + }, + properties: { + nested: { + $id: 'nested.json', + type: 'object', + $defs: { + outer: { + type: 'object', + properties: { kind: { type: 'string', const: 'nested' } }, + required: ['kind'], + }, + }, + properties: { + choice: { + oneOf: [{ $ref: '#/$defs/outer' }, { type: 'string' }], + }, + }, + required: ['choice'], + }, + }, + required: ['nested'], + }); + expect(() => standardResponseFormat(nestedScopedStandardSchema, 'nested')).toThrow( + 'establishes a separate JSON Schema resource scope', + ); + }); + it('rejects oneOf refs that cannot be resolved locally', () => { for (const branches of [ [{ $ref: '#/$defs/missing' }, { type: 'string' }], diff --git a/tests/lib/transform.test.ts b/tests/lib/transform.test.ts index 05984a17c7..851b3ddb98 100644 --- a/tests/lib/transform.test.ts +++ b/tests/lib/transform.test.ts @@ -806,6 +806,47 @@ describe('toStrictJsonSchema', () => { }); }); + test('preserves refs into allOf branches before flattening', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + value: { + allOf: [{ type: 'string' }], + }, + alias: { $ref: '#/properties/value/allOf/0' }, + }, + required: ['value', 'alias'], + }; + + const strict = toStrictJsonSchema(schema); + + expect(strict).toMatchObject({ + $defs: { + __openai_strict_allOf_ref_0: { type: 'string' }, + }, + properties: { + value: { type: 'string' }, + alias: { $ref: '#/$defs/__openai_strict_allOf_ref_0' }, + }, + }); + expect(JSON.stringify(strict)).not.toContain('"allOf"'); + }); + + test('rejects refs to missing allOf branches', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + value: { + allOf: [{ type: 'string' }], + }, + alias: { $ref: '#/properties/value/allOf/1' }, + }, + required: ['value', 'alias'], + }; + + expect(() => toStrictJsonSchema(schema)).toThrow('does not resolve to an object or boolean schema'); + }); + test('rejects a single allOf variant with sibling constraints', () => { const schema: JSONSchema = { type: 'object', @@ -928,6 +969,67 @@ describe('toStrictJsonSchema', () => { }); describe('$ref Resolution', () => { + test('resolves percent-encoded JSON Pointer tokens before pointer unescaping', () => { + const schema: JSONSchema = { + type: 'object', + $defs: { + 'A B': { type: 'string' }, + 'A/B': { type: 'number' }, + }, + properties: { + spaced: { $ref: '#/$defs/A%20B' }, + slash: { $ref: '#/$defs/A%7E1B' }, + }, + required: ['spaced', 'slash'], + }; + + expect(toStrictJsonSchema(schema)).toMatchObject({ + properties: { + spaced: { $ref: '#/$defs/A%20B' }, + slash: { $ref: '#/$defs/A%7E1B' }, + }, + }); + }); + + test('rejects malformed URI and JSON Pointer escapes in local refs', () => { + for (const ref of ['#/$defs/A%2', '#/$defs/A%ZZ', '#/$defs/A~2B']) { + const schema: JSONSchema = { + type: 'object', + $defs: { 'A B': { type: 'string' } }, + properties: { value: { $ref: ref } }, + required: ['value'], + }; + + expect(() => toStrictJsonSchema(schema)).toThrow('does not resolve to an object or boolean schema'); + } + }); + + test('allows a root $id but rejects nested resource scopes', () => { + const rootIdSchema: JSONSchema = { + $id: 'https://example.com/root.json', + type: 'object', + properties: { value: { type: 'string' } }, + required: ['value'], + }; + expect(toStrictJsonSchema(rootIdSchema)).toMatchObject({ + $id: 'https://example.com/root.json', + }); + + const nestedIdSchema: JSONSchema = { + type: 'object', + properties: { + value: { + $id: 'nested.json', + type: 'string', + }, + }, + required: ['value'], + }; + expect(() => toStrictJsonSchema(nestedIdSchema)).toThrow( + 'establishes a separate JSON Schema resource scope', + ); + }); + test('retains annotation-only recursive refs without expanding them', () => { const schema: JSONSchema = { type: 'object', From ae491121b580f679c8b3c0439329b04574586e30 Mon Sep 17 00:00:00 2001 From: Hayden Date: Tue, 21 Jul 2026 02:12:26 +0000 Subject: [PATCH 13/35] fix(helpers): preserve strict union semantics --- src/helpers/standard-schema.ts | 18 +++- src/lib/transform.ts | 119 ++++++++++++++++++++++++++ tests/helpers/standard-schema.test.ts | 100 ++++++++++++++++++++-- tests/lib/transform.test.ts | 86 ++++++++++++++++--- 4 files changed, 298 insertions(+), 25 deletions(-) diff --git a/src/helpers/standard-schema.ts b/src/helpers/standard-schema.ts index 372fe68f0c..17be5c6d51 100644 --- a/src/helpers/standard-schema.ts +++ b/src/helpers/standard-schema.ts @@ -14,6 +14,7 @@ import { forEachJSONSchemaChild, hasOnlyRefAndAnnotations, resolveLocalRef, + rewriteLocalRefsIntoMovedOneOfBranches, toStrictJsonSchema, } from '../lib/transform'; import { ResponseFormatJSONSchema } from '../resources/index'; @@ -283,11 +284,17 @@ function areOneOfBranchesMutuallyExclusive(branches: unknown[], root: JSONSchema function normalizeStructuredOutputSchema(schema: JSONSchema): JSONSchema { assertNoNestedSchemaIds(schema); const normalizedSchema = structuredClone(schema); + const oneOfSchemas: Record[] = []; const visitSchema = (value: unknown): void => { if (!value || typeof value !== 'object' || Array.isArray(value)) return; const record = value as Record; - if (Array.isArray(record['oneOf'])) { + if (record['oneOf'] !== undefined) { + if (!Array.isArray(record['oneOf'])) { + throw new OpenAIError( + 'Standard JSON Schema generated an invalid `oneOf`, which cannot be represented in an OpenAI strict schema', + ); + } if (record['anyOf'] !== undefined) { throw new OpenAIError( 'Standard JSON Schema generated both `anyOf` and `oneOf`, which cannot be represented in an OpenAI strict schema', @@ -298,15 +305,18 @@ function normalizeStructuredOutputSchema(schema: JSONSchema): JSONSchema { 'Standard JSON Schema generated a `oneOf` whose branches are not provably mutually exclusive. OpenAI strict schemas do not support `oneOf`; use `anyOf` or add a discriminator with distinct literal values.', ); } - - record['anyOf'] = record['oneOf']; - delete record['oneOf']; + oneOfSchemas.push(record); } forEachJSONSchemaChild(record, [], (child) => visitSchema(child)); }; visitSchema(normalizedSchema); + rewriteLocalRefsIntoMovedOneOfBranches(normalizedSchema); + for (const record of oneOfSchemas) { + record['anyOf'] = record['oneOf']; + delete record['oneOf']; + } return normalizedSchema; } diff --git a/src/lib/transform.ts b/src/lib/transform.ts index a37d8b60f5..42e619c9f8 100644 --- a/src/lib/transform.ts +++ b/src/lib/transform.ts @@ -54,6 +54,7 @@ const JSON_SCHEMA_UNSUPPORTED_SCHEMA_KEYWORDS = new Set([ 'else', 'if', 'not', + 'patternProperties', 'prefixItems', 'propertyNames', 'then', @@ -119,6 +120,11 @@ export function toStrictJsonSchema(schema: JSONSchema): JSONSchema { `Root schema must have type: 'object' but got type: ${schema.type ? `'${schema.type}'` : 'undefined'}`, ); } + if (schema.anyOf !== undefined) { + throw new Error( + 'Root schema must not use `anyOf` because strict Structured Outputs requires a root object without a union.', + ); + } const schemaCopy = structuredClone(schema); assertNoNestedSchemaIds(schemaCopy); @@ -234,6 +240,13 @@ function ensureStrictJsonSchema( return ensureStrictJsonSchema(jsonSchema, path, root); } + // Closing a type: object wrapper around object union branches without any + // own properties would turn it into an empty object and forbid every branch + // property. A bare object type is redundant when every branch already proves + // the value is an object, so remove only that exact constraint; fail closed + // for wrappers whose object constraints cannot be preserved mechanically. + normalizeObjectUnionWrapper(jsonSchema, path, root); + // Add additionalProperties: false to object schemas. Draft 7 permits object // keywords without an explicit type, so those implicit object shapes need // the same strict handling as type: 'object'. Explicitly open object schemas @@ -412,6 +425,34 @@ function isSchemaDefinition(value: unknown): value is JSONSchemaDefinition { return typeof value === 'boolean' || isObject(value); } +function isObjectOnlySchema( + schema: JSONSchemaDefinition, + root: JSONSchema, + seenRefs: Set = new Set(), +): boolean { + if (typeof schema === 'boolean' || !isObject(schema)) { + return false; + } + + if (schema.$ref !== undefined) { + if (typeof schema.$ref !== 'string' || !hasOnlyRefAndAnnotations(schema) || seenRefs.has(schema.$ref)) { + return false; + } + + const resolved = resolveLocalRef(root, schema.$ref); + if (resolved === undefined) { + return false; + } + + return isObjectOnlySchema(resolved, root, new Set([...seenRefs, schema.$ref])); + } + + return ( + schema.type === 'object' || + (Array.isArray(schema.type) && schema.type.length === 1 && schema.type[0] === 'object') + ); +} + export function hasOnlyRefAndAnnotations(schema: JSONSchema): boolean { return Object.keys(schema).every( (keyword) => keyword === '$ref' || JSON_SCHEMA_ANNOTATION_KEYWORDS.has(keyword), @@ -437,6 +478,31 @@ function hasObjectShape(schema: JSONSchema): boolean { ); } +function normalizeObjectUnionWrapper(jsonSchema: JSONSchema, path: string[], root: JSONSchema): void { + if (jsonSchema.anyOf === undefined || !hasObjectShape(jsonSchema)) { + return; + } + + const hasOwnObjectKeywords = Object.keys(jsonSchema).some((keyword) => + JSON_SCHEMA_OBJECT_KEYWORDS.has(keyword), + ); + if ( + jsonSchema.type === 'object' && + !hasOwnObjectKeywords && + Array.isArray(jsonSchema.anyOf) && + jsonSchema.anyOf.every((branch) => isObjectOnlySchema(branch, root)) + ) { + delete jsonSchema.type; + return; + } + + throw new Error( + 'Object anyOf schema at `' + + (path.join('/') || '') + + '` cannot be represented in strict Structured Outputs without changing Draft 7 validation.', + ); +} + export function assertNoNestedSchemaIds(schema: JSONSchema): void { const visit = (value: JSONSchemaDefinition, path: string[]): void => { if (typeof value === 'boolean' || !isObject(value)) { @@ -489,6 +555,59 @@ function escapeJSONPointerToken(token: string): string { return token.replace(/~/g, '~0').replace(/\//g, '~1'); } +/** + * Standard Schema normalization moves representable oneOf branches to anyOf. + * Rewrite only pointers that traverse an actual oneOf schema array while the + * original tree is still intact, preserving escaped tokens for every other + * path segment. + */ +export function rewriteLocalRefsIntoMovedOneOfBranches(root: JSONSchema): void { + const rewriteRef = (ref: string): string => { + const parts = parseLocalRef(ref); + if (parts === undefined || parts.length === 0) { + return ref; + } + + const encodedParts = ref.slice(2).split('/'); + let resolved: unknown = root; + let changed = false; + for (const [index, part] of parts.entries()) { + if ( + part === 'oneOf' && + index < parts.length - 1 && + isObject(resolved) && + Array.isArray(resolved['oneOf']) + ) { + encodedParts[index] = 'anyOf'; + changed = true; + } + + resolved = resolvePointerPart(resolved, part); + if (resolved === undefined) { + return ref; + } + } + + return changed ? '#/' + encodedParts.join('/') : ref; + }; + + const rewriteRefs = (value: JSONSchemaDefinition): void => { + if (typeof value === 'boolean' || !isObject(value)) { + return; + } + + if (typeof value.$ref === 'string') { + value.$ref = rewriteRef(value.$ref); + } + + forEachJSONSchemaChild(value, [], (child) => { + rewriteRefs(child as JSONSchemaDefinition); + }); + }; + + rewriteRefs(root); +} + /** * Strictification removes every representable allOf. Preserve any schema * referenced through an allOf branch under a stable root definition first so diff --git a/tests/helpers/standard-schema.test.ts b/tests/helpers/standard-schema.test.ts index a5aaf3be85..6bc61272fa 100644 --- a/tests/helpers/standard-schema.test.ts +++ b/tests/helpers/standard-schema.test.ts @@ -219,6 +219,78 @@ describe('Standard Schema helpers', () => { expect(oneOfSchema.properties.choice).toHaveProperty('oneOf'); }); + it('does not close redundant object oneOf wrappers as empty objects', () => { + const { standardSchema } = makeStandardSchema({ + type: 'object', + properties: { + choice: { + type: 'object', + oneOf: [ + { + type: 'object', + properties: { kind: { type: 'string', const: 'foo' }, foo: { type: 'string' } }, + required: ['kind', 'foo'], + }, + { + type: 'object', + properties: { kind: { type: 'string', const: 'bar' }, bar: { type: 'number' } }, + required: ['kind', 'bar'], + }, + ], + }, + }, + required: ['choice'], + }); + + const schema = standardResponseFormat(standardSchema, 'choice').json_schema.schema; + expect(schema).toMatchObject({ + properties: { + choice: { + anyOf: [ + { + type: 'object', + properties: { kind: { type: 'string', const: 'foo' }, foo: { type: 'string' } }, + required: ['kind', 'foo'], + additionalProperties: false, + }, + { + type: 'object', + properties: { kind: { type: 'string', const: 'bar' }, bar: { type: 'number' } }, + required: ['kind', 'bar'], + additionalProperties: false, + }, + ], + }, + }, + }); + const properties = (schema as Record)['properties'] as Record; + const choice = properties['choice'] as Record; + expect(choice).not.toHaveProperty('type'); + expect(choice).not.toHaveProperty('additionalProperties'); + }); + + it('updates refs into oneOf branches after moving them to anyOf', () => { + const { standardSchema } = makeStandardSchema({ + type: 'object', + properties: { + choice: { + oneOf: [{ type: 'string', const: 'foo' }, { type: 'number' }], + }, + alias: { $ref: '#/properties/choice/oneOf/0' }, + }, + required: ['choice', 'alias'], + }); + + expect(standardResponseFormat(standardSchema, 'choice').json_schema.schema).toMatchObject({ + properties: { + choice: { + anyOf: [{ type: 'string', const: 'foo' }, { type: 'number' }], + }, + alias: { $ref: '#/properties/choice/anyOf/0' }, + }, + }); + }); + it('rejects oneOf branches that may overlap', () => { const overlappingOneOfSchema = { type: 'object', @@ -500,7 +572,7 @@ describe('Standard Schema helpers', () => { }); }); - it('strictifies object schemas nested under patternProperties', () => { + it('rejects patternProperties before returning a strict schema', () => { const { standardSchema } = makeStandardSchema({ type: 'object', properties: { @@ -519,17 +591,27 @@ describe('Standard Schema helpers', () => { required: ['metadata'], }); - expect(standardResponseFormat(standardSchema, 'metadata').json_schema.schema).toMatchObject({ + expect(() => standardResponseFormat(standardSchema, 'metadata')).toThrow( + 'uses unsupported keyword `patternProperties`', + ); + }); + + it('rejects root anyOf schemas', () => { + const { standardSchema } = makeStandardSchema({ + type: 'object', properties: { - metadata: { - patternProperties: { - '^x-': { - additionalProperties: false, - }, - }, - }, + kind: { type: 'string' }, }, + required: ['kind'], + anyOf: [ + { type: 'object', properties: { kind: { type: 'string', const: 'foo' } }, required: ['kind'] }, + { type: 'object', properties: { kind: { type: 'string', const: 'bar' } }, required: ['kind'] }, + ], }); + + expect(() => standardResponseFormat(standardSchema, 'choice')).toThrow( + 'Root schema must not use `anyOf`', + ); }); it('rejects unsupported nested schema keywords before returning a strict schema', () => { diff --git a/tests/lib/transform.test.ts b/tests/lib/transform.test.ts index 851b3ddb98..962e8bb65f 100644 --- a/tests/lib/transform.test.ts +++ b/tests/lib/transform.test.ts @@ -570,7 +570,7 @@ describe('toStrictJsonSchema', () => { }); }); - test('processes patternProperties schemas recursively', () => { + test('rejects patternProperties schemas', () => { const schema: JSONSchema = { type: 'object', properties: { @@ -589,17 +589,7 @@ describe('toStrictJsonSchema', () => { required: ['metadata'], }; - expect(toStrictJsonSchema(schema)).toMatchObject({ - properties: { - metadata: { - patternProperties: { - '^x-': { - additionalProperties: false, - }, - }, - }, - }, - }); + expect(() => toStrictJsonSchema(schema)).toThrow('uses unsupported keyword `patternProperties`'); }); test.each([ @@ -727,6 +717,78 @@ describe('toStrictJsonSchema', () => { } `); }); + + test('removes a redundant object type from an anyOf wrapper before closing branches', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + value: { + type: 'object', + anyOf: [ + { + type: 'object', + properties: { kind: { type: 'string', const: 'foo' }, foo: { type: 'string' } }, + required: ['kind', 'foo'], + }, + { + type: 'object', + properties: { kind: { type: 'string', const: 'bar' }, bar: { type: 'number' } }, + required: ['kind', 'bar'], + }, + ], + }, + }, + required: ['value'], + }; + + const strict = toStrictJsonSchema(schema); + expect(strict.properties?.['value']).toEqual({ + anyOf: [ + { + type: 'object', + properties: { kind: { type: 'string', const: 'foo' }, foo: { type: 'string' } }, + required: ['kind', 'foo'], + additionalProperties: false, + }, + { + type: 'object', + properties: { kind: { type: 'string', const: 'bar' }, bar: { type: 'number' } }, + required: ['kind', 'bar'], + additionalProperties: false, + }, + ], + }); + }); + + test('rejects object anyOf wrappers whose own constraints cannot be preserved', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + value: { + type: 'object', + properties: { kind: { type: 'string' } }, + required: ['kind'], + anyOf: [ + { + type: 'object', + properties: { kind: { type: 'string', const: 'foo' }, foo: { type: 'string' } }, + required: ['kind', 'foo'], + }, + { + type: 'object', + properties: { kind: { type: 'string', const: 'bar' }, bar: { type: 'number' } }, + required: ['kind', 'bar'], + }, + ], + }, + }, + required: ['value'], + }; + + expect(() => toStrictJsonSchema(schema)).toThrow( + 'Object anyOf schema at `properties/value` cannot be represented', + ); + }); }); describe('allOf Handling', () => { From 1eaceaaef94997aabe1b474d43d0f8fddaab24fa Mon Sep 17 00:00:00 2001 From: Hayden Date: Tue, 21 Jul 2026 02:25:45 +0000 Subject: [PATCH 14/35] fix(helpers): allow comments on strict refs --- src/lib/transform.ts | 1 + tests/helpers/standard-schema.test.ts | 4 ++-- tests/lib/transform.test.ts | 4 +++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/lib/transform.ts b/src/lib/transform.ts index 42e619c9f8..82f3ffcc79 100644 --- a/src/lib/transform.ts +++ b/src/lib/transform.ts @@ -1,6 +1,7 @@ import type { JSONSchema, JSONSchemaDefinition } from './jsonschema'; const JSON_SCHEMA_ANNOTATION_KEYWORDS = new Set([ + '$comment', 'default', 'description', 'examples', diff --git a/tests/helpers/standard-schema.test.ts b/tests/helpers/standard-schema.test.ts index 6bc61272fa..266b3e1612 100644 --- a/tests/helpers/standard-schema.test.ts +++ b/tests/helpers/standard-schema.test.ts @@ -380,7 +380,7 @@ describe('Standard Schema helpers', () => { properties: { choice: { oneOf: [ - { $ref: '#/$defs/foo', description: 'Foo choice' }, + { $ref: '#/$defs/foo', $comment: 'Generated foo alias', description: 'Foo choice' }, { $ref: '#/$defs/bar', title: 'Bar choice' }, ], }, @@ -392,7 +392,7 @@ describe('Standard Schema helpers', () => { properties: { choice: { anyOf: [ - { $ref: '#/$defs/foo', description: 'Foo choice' }, + { $ref: '#/$defs/foo', $comment: 'Generated foo alias', description: 'Foo choice' }, { $ref: '#/$defs/bar', title: 'Bar choice' }, ], }, diff --git a/tests/lib/transform.test.ts b/tests/lib/transform.test.ts index 962e8bb65f..718c88c5e8 100644 --- a/tests/lib/transform.test.ts +++ b/tests/lib/transform.test.ts @@ -290,7 +290,7 @@ describe('toStrictJsonSchema', () => { expect(toStrictJsonSchema(schema).required).toEqual(['nickname']); }); - test('resolves local refs with annotation-only siblings when checking nullable optional properties', () => { + test('resolves local refs with $comment and other annotation-only siblings', () => { const schema: JSONSchema = { type: 'object', $defs: { @@ -299,6 +299,7 @@ describe('toStrictJsonSchema', () => { properties: { nickname: { $ref: '#/$defs/NullableString', + $comment: 'Generated alias', title: 'Nickname', description: 'A preferred name', default: null, @@ -312,6 +313,7 @@ describe('toStrictJsonSchema', () => { expect(strict.required).toEqual(['nickname']); expect(strict.properties?.['nickname']).toEqual({ $ref: '#/$defs/NullableString', + $comment: 'Generated alias', title: 'Nickname', description: 'A preferred name', examples: [null], From 0a9735fe8dfbba532b40414514a72e50cba5ef13 Mon Sep 17 00:00:00 2001 From: Hayden Date: Tue, 21 Jul 2026 02:56:05 +0000 Subject: [PATCH 15/35] fix(helpers): fail closed on unsupported local refs --- src/lib/transform.ts | 88 +++++++++++++++++++++------- tests/lib/transform.test.ts | 114 ++++++++++++++++++++++++++++++++++++ 2 files changed, 182 insertions(+), 20 deletions(-) diff --git a/src/lib/transform.ts b/src/lib/transform.ts index 82f3ffcc79..8e2645ca40 100644 --- a/src/lib/transform.ts +++ b/src/lib/transform.ts @@ -61,6 +61,7 @@ const JSON_SCHEMA_UNSUPPORTED_SCHEMA_KEYWORDS = new Set([ 'then', 'unevaluatedItems', 'unevaluatedProperties', + 'uniqueItems', ]); const MERGEABLE_OBJECT_ALL_OF_KEYWORDS = new Set([ @@ -355,28 +356,35 @@ function ensureStrictJsonSchema( } function parseLocalRef(ref: string): string[] | undefined { - if (ref === '#') { + if (!ref.startsWith('#')) { + return undefined; + } + + let pointer: string; + try { + // A local $ref is a URI fragment containing a JSON Pointer. RFC 6901 + // decodes the complete fragment before tokenizing the pointer, so an + // encoded slash is a separator rather than part of a literal key. + pointer = decodeURIComponent(ref.slice(1)); + } catch { + return undefined; + } + + if (pointer === '') { return []; } - if (!ref.startsWith('#/')) { + if (!pointer.startsWith('/')) { return undefined; } const parts: string[] = []; - for (const encodedPart of ref.slice(2).split('/')) { - let decodedPart: string; - try { - decodedPart = decodeURIComponent(encodedPart); - } catch { - return undefined; - } - + for (const encodedPart of pointer.slice(1).split('/')) { // JSON Pointer only defines ~0 and ~1 escapes. Reject malformed escape // sequences instead of looking up a different literal key. - if (/~(?:[^01]|$)/.test(decodedPart)) { + if (/~(?:[^01]|$)/.test(encodedPart)) { return undefined; } - parts.push(decodedPart.replace(/~1/g, '/').replace(/~0/g, '~')); + parts.push(encodedPart.replace(/~1/g, '/').replace(/~0/g, '~')); } return parts; @@ -407,15 +415,56 @@ export function resolveLocalRef(root: JSONSchema, ref: string): JSONSchemaDefini return undefined; } + // Literal payloads such as `default`, `enum`, and `const` can contain + // object-shaped values, but they are not schemas and are never traversed by + // strictification. Resolve only the schema-bearing locations visited by + // forEachJSONSchemaChild so every accepted target is normalized before we + // advertise the result as strict. let resolved: unknown = root; - for (const part of parts) { - resolved = resolvePointerPart(resolved, part); - if (resolved === undefined) { + for (let index = 0; index < parts.length; ) { + if (!isObject(resolved)) { return undefined; } + + const keyword = parts[index]!; + if (JSON_SCHEMA_SINGLE_SCHEMA_KEYWORDS.includes(keyword)) { + resolved = resolvePointerPart(resolved, keyword); + index += 1; + continue; + } + + if (JSON_SCHEMA_ARRAY_SCHEMA_KEYWORDS.includes(keyword)) { + resolved = resolvePointerPart(resolved, keyword); + index += 1; + if (Array.isArray(resolved)) { + if (index >= parts.length) { + return undefined; + } + resolved = resolvePointerPart(resolved, parts[index]!); + index += 1; + } + continue; + } + + if (JSON_SCHEMA_MAP_SCHEMA_KEYWORDS.includes(keyword)) { + const children = resolvePointerPart(resolved, keyword); + index += 1; + if (!isObject(children) || index >= parts.length) { + return undefined; + } + + resolved = resolvePointerPart(children, parts[index]!); + if (keyword === 'dependencies' && !isSchemaDefinition(resolved)) { + return undefined; + } + index += 1; + continue; + } + + return undefined; } - return resolved as JSONSchemaDefinition; + return isSchemaDefinition(resolved) ? resolved : undefined; } function isObject(obj: T | Array): obj is Extract> { @@ -569,7 +618,6 @@ export function rewriteLocalRefsIntoMovedOneOfBranches(root: JSONSchema): void { return ref; } - const encodedParts = ref.slice(2).split('/'); let resolved: unknown = root; let changed = false; for (const [index, part] of parts.entries()) { @@ -579,7 +627,7 @@ export function rewriteLocalRefsIntoMovedOneOfBranches(root: JSONSchema): void { isObject(resolved) && Array.isArray(resolved['oneOf']) ) { - encodedParts[index] = 'anyOf'; + parts[index] = 'anyOf'; changed = true; } @@ -589,7 +637,7 @@ export function rewriteLocalRefsIntoMovedOneOfBranches(root: JSONSchema): void { } } - return changed ? '#/' + encodedParts.join('/') : ref; + return changed ? '#/' + parts.map(escapeJSONPointerToken).join('/') : ref; }; const rewriteRefs = (value: JSONSchemaDefinition): void => { @@ -682,7 +730,7 @@ function validateRefSchemas(schema: JSONSchemaDefinition, path: string[], root: if (typeof ref !== 'string') { throw new TypeError(`Received non-string $ref - ${ref}; path=${path.join('/')}`); } - if (ref !== '#' && !ref.startsWith('#/')) { + if (!ref.startsWith('#')) { throw new Error( `External $ref at \`${ path.join('/') || '' diff --git a/tests/lib/transform.test.ts b/tests/lib/transform.test.ts index 718c88c5e8..3857e20fff 100644 --- a/tests/lib/transform.test.ts +++ b/tests/lib/transform.test.ts @@ -290,6 +290,104 @@ describe('toStrictJsonSchema', () => { expect(toStrictJsonSchema(schema).required).toEqual(['nickname']); }); + test('decodes complete URI fragments before splitting pointer tokens', () => { + const schema: JSONSchema = { + type: 'object', + $defs: { + 'A/properties/B': { type: 'number' }, + A: { + type: 'object', + properties: { + B: { type: ['string', 'null'] }, + }, + required: ['B'], + }, + }, + properties: { + value: { $ref: '#/$defs/A%2Fproperties%2FB' }, + }, + }; + + const strict = toStrictJsonSchema(schema); + + expect(strict.required).toEqual(['value']); + expect(strict.properties?.['value']).toEqual({ $ref: '#/$defs/A%2Fproperties%2FB' }); + }); + + test('resolves literal slash keys only through JSON Pointer escaping', () => { + const schema: JSONSchema = { + type: 'object', + $defs: { + 'A/properties/B': { type: 'string' }, + A: { + type: 'object', + properties: { + B: { type: 'number' }, + }, + required: ['B'], + }, + }, + properties: { + value: { $ref: '#/$defs/A~1properties~1B' }, + }, + required: ['value'], + }; + + expect(toStrictJsonSchema(schema).properties?.['value']).toEqual({ + $ref: '#/$defs/A~1properties~1B', + }); + }); + + test('rejects malformed URI encodings in local refs', () => { + const schema: JSONSchema = { + type: 'object', + $defs: { + Text: { type: 'string' }, + }, + properties: { + value: { $ref: '#/$defs/Text%2' }, + }, + required: ['value'], + }; + + expect(() => toStrictJsonSchema(schema)).toThrow( + 'Local $ref at `properties/value` does not resolve to an object or boolean schema', + ); + }); + + test('rejects local refs into non-schema literal payloads', () => { + const schema: JSONSchema = { + type: 'object', + default: { type: 'string' }, + properties: { + value: { $ref: '#/default' }, + }, + required: ['value'], + }; + + expect(() => toStrictJsonSchema(schema)).toThrow( + 'Local $ref at `properties/value` does not resolve to an object or boolean schema', + ); + }); + + test('allows local refs into traversed schema array locations', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + source: { + type: 'array', + items: [{ type: 'string' }], + }, + alias: { $ref: '#/properties/source/items/0' }, + }, + required: ['source', 'alias'], + }; + + expect(toStrictJsonSchema(schema).properties?.['alias']).toEqual({ + $ref: '#/properties/source/items/0', + }); + }); + test('resolves local refs with $comment and other annotation-only siblings', () => { const schema: JSONSchema = { type: 'object', @@ -594,6 +692,22 @@ describe('toStrictJsonSchema', () => { expect(() => toStrictJsonSchema(schema)).toThrow('uses unsupported keyword `patternProperties`'); }); + test('rejects uniqueItems schemas', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + tags: { + type: 'array', + items: { type: 'string' }, + uniqueItems: true, + }, + }, + required: ['tags'], + }; + + expect(() => toStrictJsonSchema(schema)).toThrow('uses unsupported keyword `uniqueItems`'); + }); + test.each([ [ 'contains', From 6adaabed00019810c49014fdbf6b482111609409 Mon Sep 17 00:00:00 2001 From: Hayden Date: Tue, 21 Jul 2026 03:26:59 +0000 Subject: [PATCH 16/35] fix(helpers): handle strict schema edge cases --- src/lib/transform.ts | 47 ++++++++++-- tests/helpers/standard-schema.test.ts | 100 ++++++++++++++++++++++++++ tests/lib/transform.test.ts | 67 +++++++++++++++++ 3 files changed, 209 insertions(+), 5 deletions(-) diff --git a/src/lib/transform.ts b/src/lib/transform.ts index 8e2645ca40..78e187c233 100644 --- a/src/lib/transform.ts +++ b/src/lib/transform.ts @@ -54,6 +54,8 @@ const JSON_SCHEMA_UNSUPPORTED_SCHEMA_KEYWORDS = new Set([ 'dependencies', 'else', 'if', + 'maxProperties', + 'minProperties', 'not', 'patternProperties', 'prefixItems', @@ -117,18 +119,22 @@ export function forEachJSONSchemaChild( } export function toStrictJsonSchema(schema: JSONSchema): JSONSchema { - if (schema.type !== 'object') { + const schemaCopy = structuredClone(schema); + normalizeSingletonTypeArrays(schemaCopy); + + if (schemaCopy.type !== 'object') { throw new Error( - `Root schema must have type: 'object' but got type: ${schema.type ? `'${schema.type}'` : 'undefined'}`, + `Root schema must have type: 'object' but got type: ${ + schemaCopy.type ? `'${schemaCopy.type}'` : 'undefined' + }`, ); } - if (schema.anyOf !== undefined) { + if (schemaCopy.anyOf !== undefined) { throw new Error( 'Root schema must not use `anyOf` because strict Structured Outputs requires a root object without a union.', ); } - const schemaCopy = structuredClone(schema); assertNoNestedSchemaIds(schemaCopy); validateRefSchemas(schemaCopy, [], schemaCopy); preserveAllOfRefTargets(schemaCopy); @@ -138,6 +144,26 @@ export function toStrictJsonSchema(schema: JSONSchema): JSONSchema { return strictSchema; } +/** + * Draft 7 permits `type` to be either a string or an array of strings. A + * singleton array has exactly the same validation semantics as its scalar + * form, so canonicalize it before root validation and recursive strictifying. + * Multi-type arrays carry real union semantics and must remain unchanged. + */ +function normalizeSingletonTypeArrays(schema: JSONSchemaDefinition): void { + if (typeof schema === 'boolean' || !isObject(schema)) { + return; + } + + if (Array.isArray(schema.type) && schema.type.length === 1) { + schema.type = schema.type[0]!; + } + + forEachJSONSchemaChild(schema, [], (child) => { + normalizeSingletonTypeArrays(child as JSONSchemaDefinition); + }); +} + function isNullable( schema: JSONSchemaDefinition, root: JSONSchema, @@ -605,6 +631,14 @@ function escapeJSONPointerToken(token: string): string { return token.replace(/~/g, '~0').replace(/\//g, '~1'); } +function encodeJSONPointerTokenForURIFragment(token: string): string { + // `$` is a valid URI fragment sub-delimiter and keeping it readable retains + // the conventional `#/$defs/...` spelling. Everything else that could + // invalidate or retokenize the fragment (notably `%` and spaces) is encoded + // after JSON Pointer escaping. + return encodeURIComponent(escapeJSONPointerToken(token)).replace(/%24/g, '$'); +} + /** * Standard Schema normalization moves representable oneOf branches to anyOf. * Rewrite only pointers that traverse an actual oneOf schema array while the @@ -637,7 +671,7 @@ export function rewriteLocalRefsIntoMovedOneOfBranches(root: JSONSchema): void { } } - return changed ? '#/' + parts.map(escapeJSONPointerToken).join('/') : ref; + return changed ? '#/' + parts.map(encodeJSONPointerTokenForURIFragment).join('/') : ref; }; const rewriteRefs = (value: JSONSchemaDefinition): void => { @@ -745,6 +779,9 @@ function validateRefSchemas(schema: JSONSchemaDefinition, path: string[], root: }\` does not resolve to an object or boolean schema: ${JSON.stringify(ref)}`, ); } + if (typeof resolved === 'boolean') { + throw new TypeError(`Expected object schema but got boolean; path=${path.join('/')}`); + } if (!hasOnlyRefAndAnnotations(schema)) { throw new Error( `Schema $ref at \`${ diff --git a/tests/helpers/standard-schema.test.ts b/tests/helpers/standard-schema.test.ts index 266b3e1612..6ee128cbde 100644 --- a/tests/helpers/standard-schema.test.ts +++ b/tests/helpers/standard-schema.test.ts @@ -291,6 +291,35 @@ describe('Standard Schema helpers', () => { }); }); + it('re-encodes rewritten local refs after moving oneOf branches', () => { + const definitionName = 'folder/name~% value'; + const { standardSchema } = makeStandardSchema({ + type: 'object', + $defs: { + [definitionName]: { + oneOf: [{ type: 'string', const: 'foo' }, { type: 'number' }], + }, + }, + properties: { + // The encoded slashes are URI-fragment separators. The definition + // token itself exercises JSON Pointer escapes plus URI encoding. + alias: { $ref: '#/%24defs%2Ffolder~1name~0%25%20value%2FoneOf%2F0' }, + }, + required: ['alias'], + }); + + expect(standardResponseFormat(standardSchema, 'choice').json_schema.schema).toMatchObject({ + $defs: { + [definitionName]: { + anyOf: [{ type: 'string', const: 'foo' }, { type: 'number' }], + }, + }, + properties: { + alias: { $ref: '#/$defs/folder~1name~0%25%20value/anyOf/0' }, + }, + }); + }); + it('rejects oneOf branches that may overlap', () => { const overlappingOneOfSchema = { type: 'object', @@ -572,6 +601,40 @@ describe('Standard Schema helpers', () => { }); }); + it('normalizes singleton type arrays while preserving multi-type arrays', () => { + const { standardSchema } = makeStandardSchema({ + type: ['object'], + properties: { + weather: { + type: ['object'], + properties: { + city: { type: ['string'] }, + unit: { type: ['string', 'null'] }, + }, + required: ['city', 'unit'], + }, + }, + required: ['weather'], + }); + + expect(standardResponseFormat(standardSchema, 'weather').json_schema.schema).toEqual({ + type: 'object', + properties: { + weather: { + type: 'object', + properties: { + city: { type: 'string' }, + unit: { type: ['string', 'null'] }, + }, + required: ['city', 'unit'], + additionalProperties: false, + }, + }, + required: ['weather'], + additionalProperties: false, + }); + }); + it('rejects patternProperties before returning a strict schema', () => { const { standardSchema } = makeStandardSchema({ type: 'object', @@ -596,6 +659,28 @@ describe('Standard Schema helpers', () => { ); }); + it.each(['minProperties', 'maxProperties'] as const)( + 'rejects %s before returning a strict schema', + (keyword) => { + const { standardSchema } = makeStandardSchema({ + type: 'object', + properties: { + metadata: { + type: 'object', + properties: { value: { type: 'string' } }, + required: ['value'], + [keyword]: 1, + }, + }, + required: ['metadata'], + }); + + expect(() => standardResponseFormat(standardSchema, 'metadata')).toThrow( + `uses unsupported keyword \`${keyword}\``, + ); + }, + ); + it('rejects root anyOf schemas', () => { const { standardSchema } = makeStandardSchema({ type: 'object', @@ -653,6 +738,21 @@ describe('Standard Schema helpers', () => { ); }); + it('rejects boolean schemas reached through local refs before returning a strict schema', () => { + const { standardSchema } = makeStandardSchema({ + type: 'object', + additionalProperties: false, + properties: { + value: { $ref: '#/additionalProperties' }, + }, + required: ['value'], + }); + + expect(() => standardResponseFormat(standardSchema, 'value')).toThrow( + 'Expected object schema but got boolean; path=properties/value', + ); + }); + it('throws an actionable error when no JSON Schema is available', () => { const standardSchema = makeValidationOnlySchema(); diff --git a/tests/lib/transform.test.ts b/tests/lib/transform.test.ts index 3857e20fff..8ea1a83781 100644 --- a/tests/lib/transform.test.ts +++ b/tests/lib/transform.test.ts @@ -19,6 +19,41 @@ describe('toStrictJsonSchema', () => { "Root schema must have type: 'object' but got type: undefined", ); }); + + test('normalizes singleton type arrays at root and nested positions', () => { + const schema: JSONSchema = { + type: ['object'], + properties: { + user: { + type: ['object'], + properties: { + name: { type: ['string'] }, + nickname: { type: ['string', 'null'] }, + }, + required: ['name', 'nickname'], + }, + }, + required: ['user'], + }; + + expect(toStrictJsonSchema(schema)).toEqual({ + type: 'object', + properties: { + user: { + type: 'object', + properties: { + name: { type: 'string' }, + nickname: { type: ['string', 'null'] }, + }, + required: ['name', 'nickname'], + additionalProperties: false, + }, + }, + required: ['user'], + additionalProperties: false, + }); + expect(schema.type).toEqual(['object']); + }); }); describe('Additional Properties', () => { @@ -370,6 +405,21 @@ describe('toStrictJsonSchema', () => { ); }); + test('rejects boolean schemas reached through local refs', () => { + const schema: JSONSchema = { + type: 'object', + additionalProperties: false, + properties: { + value: { $ref: '#/additionalProperties' }, + }, + required: ['value'], + }; + + expect(() => toStrictJsonSchema(schema)).toThrow( + 'Expected object schema but got boolean; path=properties/value', + ); + }); + test('allows local refs into traversed schema array locations', () => { const schema: JSONSchema = { type: 'object', @@ -708,6 +758,23 @@ describe('toStrictJsonSchema', () => { expect(() => toStrictJsonSchema(schema)).toThrow('uses unsupported keyword `uniqueItems`'); }); + test.each(['minProperties', 'maxProperties'] as const)('rejects %s schemas', (keyword) => { + const schema: JSONSchema = { + type: 'object', + properties: { + metadata: { + type: 'object', + properties: { value: { type: 'string' } }, + required: ['value'], + [keyword]: 1, + }, + }, + required: ['metadata'], + }; + + expect(() => toStrictJsonSchema(schema)).toThrow(`uses unsupported keyword \`${keyword}\``); + }); + test.each([ [ 'contains', From fec404df10141bf1de0cfcd7043daa9723a24781 Mon Sep 17 00:00:00 2001 From: Hayden Date: Tue, 21 Jul 2026 16:54:27 +0000 Subject: [PATCH 17/35] fix(helpers): address strict schema review feedback --- src/lib/transform.ts | 95 ++++++++++++++++++++++++---- tests/lib/transform.test.ts | 119 ++++++++++++++++++------------------ 2 files changed, 142 insertions(+), 72 deletions(-) diff --git a/src/lib/transform.ts b/src/lib/transform.ts index 78e187c233..b15ff2f54c 100644 --- a/src/lib/transform.ts +++ b/src/lib/transform.ts @@ -50,6 +50,7 @@ const JSON_SCHEMA_UNSUPPORTED_SCHEMA_KEYWORDS = new Set([ 'allOf', 'contains', 'contentSchema', + 'dependentRequired', 'dependentSchemas', 'dependencies', 'else', @@ -121,6 +122,7 @@ export function forEachJSONSchemaChild( export function toStrictJsonSchema(schema: JSONSchema): JSONSchema { const schemaCopy = structuredClone(schema); normalizeSingletonTypeArrays(schemaCopy); + inlineRootRefObject(schemaCopy); if (schemaCopy.type !== 'object') { throw new Error( @@ -144,6 +146,73 @@ export function toStrictJsonSchema(schema: JSONSchema): JSONSchema { return strictSchema; } +/** + * Some Standard Schema converters emit the root object through a local ref, + * with the referenced schema stored in a root definition map. Structured + * Outputs requires the root itself to be an object, so inline that safe, + * definition-only form while keeping the root maps available for every local + * pointer in the schema. + */ +function inlineRootRefObject(schema: JSONSchema): void { + const ref = schema.$ref; + if (ref === undefined) { + return; + } + + if (typeof ref !== 'string') { + throw new TypeError('Received non-string $ref - ' + String(ref) + '; path='); + } + if (!ref.startsWith('#')) { + throw new Error( + 'External $ref at `` is not supported in strict Structured Outputs: ' + JSON.stringify(ref), + ); + } + if (!hasOnlyRootRefAndDefinitions(schema)) { + throw new Error( + 'Schema $ref at `` has non-annotation siblings that Draft 7 ignores and cannot be represented in strict Structured Outputs.', + ); + } + + const resolved = resolveLocalRef(schema, ref); + if (resolved === undefined) { + throw new Error( + 'Local $ref at `` does not resolve to an object or boolean schema: ' + JSON.stringify(ref), + ); + } + if (typeof resolved === 'boolean') { + throw new TypeError('Expected object schema but got boolean; path='); + } + + const rootDefinitions = schema.$defs; + const legacyDefinitions = schema.definitions; + const rootAnnotations = Object.fromEntries( + Object.entries(schema).filter(([keyword]) => JSON_SCHEMA_ANNOTATION_KEYWORDS.has(keyword)), + ); + const inlined = structuredClone(resolved); + const schemaRecord = schema as Record; + + for (const keyword of Object.keys(schema)) { + delete schemaRecord[keyword]; + } + Object.assign(schema, inlined, rootAnnotations); + if (rootDefinitions !== undefined) { + schema.$defs = rootDefinitions; + } + if (legacyDefinitions !== undefined) { + schema.definitions = legacyDefinitions; + } +} + +function hasOnlyRootRefAndDefinitions(schema: JSONSchema): boolean { + return Object.keys(schema).every( + (keyword) => + keyword === '$ref' || + keyword === '$defs' || + keyword === 'definitions' || + JSON_SCHEMA_ANNOTATION_KEYWORDS.has(keyword), + ); +} + /** * Draft 7 permits `type` to be either a string or an array of strings. A * singleton array has exactly the same validation semantics as its scalar @@ -324,20 +393,24 @@ function ensureStrictJsonSchema( jsonSchema.required = Object.keys(properties); } - // Draft 7 only applies additionalItems to tuple-style items arrays. Recurse - // into schema-valued extras so nested objects are strictified too; reject an - // ignored additionalItems rather than sending a keyword whose semantics may - // differ under Structured Outputs. + // Structured Outputs accepts one schema for every array item and does not + // support Draft 7 tuples or additionalItems. Reject both forms rather than + // advertising a schema whose array validation the API cannot preserve. const items = jsonSchema.items; const additionalItems = jsonSchema.additionalItems; + if (Array.isArray(items)) { + throw new Error( + `Schema at \`${ + path.join('/') || '' + }\` uses tuple-form \`items\`, which cannot be represented in strict Structured Outputs.`, + ); + } if (additionalItems !== undefined) { - if (!Array.isArray(items)) { - throw new Error( - `Schema at \`${ - path.join('/') || '' - }\` uses \`additionalItems\` without tuple \`items\`, which cannot be represented in strict Structured Outputs.`, - ); - } + throw new Error( + `Schema at \`${ + path.join('/') || '' + }\` uses unsupported keyword \`additionalItems\` and cannot be represented in strict Structured Outputs.`, + ); } // Handle intersections (allOf) diff --git a/tests/lib/transform.test.ts b/tests/lib/transform.test.ts index 8ea1a83781..c322145950 100644 --- a/tests/lib/transform.test.ts +++ b/tests/lib/transform.test.ts @@ -54,6 +54,43 @@ describe('toStrictJsonSchema', () => { }); expect(schema.type).toEqual(['object']); }); + + test('inlines a root local object ref while retaining definitions', () => { + const schema: JSONSchema = { + $ref: '#/$defs/Input', + $defs: { + Input: { + type: 'object', + properties: { + name: { $ref: '#/$defs/Name' }, + }, + required: ['name'], + }, + Name: { type: 'string' }, + }, + }; + + expect(toStrictJsonSchema(schema)).toEqual({ + type: 'object', + properties: { + name: { $ref: '#/$defs/Name' }, + }, + required: ['name'], + additionalProperties: false, + $defs: { + Input: { + type: 'object', + properties: { + name: { $ref: '#/$defs/Name' }, + }, + required: ['name'], + additionalProperties: false, + }, + Name: { type: 'string' }, + }, + }); + expect(schema).not.toHaveProperty('type'); + }); }); describe('Additional Properties', () => { @@ -425,16 +462,15 @@ describe('toStrictJsonSchema', () => { type: 'object', properties: { source: { - type: 'array', - items: [{ type: 'string' }], + anyOf: [{ type: 'string' }, { type: 'number' }], }, - alias: { $ref: '#/properties/source/items/0' }, + alias: { $ref: '#/properties/source/anyOf/0' }, }, required: ['source', 'alias'], }; expect(toStrictJsonSchema(schema).properties?.['alias']).toEqual({ - $ref: '#/properties/source/items/0', + $ref: '#/properties/source/anyOf/0', }); }); @@ -634,63 +670,28 @@ describe('toStrictJsonSchema', () => { `); }); - test('processes tuple item schemas recursively', () => { + test('rejects tuple-form items before returning a strict schema', () => { const schema: JSONSchema = { type: 'object', properties: { tuple: { type: 'array', - items: [ - { type: 'string' }, - { - type: 'array', - items: [ - { - type: 'object', - properties: { name: { type: 'string' } }, - required: ['name'], - }, - ], - }, - ], + items: [{ type: 'string' }, { type: 'number' }], }, }, required: ['tuple'], }; - expect(toStrictJsonSchema(schema)).toEqual({ - type: 'object', - properties: { - tuple: { - type: 'array', - items: [ - { type: 'string' }, - { - type: 'array', - items: [ - { - type: 'object', - properties: { name: { type: 'string' } }, - required: ['name'], - additionalProperties: false, - }, - ], - }, - ], - }, - }, - required: ['tuple'], - additionalProperties: false, - }); + expect(() => toStrictJsonSchema(schema)).toThrow('uses tuple-form `items`'); }); - test('processes schema-valued additionalItems recursively', () => { + test('rejects additionalItems before returning a strict schema', () => { const schema: JSONSchema = { type: 'object', properties: { tuple: { type: 'array', - items: [{ type: 'string' }], + items: { type: 'string' }, additionalItems: { type: 'object', properties: { name: { type: 'string' } }, @@ -701,23 +702,7 @@ describe('toStrictJsonSchema', () => { required: ['tuple'], }; - expect(toStrictJsonSchema(schema)).toEqual({ - type: 'object', - properties: { - tuple: { - type: 'array', - items: [{ type: 'string' }], - additionalItems: { - type: 'object', - properties: { name: { type: 'string' } }, - required: ['name'], - additionalProperties: false, - }, - }, - }, - required: ['tuple'], - additionalProperties: false, - }); + expect(() => toStrictJsonSchema(schema)).toThrow('uses unsupported keyword `additionalItems`'); }); test('rejects patternProperties schemas', () => { @@ -839,6 +824,18 @@ describe('toStrictJsonSchema', () => { additionalProperties: false, }, ], + [ + 'dependentRequired', + { + type: 'object', + properties: { value: { type: 'string' } }, + required: ['value'], + dependentRequired: { + value: ['dependent'], + }, + additionalProperties: false, + }, + ], ])('rejects unsupported nested %s schemas', (_name, propertySchema) => { const schema: JSONSchema = { type: 'object', From a142cb816e3fe1ba2dc160d356da971609b52dde Mon Sep 17 00:00:00 2001 From: Hayden Date: Tue, 21 Jul 2026 17:10:34 +0000 Subject: [PATCH 18/35] fix(helpers): compare schema keys deterministically --- src/lib/transform.ts | 29 ++++++++++++- tests/lib/transform.test.ts | 86 +++++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/src/lib/transform.ts b/src/lib/transform.ts index b15ff2f54c..4d692c0491 100644 --- a/src/lib/transform.ts +++ b/src/lib/transform.ts @@ -1032,5 +1032,32 @@ function hasObjectShapeWithoutAllOf(schema: JSONSchema): boolean { } function schemasEqual(left: unknown, right: unknown): boolean { - return JSON.stringify(left) === JSON.stringify(right); + if (left === right) { + return true; + } + + if (Array.isArray(left) || Array.isArray(right)) { + if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) { + return false; + } + + return left.every((value, index) => schemasEqual(value, right[index])); + } + + if (typeof left !== 'object' || left === null || typeof right !== 'object' || right === null) { + return false; + } + + const leftRecord = left as Record; + const rightRecord = right as Record; + const leftKeys = Object.keys(leftRecord).sort(); + const rightKeys = Object.keys(rightRecord).sort(); + + if (leftKeys.length !== rightKeys.length) { + return false; + } + + return leftKeys.every( + (key, index) => key === rightKeys[index] && schemasEqual(leftRecord[key], rightRecord[key]), + ); } diff --git a/tests/lib/transform.test.ts b/tests/lib/transform.test.ts index c322145950..fab2d49dd9 100644 --- a/tests/lib/transform.test.ts +++ b/tests/lib/transform.test.ts @@ -1182,6 +1182,92 @@ describe('toStrictJsonSchema', () => { expect(Object.prototype.hasOwnProperty.call(properties, '__proto__')).toBe(true); }); + test('merges matching object allOf properties with reordered schema keys', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + value: { + allOf: [ + { + type: 'object', + properties: { + shared: { + type: 'object', + description: 'shared object', + properties: { name: { type: 'string', description: 'display name' } }, + required: ['name'], + }, + }, + required: ['shared'], + }, + { + required: ['shared'], + properties: { + shared: { + required: ['name'], + properties: { name: { description: 'display name', type: 'string' } }, + description: 'shared object', + type: 'object', + }, + }, + type: 'object', + }, + ], + }, + }, + required: ['value'], + }; + + expect(toStrictJsonSchema(schema)).toEqual({ + type: 'object', + properties: { + value: { + type: 'object', + properties: { + shared: { + type: 'object', + description: 'shared object', + properties: { name: { type: 'string', description: 'display name' } }, + required: ['name'], + additionalProperties: false, + }, + }, + required: ['shared'], + additionalProperties: false, + }, + }, + required: ['value'], + additionalProperties: false, + }); + }); + + test('rejects matching object allOf properties with reordered array values', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + value: { + allOf: [ + { + type: 'object', + properties: { shared: { type: 'string', enum: ['first', 'second'] } }, + required: ['shared'], + }, + { + type: 'object', + properties: { shared: { enum: ['second', 'first'], type: 'string' } }, + required: ['shared'], + }, + ], + }, + }, + required: ['value'], + }; + + expect(() => toStrictJsonSchema(schema)).toThrow( + 'cannot be merged without changing Draft 7 validation', + ); + }); + test('rejects object allOf variants that cannot be merged exactly', () => { const schema: JSONSchema = { type: 'object', From 6268a97b5941a0dce5ea7eec0e2a067e8d11dfa2 Mon Sep 17 00:00:00 2001 From: Hayden Date: Tue, 21 Jul 2026 17:16:33 +0000 Subject: [PATCH 19/35] fix(helpers): handle shared schema edge cases --- src/helpers/standard-schema.ts | 4 ++ src/lib/transform.ts | 63 +++++++++++++++++++-------- tests/helpers/standard-schema.test.ts | 21 +++++++++ tests/lib/transform.test.ts | 54 +++++++++++++++++++++++ 4 files changed, 125 insertions(+), 17 deletions(-) diff --git a/src/helpers/standard-schema.ts b/src/helpers/standard-schema.ts index 17be5c6d51..b6a8100760 100644 --- a/src/helpers/standard-schema.ts +++ b/src/helpers/standard-schema.ts @@ -285,10 +285,14 @@ function normalizeStructuredOutputSchema(schema: JSONSchema): JSONSchema { assertNoNestedSchemaIds(schema); const normalizedSchema = structuredClone(schema); const oneOfSchemas: Record[] = []; + const visitedSchemas = new Set>(); const visitSchema = (value: unknown): void => { if (!value || typeof value !== 'object' || Array.isArray(value)) return; const record = value as Record; + if (visitedSchemas.has(record)) return; + visitedSchemas.add(record); + if (record['oneOf'] !== undefined) { if (!Array.isArray(record['oneOf'])) { throw new OpenAIError( diff --git a/src/lib/transform.ts b/src/lib/transform.ts index 4d692c0491..1ea7e3ff6b 100644 --- a/src/lib/transform.ts +++ b/src/lib/transform.ts @@ -154,33 +154,51 @@ export function toStrictJsonSchema(schema: JSONSchema): JSONSchema { * pointer in the schema. */ function inlineRootRefObject(schema: JSONSchema): void { - const ref = schema.$ref; + let ref = schema.$ref; if (ref === undefined) { return; } - if (typeof ref !== 'string') { - throw new TypeError('Received non-string $ref - ' + String(ref) + '; path='); - } - if (!ref.startsWith('#')) { - throw new Error( - 'External $ref at `` is not supported in strict Structured Outputs: ' + JSON.stringify(ref), - ); - } + assertLocalRootRef(ref); if (!hasOnlyRootRefAndDefinitions(schema)) { throw new Error( 'Schema $ref at `` has non-annotation siblings that Draft 7 ignores and cannot be represented in strict Structured Outputs.', ); } - const resolved = resolveLocalRef(schema, ref); - if (resolved === undefined) { - throw new Error( - 'Local $ref at `` does not resolve to an object or boolean schema: ' + JSON.stringify(ref), - ); - } - if (typeof resolved === 'boolean') { - throw new TypeError('Expected object schema but got boolean; path='); + const seenRefs = new Set(); + let resolved: JSONSchema; + while (true) { + if (seenRefs.has(ref)) { + throw new Error('Cyclic local $ref at `` is not supported: ' + JSON.stringify(ref)); + } + seenRefs.add(ref); + + const target = resolveLocalRef(schema, ref); + if (target === undefined) { + throw new Error( + 'Local $ref at `` does not resolve to an object or boolean schema: ' + JSON.stringify(ref), + ); + } + if (typeof target === 'boolean') { + throw new TypeError('Expected object schema but got boolean; path='); + } + + const nextRef = target.$ref; + if (nextRef === undefined) { + resolved = target; + break; + } + assertLocalRootRef(nextRef); + if (seenRefs.has(nextRef)) { + throw new Error('Cyclic local $ref at `` is not supported: ' + JSON.stringify(nextRef)); + } + if (!hasOnlyRefAndAnnotations(target)) { + throw new Error( + 'Schema $ref in root chain has non-annotation siblings that Draft 7 ignores and cannot be represented in strict Structured Outputs.', + ); + } + ref = nextRef; } const rootDefinitions = schema.$defs; @@ -203,6 +221,17 @@ function inlineRootRefObject(schema: JSONSchema): void { } } +function assertLocalRootRef(ref: unknown): asserts ref is string { + if (typeof ref !== 'string') { + throw new TypeError('Received non-string $ref - ' + String(ref) + '; path='); + } + if (!ref.startsWith('#')) { + throw new Error( + 'External $ref at `` is not supported in strict Structured Outputs: ' + JSON.stringify(ref), + ); + } +} + function hasOnlyRootRefAndDefinitions(schema: JSONSchema): boolean { return Object.keys(schema).every( (keyword) => diff --git a/tests/helpers/standard-schema.test.ts b/tests/helpers/standard-schema.test.ts index 6ee128cbde..54751222b5 100644 --- a/tests/helpers/standard-schema.test.ts +++ b/tests/helpers/standard-schema.test.ts @@ -219,6 +219,27 @@ describe('Standard Schema helpers', () => { expect(oneOfSchema.properties.choice).toHaveProperty('oneOf'); }); + it('rewrites a shared oneOf schema only once', () => { + const sharedChoice = { + oneOf: [{ type: 'string', const: 'foo' }, { type: 'number' }], + }; + const { standardSchema } = makeStandardSchema({ + type: 'object', + properties: { first: sharedChoice, second: sharedChoice }, + required: ['first', 'second'], + }); + + const schema = standardResponseFormat(standardSchema, 'choice').json_schema.schema; + + expect(schema).toMatchObject({ + properties: { + first: { anyOf: [{ type: 'string', const: 'foo' }, { type: 'number' }] }, + second: { anyOf: [{ type: 'string', const: 'foo' }, { type: 'number' }] }, + }, + }); + expect(JSON.stringify(schema)).not.toContain('"oneOf"'); + }); + it('does not close redundant object oneOf wrappers as empty objects', () => { const { standardSchema } = makeStandardSchema({ type: 'object', diff --git a/tests/lib/transform.test.ts b/tests/lib/transform.test.ts index fab2d49dd9..1e886435d9 100644 --- a/tests/lib/transform.test.ts +++ b/tests/lib/transform.test.ts @@ -91,6 +91,60 @@ describe('toStrictJsonSchema', () => { }); expect(schema).not.toHaveProperty('type'); }); + + test('follows a root local ref chain before validating the object type', () => { + const schema: JSONSchema = { + $ref: '#/$defs/A', + $defs: { + A: { $ref: '#/$defs/B' }, + B: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + }, + }, + }; + + expect(toStrictJsonSchema(schema)).toEqual({ + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + additionalProperties: false, + $defs: { + A: { $ref: '#/$defs/B' }, + B: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + additionalProperties: false, + }, + }, + }); + }); + + test('rejects cyclic root local ref chains', () => { + const schema: JSONSchema = { + $ref: '#/$defs/A', + $defs: { + A: { $ref: '#/$defs/B' }, + B: { $ref: '#/$defs/A' }, + }, + }; + + expect(() => toStrictJsonSchema(schema)).toThrow('Cyclic local $ref at ``'); + }); + + test.each([ + ['missing', '#/$defs/Missing', 'does not resolve to an object or boolean schema'], + ['external', 'https://example.com/schema.json#/$defs/Input', 'External $ref at ``'], + ])('rejects %s targets in root local ref chains', (_name, ref, message) => { + const schema: JSONSchema = { + $ref: '#/$defs/A', + $defs: { A: { $ref: ref } }, + }; + + expect(() => toStrictJsonSchema(schema)).toThrow(message); + }); }); describe('Additional Properties', () => { From 9d400cc4b9ff4a5eea3d8b16172d4c735b55f30a Mon Sep 17 00:00:00 2001 From: Hayden Date: Tue, 21 Jul 2026 17:19:22 +0000 Subject: [PATCH 20/35] fix(helpers): report root ref paths --- src/lib/transform.ts | 4 ++-- tests/lib/transform.test.ts | 12 ++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/lib/transform.ts b/src/lib/transform.ts index 1ea7e3ff6b..0194dc26e7 100644 --- a/src/lib/transform.ts +++ b/src/lib/transform.ts @@ -181,7 +181,7 @@ function inlineRootRefObject(schema: JSONSchema): void { ); } if (typeof target === 'boolean') { - throw new TypeError('Expected object schema but got boolean; path='); + throw new TypeError('Expected object schema but got boolean; path='); } const nextRef = target.$ref; @@ -223,7 +223,7 @@ function inlineRootRefObject(schema: JSONSchema): void { function assertLocalRootRef(ref: unknown): asserts ref is string { if (typeof ref !== 'string') { - throw new TypeError('Received non-string $ref - ' + String(ref) + '; path='); + throw new TypeError('Received non-string $ref - ' + String(ref) + '; path='); } if (!ref.startsWith('#')) { throw new Error( diff --git a/tests/lib/transform.test.ts b/tests/lib/transform.test.ts index 1e886435d9..971695de63 100644 --- a/tests/lib/transform.test.ts +++ b/tests/lib/transform.test.ts @@ -145,6 +145,18 @@ describe('toStrictJsonSchema', () => { expect(() => toStrictJsonSchema(schema)).toThrow(message); }); + + test('reports the root path for invalid root ref values and boolean targets', () => { + expect(() => toStrictJsonSchema({ $ref: 1 } as unknown as JSONSchema)).toThrow( + 'Received non-string $ref - 1; path=', + ); + expect(() => + toStrictJsonSchema({ + $ref: '#/$defs/False', + $defs: { False: false }, + }), + ).toThrow('Expected object schema but got boolean; path='); + }); }); describe('Additional Properties', () => { From cbac9636258797fca5f9d170e77e8b6fc79643ae Mon Sep 17 00:00:00 2001 From: Hayden Date: Tue, 21 Jul 2026 17:43:57 +0000 Subject: [PATCH 21/35] fix(helpers): address standard schema review feedback --- src/lib/ResponsesParser.ts | 9 ++- src/lib/jsonschema.ts | 3 + src/lib/transform.ts | 72 +++++++++++++++++++++-- tests/lib/transform.test.ts | 114 ++++++++++++++++++++++++++++++++++++ 4 files changed, 187 insertions(+), 11 deletions(-) diff --git a/src/lib/ResponsesParser.ts b/src/lib/ResponsesParser.ts index bc13cf751d..3113716415 100644 --- a/src/lib/ResponsesParser.ts +++ b/src/lib/ResponsesParser.ts @@ -147,11 +147,10 @@ export function hasAutoParseableInput(params: ResponseCreateParamsWithTools): bo } return ( - (Array.isArray(params.tools) && - params.tools.some( - (tool) => isAutoParsableTool(tool) || (tool.type === 'function' && tool.strict === true), - )) || - false + Array.isArray(params.tools) && + params.tools.some( + (tool) => isAutoParsableTool(tool) || (tool.type === 'function' && tool.strict === true), + ) ); } diff --git a/src/lib/jsonschema.ts b/src/lib/jsonschema.ts index 45eda05277..70f12f5792 100644 --- a/src/lib/jsonschema.ts +++ b/src/lib/jsonschema.ts @@ -61,6 +61,7 @@ export type JSONSchemaVersion = string; */ export type JSONSchemaDefinition = JSONSchema | boolean; export interface JSONSchema { + $schema?: JSONSchemaVersion | undefined; $id?: string | undefined; $comment?: string | undefined; @@ -86,6 +87,8 @@ export interface JSONSchema { maxLength?: number | undefined; minLength?: number | undefined; pattern?: string | undefined; + contentEncoding?: string | undefined; + contentMediaType?: string | undefined; /** * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-6.4 diff --git a/src/lib/transform.ts b/src/lib/transform.ts index 0194dc26e7..06603cf838 100644 --- a/src/lib/transform.ts +++ b/src/lib/transform.ts @@ -10,6 +10,8 @@ const JSON_SCHEMA_ANNOTATION_KEYWORDS = new Set([ 'writeOnly', ]); +const JSON_SCHEMA_ROOT_METADATA_KEYWORDS = new Set(['$id', '$schema']); + const JSON_SCHEMA_OBJECT_KEYWORDS = new Set([ 'additionalProperties', 'dependencies', @@ -49,6 +51,8 @@ const JSON_SCHEMA_MAP_SCHEMA_KEYWORDS = [ const JSON_SCHEMA_UNSUPPORTED_SCHEMA_KEYWORDS = new Set([ 'allOf', 'contains', + 'contentEncoding', + 'contentMediaType', 'contentSchema', 'dependentRequired', 'dependentSchemas', @@ -123,6 +127,8 @@ export function toStrictJsonSchema(schema: JSONSchema): JSONSchema { const schemaCopy = structuredClone(schema); normalizeSingletonTypeArrays(schemaCopy); inlineRootRefObject(schemaCopy); + preserveAllOfRefTargets(schemaCopy, true); + normalizeRootAllOf(schemaCopy); if (schemaCopy.type !== 'object') { throw new Error( @@ -162,7 +168,7 @@ function inlineRootRefObject(schema: JSONSchema): void { assertLocalRootRef(ref); if (!hasOnlyRootRefAndDefinitions(schema)) { throw new Error( - 'Schema $ref at `` has non-annotation siblings that Draft 7 ignores and cannot be represented in strict Structured Outputs.', + 'Schema $ref at `` has non-metadata siblings that Draft 7 ignores and cannot be represented in strict Structured Outputs.', ); } @@ -203,8 +209,11 @@ function inlineRootRefObject(schema: JSONSchema): void { const rootDefinitions = schema.$defs; const legacyDefinitions = schema.definitions; - const rootAnnotations = Object.fromEntries( - Object.entries(schema).filter(([keyword]) => JSON_SCHEMA_ANNOTATION_KEYWORDS.has(keyword)), + const rootMetadata = Object.fromEntries( + Object.entries(schema).filter( + ([keyword]) => + JSON_SCHEMA_ANNOTATION_KEYWORDS.has(keyword) || JSON_SCHEMA_ROOT_METADATA_KEYWORDS.has(keyword), + ), ); const inlined = structuredClone(resolved); const schemaRecord = schema as Record; @@ -212,7 +221,7 @@ function inlineRootRefObject(schema: JSONSchema): void { for (const keyword of Object.keys(schema)) { delete schemaRecord[keyword]; } - Object.assign(schema, inlined, rootAnnotations); + Object.assign(schema, inlined, rootMetadata); if (rootDefinitions !== undefined) { schema.$defs = rootDefinitions; } @@ -221,6 +230,39 @@ function inlineRootRefObject(schema: JSONSchema): void { } } +/** + * Root object validation runs before recursive strictification, so normalize + * the same exactly representable allOf forms here that the recursive pass + * handles for nested schemas. + */ +function normalizeRootAllOf(schema: JSONSchema): void { + while (schema.allOf !== undefined) { + if (mergeObjectAllOf(schema, [])) { + return; + } + + const allOf = schema.allOf; + if (!Array.isArray(allOf) || allOf.length !== 1 || !hasOnlyRootAllOfMetadataSiblings(schema)) { + return; + } + + const branch = allOf[0]; + if (typeof branch === 'boolean' || !isObject(branch)) { + return; + } + + const rootMetadata = { ...schema }; + delete rootMetadata.allOf; + const normalized = structuredClone(branch); + const schemaRecord = schema as Record; + + for (const keyword of Object.keys(schema)) { + delete schemaRecord[keyword]; + } + Object.assign(schema, normalized, rootMetadata); + } +} + function assertLocalRootRef(ref: unknown): asserts ref is string { if (typeof ref !== 'string') { throw new TypeError('Received non-string $ref - ' + String(ref) + '; path='); @@ -238,6 +280,7 @@ function hasOnlyRootRefAndDefinitions(schema: JSONSchema): boolean { keyword === '$ref' || keyword === '$defs' || keyword === 'definitions' || + JSON_SCHEMA_ROOT_METADATA_KEYWORDS.has(keyword) || JSON_SCHEMA_ANNOTATION_KEYWORDS.has(keyword), ); } @@ -643,6 +686,17 @@ function hasOnlyAnnotationSiblings(schema: JSONSchema, keyword: string): boolean ); } +function hasOnlyRootAllOfMetadataSiblings(schema: JSONSchema): boolean { + return Object.keys(schema).every( + (keyword) => + keyword === 'allOf' || + keyword === '$defs' || + keyword === 'definitions' || + JSON_SCHEMA_ROOT_METADATA_KEYWORDS.has(keyword) || + JSON_SCHEMA_ANNOTATION_KEYWORDS.has(keyword), + ); +} + function hasObjectKeywords(schema: JSONSchema): boolean { return Object.keys(schema).some((keyword) => JSON_SCHEMA_OBJECT_KEYWORDS.has(keyword)); } @@ -798,7 +852,7 @@ export function rewriteLocalRefsIntoMovedOneOfBranches(root: JSONSchema): void { * referenced through an allOf branch under a stable root definition first so * structural flattening cannot leave a dangling local pointer behind. */ -function preserveAllOfRefTargets(root: JSONSchema): void { +function preserveAllOfRefTargets(root: JSONSchema, rootOnly = false): void { const refsToPreserve = new Set(); const collectRefs = (value: JSONSchemaDefinition): void => { if (typeof value === 'boolean' || !isObject(value)) { @@ -806,7 +860,10 @@ function preserveAllOfRefTargets(root: JSONSchema): void { } if (typeof value.$ref === 'string' && refTargetsAllOfBranch(root, value.$ref)) { - refsToPreserve.add(value.$ref); + const pointerParts = parseLocalRef(value.$ref); + if (!rootOnly || pointerParts?.[0] === 'allOf') { + refsToPreserve.add(value.$ref); + } } forEachJSONSchemaChild(value, [], (child) => { @@ -829,6 +886,9 @@ function preserveAllOfRefTargets(root: JSONSchema): void { for (const ref of refsToPreserve) { const target = resolveLocalRef(root, ref); if (!isSchemaDefinition(target)) { + if (rootOnly) { + continue; + } throw new Error('Local $ref cannot be preserved before allOf flattening: ' + JSON.stringify(ref)); } diff --git a/tests/lib/transform.test.ts b/tests/lib/transform.test.ts index 971695de63..b9981ae65e 100644 --- a/tests/lib/transform.test.ts +++ b/tests/lib/transform.test.ts @@ -92,6 +92,38 @@ describe('toStrictJsonSchema', () => { expect(schema).not.toHaveProperty('type'); }); + test('preserves root dialect and base metadata when inlining a local ref', () => { + const schema: JSONSchema = { + $schema: 'http://json-schema.org/draft-07/schema#', + $id: 'https://example.com/input.json', + $ref: '#/$defs/Input', + $defs: { + Input: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + }, + }, + }; + + expect(toStrictJsonSchema(schema)).toEqual({ + $schema: 'http://json-schema.org/draft-07/schema#', + $id: 'https://example.com/input.json', + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + additionalProperties: false, + $defs: { + Input: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + additionalProperties: false, + }, + }, + }); + }); + test('follows a root local ref chain before validating the object type', () => { const schema: JSONSchema = { $ref: '#/$defs/A', @@ -157,6 +189,70 @@ describe('toStrictJsonSchema', () => { }), ).toThrow('Expected object schema but got boolean; path='); }); + + test('normalizes a single root allOf object branch before validating the type', () => { + const schema: JSONSchema = { + allOf: [ + { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + }, + ], + }; + + expect(toStrictJsonSchema(schema)).toEqual({ + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + additionalProperties: false, + }); + }); + + test('merges compatible root allOf object branches before validating the type', () => { + const schema: JSONSchema = { + allOf: [ + { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + }, + { + type: 'object', + properties: { age: { type: 'number' } }, + required: ['age'], + }, + ], + }; + + expect(toStrictJsonSchema(schema)).toEqual({ + type: 'object', + properties: { name: { type: 'string' }, age: { type: 'number' } }, + required: ['name', 'age'], + additionalProperties: false, + }); + }); + + test('still rejects root allOf object branches that cannot be merged exactly', () => { + const schema: JSONSchema = { + allOf: [ + { + type: 'object', + properties: { shared: { type: 'string' } }, + required: ['shared'], + }, + { + type: 'object', + properties: { shared: { type: 'number' } }, + required: ['shared'], + }, + ], + }; + + expect(() => toStrictJsonSchema(schema)).toThrow( + 'cannot be merged without changing Draft 7 validation', + ); + }); }); describe('Additional Properties', () => { @@ -809,6 +905,24 @@ describe('toStrictJsonSchema', () => { expect(() => toStrictJsonSchema(schema)).toThrow('uses unsupported keyword `uniqueItems`'); }); + test.each([ + ['contentEncoding', 'base64'], + ['contentMediaType', 'application/json'], + ] as const)('rejects unsupported %s schemas', (keyword, value) => { + const schema: JSONSchema = { + type: 'object', + properties: { + payload: { + type: 'string', + [keyword]: value, + }, + }, + required: ['payload'], + }; + + expect(() => toStrictJsonSchema(schema)).toThrow(`uses unsupported keyword \`${keyword}\``); + }); + test.each(['minProperties', 'maxProperties'] as const)('rejects %s schemas', (keyword) => { const schema: JSONSchema = { type: 'object', From d6d58eeabcdf129118168d9f066d0c4f94d56b02 Mon Sep 17 00:00:00 2001 From: Hayden Date: Tue, 21 Jul 2026 18:09:39 +0000 Subject: [PATCH 22/35] fix(helpers): preserve root schema normalization --- src/lib/transform.ts | 59 ++++++++++- tests/helpers/standard-schema.test.ts | 72 +++++++++++++ tests/lib/transform.test.ts | 147 ++++++++++++++++++++++++++ 3 files changed, 274 insertions(+), 4 deletions(-) diff --git a/src/lib/transform.ts b/src/lib/transform.ts index 06603cf838..894d96ec82 100644 --- a/src/lib/transform.ts +++ b/src/lib/transform.ts @@ -129,6 +129,9 @@ export function toStrictJsonSchema(schema: JSONSchema): JSONSchema { inlineRootRefObject(schemaCopy); preserveAllOfRefTargets(schemaCopy, true); normalizeRootAllOf(schemaCopy); + // A singleton root allOf can flatten to a local ref. Run the same root + // inliner again so its chain and cycle checks apply before root validation. + inlineRootRefObject(schemaCopy); if (schemaCopy.type !== 'object') { throw new Error( @@ -173,6 +176,12 @@ function inlineRootRefObject(schema: JSONSchema): void { } const seenRefs = new Set(); + // Ref siblings are annotations in Draft 7, so keep them while following + // aliases. Add outer annotations first so they win over inner aliases and + // the final target when the effective root is assembled. + const inheritedAnnotations: Record = Object.fromEntries( + Object.entries(schema).filter(([keyword]) => JSON_SCHEMA_ANNOTATION_KEYWORDS.has(keyword)), + ); let resolved: JSONSchema; while (true) { if (seenRefs.has(ref)) { @@ -204,11 +213,31 @@ function inlineRootRefObject(schema: JSONSchema): void { 'Schema $ref in root chain has non-annotation siblings that Draft 7 ignores and cannot be represented in strict Structured Outputs.', ); } + for (const keyword of JSON_SCHEMA_ANNOTATION_KEYWORDS) { + if (!(keyword in inheritedAnnotations) && keyword in target) { + inheritedAnnotations[keyword] = (target as Record)[keyword]; + } + } ref = nextRef; } const rootDefinitions = schema.$defs; const legacyDefinitions = schema.definitions; + for (const keyword of ['$defs', 'definitions'] as const) { + const rootDefinitionMap = schema[keyword]; + const targetDefinitionMap = resolved[keyword]; + if ( + rootDefinitionMap !== undefined && + targetDefinitionMap !== undefined && + !schemasEqual(rootDefinitionMap, targetDefinitionMap) + ) { + throw new Error( + 'Cannot inline a root local $ref with conflicting ' + + keyword + + ' definition maps without changing local ref resolution.', + ); + } + } const rootMetadata = Object.fromEntries( Object.entries(schema).filter( ([keyword]) => @@ -221,7 +250,7 @@ function inlineRootRefObject(schema: JSONSchema): void { for (const keyword of Object.keys(schema)) { delete schemaRecord[keyword]; } - Object.assign(schema, inlined, rootMetadata); + Object.assign(schema, inlined, inheritedAnnotations, rootMetadata); if (rootDefinitions !== undefined) { schema.$defs = rootDefinitions; } @@ -715,15 +744,26 @@ function normalizeObjectUnionWrapper(jsonSchema: JSONSchema, path: string[], roo return; } - const hasOwnObjectKeywords = Object.keys(jsonSchema).some((keyword) => - JSON_SCHEMA_OBJECT_KEYWORDS.has(keyword), + const isValidationNeutralEmptyObjectKeyword = (keyword: string): boolean => + (keyword === 'properties' && + isObject(jsonSchema.properties) && + Object.keys(jsonSchema.properties).length === 0) || + (keyword === 'required' && Array.isArray(jsonSchema.required) && jsonSchema.required.length === 0); + const hasOwnObjectConstraints = Object.keys(jsonSchema).some( + (keyword) => JSON_SCHEMA_OBJECT_KEYWORDS.has(keyword) && !isValidationNeutralEmptyObjectKeyword(keyword), ); if ( jsonSchema.type === 'object' && - !hasOwnObjectKeywords && + !hasOwnObjectConstraints && Array.isArray(jsonSchema.anyOf) && jsonSchema.anyOf.every((branch) => isObjectOnlySchema(branch, root)) ) { + if (isValidationNeutralEmptyObjectKeyword('properties')) { + delete jsonSchema.properties; + } + if (isValidationNeutralEmptyObjectKeyword('required')) { + delete jsonSchema.required; + } delete jsonSchema.type; return; } @@ -997,6 +1037,7 @@ function mergeObjectAllOf(jsonSchema: JSONSchema, path: string[]): boolean { keyword !== 'allOf' && keyword !== '$defs' && keyword !== 'definitions' && + !(path.length === 0 && JSON_SCHEMA_ROOT_METADATA_KEYWORDS.has(keyword)) && !MERGEABLE_OBJECT_ALL_OF_KEYWORDS.has(keyword) ) { fail(); @@ -1025,6 +1066,13 @@ function mergeObjectAllOf(jsonSchema: JSONSchema, path: string[]): boolean { merged[keyword] = jsonSchema[keyword]; } } + if (path.length === 0) { + for (const keyword of JSON_SCHEMA_ROOT_METADATA_KEYWORDS) { + if (keyword in jsonSchema) { + (merged as Record)[keyword] = (jsonSchema as Record)[keyword]; + } + } + } const mergedProperties = Object.create(null) as Record; const mergedRequired = new Set(); @@ -1052,6 +1100,9 @@ function mergeObjectAllOf(jsonSchema: JSONSchema, path: string[]): boolean { for (const keyword of Object.keys(branch)) { if (keyword === 'allOf' && branch === jsonSchema) continue; if ((keyword === '$defs' || keyword === 'definitions') && branch === jsonSchema) continue; + if (branch === jsonSchema && path.length === 0 && JSON_SCHEMA_ROOT_METADATA_KEYWORDS.has(keyword)) { + continue; + } if (!MERGEABLE_OBJECT_ALL_OF_KEYWORDS.has(keyword)) { fail(); } diff --git a/tests/helpers/standard-schema.test.ts b/tests/helpers/standard-schema.test.ts index 54751222b5..98a8f0c3b9 100644 --- a/tests/helpers/standard-schema.test.ts +++ b/tests/helpers/standard-schema.test.ts @@ -411,6 +411,78 @@ describe('Standard Schema helpers', () => { }); }); + it('drops validation-neutral empty object keywords when normalizing oneOf wrappers', () => { + const { standardSchema } = makeStandardSchema({ + type: 'object', + properties: { + choice: { + type: 'object', + properties: {}, + required: [], + oneOf: [ + { + type: 'object', + properties: { kind: { type: 'string', const: 'foo' }, foo: { type: 'string' } }, + required: ['kind', 'foo'], + }, + { + type: 'object', + properties: { kind: { type: 'string', const: 'bar' }, bar: { type: 'number' } }, + required: ['kind', 'bar'], + }, + ], + }, + }, + required: ['choice'], + }); + + const schema = standardResponseFormat(standardSchema, 'choice').json_schema.schema; + expect((schema as JSONSchema).properties?.['choice']).toEqual({ + anyOf: [ + { + type: 'object', + properties: { kind: { type: 'string', const: 'foo' }, foo: { type: 'string' } }, + required: ['kind', 'foo'], + additionalProperties: false, + }, + { + type: 'object', + properties: { kind: { type: 'string', const: 'bar' }, bar: { type: 'number' } }, + required: ['kind', 'bar'], + additionalProperties: false, + }, + ], + }); + }); + + it('still rejects non-empty object constraints on normalized oneOf wrappers', () => { + const { standardSchema } = makeStandardSchema({ + type: 'object', + properties: { + choice: { + type: 'object', + properties: { kind: { type: 'string' } }, + required: ['kind'], + oneOf: [ + { + type: 'object', + properties: { kind: { type: 'string', const: 'foo' }, foo: { type: 'string' } }, + required: ['kind', 'foo'], + }, + { + type: 'object', + properties: { kind: { type: 'string', const: 'bar' }, bar: { type: 'number' } }, + required: ['kind', 'bar'], + }, + ], + }, + }, + required: ['choice'], + }); + + expect(() => standardResponseFormat(standardSchema, 'choice')).toThrow('Object anyOf schema'); + }); + it('normalizes annotated oneOf ref branches but rejects validation siblings', () => { const definitions = { foo: { diff --git a/tests/lib/transform.test.ts b/tests/lib/transform.test.ts index b9981ae65e..8166ac65df 100644 --- a/tests/lib/transform.test.ts +++ b/tests/lib/transform.test.ts @@ -154,6 +154,83 @@ describe('toStrictJsonSchema', () => { }); }); + test('preserves root ref-chain annotations with outer precedence', () => { + const schema: JSONSchema = { + $ref: '#/$defs/A', + description: 'root description', + $defs: { + A: { + $ref: '#/$defs/B', + title: 'alias title', + description: 'alias description', + }, + B: { + $ref: '#/$defs/Input', + title: 'inner alias title', + $comment: 'inner alias comment', + }, + Input: { + type: 'object', + title: 'target title', + description: 'target description', + $comment: 'target comment', + properties: { name: { type: 'string' } }, + required: ['name'], + }, + }, + }; + + expect(toStrictJsonSchema(schema)).toEqual({ + type: 'object', + title: 'alias title', + description: 'root description', + $comment: 'inner alias comment', + properties: { name: { type: 'string' } }, + required: ['name'], + additionalProperties: false, + $defs: { + A: { + $ref: '#/$defs/B', + title: 'alias title', + description: 'alias description', + }, + B: { + $ref: '#/$defs/Input', + title: 'inner alias title', + $comment: 'inner alias comment', + }, + Input: { + type: 'object', + title: 'target title', + description: 'target description', + $comment: 'target comment', + properties: { name: { type: 'string' } }, + required: ['name'], + additionalProperties: false, + }, + }, + }); + }); + + test.each(['$defs', 'definitions'] as const)( + 'rejects conflicting %s maps when inlining a root local ref', + (keyword) => { + const schema = { + $ref: '#/' + keyword + '/Input', + [keyword]: { + Input: { + type: 'object', + [keyword]: { Nested: { type: 'string' } }, + properties: { name: { type: 'string' } }, + required: ['name'], + }, + }, + } as JSONSchema; + + expect(() => toStrictJsonSchema(schema)).toThrow('conflicting ' + keyword + ' definition maps'); + }, + ); + test('rejects cyclic root local ref chains', () => { const schema: JSONSchema = { $ref: '#/$defs/A', @@ -166,6 +243,48 @@ describe('toStrictJsonSchema', () => { expect(() => toStrictJsonSchema(schema)).toThrow('Cyclic local $ref at ``'); }); + test('inlines root local ref chains exposed by singleton root allOf', () => { + const schema: JSONSchema = { + allOf: [{ $ref: '#/$defs/A' }], + $defs: { + A: { $ref: '#/$defs/Input' }, + Input: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + }, + }, + }; + + expect(toStrictJsonSchema(schema)).toEqual({ + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + additionalProperties: false, + $defs: { + A: { $ref: '#/$defs/Input' }, + Input: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + additionalProperties: false, + }, + }, + }); + }); + + test('rejects root local ref cycles exposed by singleton root allOf', () => { + const schema: JSONSchema = { + allOf: [{ $ref: '#/$defs/A' }], + $defs: { + A: { $ref: '#/$defs/B' }, + B: { $ref: '#/$defs/A' }, + }, + }; + + expect(() => toStrictJsonSchema(schema)).toThrow('Cyclic local $ref'); + }); + test.each([ ['missing', '#/$defs/Missing', 'does not resolve to an object or boolean schema'], ['external', 'https://example.com/schema.json#/$defs/Input', 'External $ref at ``'], @@ -233,6 +352,34 @@ describe('toStrictJsonSchema', () => { }); }); + test('preserves root dialect and base metadata while merging root allOf branches', () => { + const schema: JSONSchema = { + $schema: 'http://json-schema.org/draft-07/schema#', + $id: 'https://example.com/input.json', + allOf: [ + { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + }, + { + type: 'object', + properties: { age: { type: 'number' } }, + required: ['age'], + }, + ], + }; + + expect(toStrictJsonSchema(schema)).toEqual({ + $schema: 'http://json-schema.org/draft-07/schema#', + $id: 'https://example.com/input.json', + type: 'object', + properties: { name: { type: 'string' }, age: { type: 'number' } }, + required: ['name', 'age'], + additionalProperties: false, + }); + }); + test('still rejects root allOf object branches that cannot be merged exactly', () => { const schema: JSONSchema = { allOf: [ From cd65f48f98d25a20e3c4e65849f150d26c6f0202 Mon Sep 17 00:00:00 2001 From: Hayden Date: Tue, 21 Jul 2026 18:46:22 +0000 Subject: [PATCH 23/35] fix(helpers): preserve strict schema semantics --- src/lib/transform.ts | 57 +++++++++--------- tests/helpers/standard-schema.test.ts | 52 +++++++++++++++++ tests/lib/transform.test.ts | 84 +++++++++++++++++++++++++-- 3 files changed, 160 insertions(+), 33 deletions(-) diff --git a/src/lib/transform.ts b/src/lib/transform.ts index 894d96ec82..c534ef6ecb 100644 --- a/src/lib/transform.ts +++ b/src/lib/transform.ts @@ -59,7 +59,9 @@ const JSON_SCHEMA_UNSUPPORTED_SCHEMA_KEYWORDS = new Set([ 'dependencies', 'else', 'if', + 'maxContains', 'maxProperties', + 'minContains', 'minProperties', 'not', 'patternProperties', @@ -223,21 +225,6 @@ function inlineRootRefObject(schema: JSONSchema): void { const rootDefinitions = schema.$defs; const legacyDefinitions = schema.definitions; - for (const keyword of ['$defs', 'definitions'] as const) { - const rootDefinitionMap = schema[keyword]; - const targetDefinitionMap = resolved[keyword]; - if ( - rootDefinitionMap !== undefined && - targetDefinitionMap !== undefined && - !schemasEqual(rootDefinitionMap, targetDefinitionMap) - ) { - throw new Error( - 'Cannot inline a root local $ref with conflicting ' + - keyword + - ' definition maps without changing local ref resolution.', - ); - } - } const rootMetadata = Object.fromEntries( Object.entries(schema).filter( ([keyword]) => @@ -245,6 +232,15 @@ function inlineRootRefObject(schema: JSONSchema): void { ), ); const inlined = structuredClone(resolved); + // A target's definition map lives below the target's original pointer, not + // at the document root. Keep the outer document map at the root so existing + // absolute refs retain their meaning; the retained outer map also keeps the + // target's nested map reachable at its original pointer. + for (const keyword of ['$defs', 'definitions'] as const) { + if (schema[keyword] !== undefined && inlined[keyword] !== undefined) { + delete inlined[keyword]; + } + } const schemaRecord = schema as Record; for (const keyword of Object.keys(schema)) { @@ -740,17 +736,26 @@ function hasObjectShape(schema: JSONSchema): boolean { } function normalizeObjectUnionWrapper(jsonSchema: JSONSchema, path: string[], root: JSONSchema): void { - if (jsonSchema.anyOf === undefined || !hasObjectShape(jsonSchema)) { + if (jsonSchema.anyOf === undefined) { return; } - const isValidationNeutralEmptyObjectKeyword = (keyword: string): boolean => - (keyword === 'properties' && - isObject(jsonSchema.properties) && - Object.keys(jsonSchema.properties).length === 0) || - (keyword === 'required' && Array.isArray(jsonSchema.required) && jsonSchema.required.length === 0); - const hasOwnObjectConstraints = Object.keys(jsonSchema).some( - (keyword) => JSON_SCHEMA_OBJECT_KEYWORDS.has(keyword) && !isValidationNeutralEmptyObjectKeyword(keyword), + const hasEmptyProperties = + isObject(jsonSchema.properties) && Object.keys(jsonSchema.properties).length === 0; + const hasEmptyRequired = Array.isArray(jsonSchema.required) && jsonSchema.required.length === 0; + if (hasEmptyProperties) { + delete jsonSchema.properties; + } + if (hasEmptyRequired) { + delete jsonSchema.required; + } + + if (!hasObjectShape(jsonSchema)) { + return; + } + + const hasOwnObjectConstraints = Object.keys(jsonSchema).some((keyword) => + JSON_SCHEMA_OBJECT_KEYWORDS.has(keyword), ); if ( jsonSchema.type === 'object' && @@ -758,12 +763,6 @@ function normalizeObjectUnionWrapper(jsonSchema: JSONSchema, path: string[], roo Array.isArray(jsonSchema.anyOf) && jsonSchema.anyOf.every((branch) => isObjectOnlySchema(branch, root)) ) { - if (isValidationNeutralEmptyObjectKeyword('properties')) { - delete jsonSchema.properties; - } - if (isValidationNeutralEmptyObjectKeyword('required')) { - delete jsonSchema.required; - } delete jsonSchema.type; return; } diff --git a/tests/helpers/standard-schema.test.ts b/tests/helpers/standard-schema.test.ts index 98a8f0c3b9..aadbf3f36d 100644 --- a/tests/helpers/standard-schema.test.ts +++ b/tests/helpers/standard-schema.test.ts @@ -455,6 +455,40 @@ describe('Standard Schema helpers', () => { }); }); + it('drops empty object keywords from untyped oneOf wrappers', () => { + const { standardSchema } = makeStandardSchema({ + type: 'object', + properties: { + choice: { + properties: {}, + required: [], + oneOf: [{ type: 'string' }, { type: 'number' }], + }, + }, + required: ['choice'], + }); + + const schema = standardResponseFormat(standardSchema, 'choice').json_schema.schema; + expect((schema as JSONSchema).properties?.['choice']).toEqual({ + anyOf: [{ type: 'string' }, { type: 'number' }], + }); + }); + + it('still rejects non-empty object constraints on untyped oneOf wrappers', () => { + const { standardSchema } = makeStandardSchema({ + type: 'object', + properties: { + choice: { + properties: { kind: { type: 'string' } }, + oneOf: [{ type: 'string' }, { type: 'number' }], + }, + }, + required: ['choice'], + }); + + expect(() => standardResponseFormat(standardSchema, 'choice')).toThrow('Object anyOf schema'); + }); + it('still rejects non-empty object constraints on normalized oneOf wrappers', () => { const { standardSchema } = makeStandardSchema({ type: 'object', @@ -774,6 +808,24 @@ describe('Standard Schema helpers', () => { }, ); + it.each(['minContains', 'maxContains'] as const)( + 'rejects unsupported %s without contains before returning a strict schema', + (keyword) => { + const { standardSchema } = makeStandardSchema({ + type: 'object', + properties: { + values: { + type: 'array', + [keyword]: 1, + }, + }, + required: ['values'], + }); + + expect(() => standardResponseFormat(standardSchema, 'values')).toThrow('uses unsupported keyword'); + }, + ); + it('rejects root anyOf schemas', () => { const { standardSchema } = makeStandardSchema({ type: 'object', diff --git a/tests/lib/transform.test.ts b/tests/lib/transform.test.ts index 8166ac65df..b466344532 100644 --- a/tests/lib/transform.test.ts +++ b/tests/lib/transform.test.ts @@ -213,7 +213,7 @@ describe('toStrictJsonSchema', () => { }); test.each(['$defs', 'definitions'] as const)( - 'rejects conflicting %s maps when inlining a root local ref', + 'preserves nested %s maps at their original pointer scope when inlining a root local ref', (keyword) => { const schema = { $ref: '#/' + keyword + '/Input', @@ -221,13 +221,38 @@ describe('toStrictJsonSchema', () => { Input: { type: 'object', [keyword]: { Nested: { type: 'string' } }, - properties: { name: { type: 'string' } }, - required: ['name'], + properties: { + nested: { $ref: '#/' + keyword + '/Input/' + keyword + '/Nested' }, + outer: { $ref: '#/' + keyword + '/Nested' }, + }, + required: ['nested', 'outer'], }, + Nested: { type: 'number' }, }, } as JSONSchema; - expect(() => toStrictJsonSchema(schema)).toThrow('conflicting ' + keyword + ' definition maps'); + expect(toStrictJsonSchema(schema)).toEqual({ + type: 'object', + properties: { + nested: { $ref: '#/' + keyword + '/Input/' + keyword + '/Nested' }, + outer: { $ref: '#/' + keyword + '/Nested' }, + }, + required: ['nested', 'outer'], + additionalProperties: false, + [keyword]: { + Input: { + type: 'object', + [keyword]: { Nested: { type: 'string' } }, + properties: { + nested: { $ref: '#/' + keyword + '/Input/' + keyword + '/Nested' }, + outer: { $ref: '#/' + keyword + '/Nested' }, + }, + required: ['nested', 'outer'], + additionalProperties: false, + }, + Nested: { type: 'number' }, + }, + }); }, ); @@ -1087,6 +1112,24 @@ describe('toStrictJsonSchema', () => { expect(() => toStrictJsonSchema(schema)).toThrow(`uses unsupported keyword \`${keyword}\``); }); + test.each(['minContains', 'maxContains'] as const)( + 'rejects unsupported %s without contains', + (keyword) => { + const schema = { + type: 'object', + properties: { + values: { + type: 'array', + [keyword]: 1, + }, + }, + required: ['values'], + } as JSONSchema; + + expect(() => toStrictJsonSchema(schema)).toThrow('uses unsupported keyword'); + }, + ); + test.each([ [ 'contains', @@ -1267,6 +1310,39 @@ describe('toStrictJsonSchema', () => { }); }); + test('removes empty object keywords from an untyped anyOf wrapper', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + value: { + properties: {}, + required: [], + anyOf: [{ type: 'string' }, { type: 'number' }], + }, + }, + required: ['value'], + }; + + expect(toStrictJsonSchema(schema).properties?.['value']).toEqual({ + anyOf: [{ type: 'string' }, { type: 'number' }], + }); + }); + + test('rejects non-empty object constraints on untyped anyOf wrappers', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + value: { + properties: { kind: { type: 'string' } }, + anyOf: [{ type: 'string' }, { type: 'number' }], + }, + }, + required: ['value'], + }; + + expect(() => toStrictJsonSchema(schema)).toThrow('Object anyOf schema'); + }); + test('rejects object anyOf wrappers whose own constraints cannot be preserved', () => { const schema: JSONSchema = { type: 'object', From 77d391f2e0c15db1acd07a7d3f5a9065c3dccbb8 Mon Sep 17 00:00:00 2001 From: Hayden Date: Tue, 21 Jul 2026 19:13:34 +0000 Subject: [PATCH 24/35] fix(helpers): handle strict schema ref edge cases --- src/lib/transform.ts | 103 ++++++++++++++++++++++++--- tests/lib/transform.test.ts | 134 ++++++++++++++++++++++++++++++++++++ 2 files changed, 227 insertions(+), 10 deletions(-) diff --git a/src/lib/transform.ts b/src/lib/transform.ts index c534ef6ecb..c7db74cf92 100644 --- a/src/lib/transform.ts +++ b/src/lib/transform.ts @@ -152,6 +152,11 @@ export function toStrictJsonSchema(schema: JSONSchema): JSONSchema { validateRefSchemas(schemaCopy, [], schemaCopy); preserveAllOfRefTargets(schemaCopy); validateRefSchemas(schemaCopy, [], schemaCopy); + // Resolve representable object intersections before recursive + // strictification closes referenced definitions. Otherwise a definition + // reached through an allOf ref would be closed in isolation before its + // sibling object properties can be merged. + normalizeObjectAllOfBranches(schemaCopy, [], schemaCopy); const strictSchema = ensureStrictJsonSchema(schemaCopy, [], schemaCopy); validateRefSchemas(strictSchema, [], strictSchema); return strictSchema; @@ -262,7 +267,7 @@ function inlineRootRefObject(schema: JSONSchema): void { */ function normalizeRootAllOf(schema: JSONSchema): void { while (schema.allOf !== undefined) { - if (mergeObjectAllOf(schema, [])) { + if (mergeObjectAllOf(schema, [], schema)) { return; } @@ -430,7 +435,7 @@ function ensureStrictJsonSchema( // intersection: sibling branches' properties become forbidden extras. Merge // the small object-intersection subset that can be represented exactly before // applying strict object closure, and fail closed for the rest. - if (mergeObjectAllOf(jsonSchema, path)) { + if (mergeObjectAllOf(jsonSchema, path, root)) { return ensureStrictJsonSchema(jsonSchema, path, root); } @@ -532,6 +537,15 @@ function ensureStrictJsonSchema( } } + const type = jsonSchema.type; + if ((type === 'array' || (Array.isArray(type) && type.includes('array'))) && items === undefined) { + throw new Error( + `Schema at \`${ + path.join('/') || '' + }\` declares an array without \`items\`, which cannot be represented in strict Structured Outputs.`, + ); + } + forEachJSONSchemaChild(jsonSchema, path, (child, childPath, keyword) => { // These boolean forms are already handled as parent-keyword semantics: // additionalProperties: false closes objects, while boolean @@ -701,7 +715,13 @@ function isObjectOnlySchema( export function hasOnlyRefAndAnnotations(schema: JSONSchema): boolean { return Object.keys(schema).every( - (keyword) => keyword === '$ref' || JSON_SCHEMA_ANNOTATION_KEYWORDS.has(keyword), + // Definition maps do not add sibling validation constraints, and keeping + // them in place preserves local pointers into a nested alias's scope. + (keyword) => + keyword === '$ref' || + keyword === '$defs' || + keyword === 'definitions' || + JSON_SCHEMA_ANNOTATION_KEYWORDS.has(keyword), ); } @@ -997,16 +1017,72 @@ function validateRefSchemas(schema: JSONSchemaDefinition, path: string[], root: }); } -function mergeObjectAllOf(jsonSchema: JSONSchema, path: string[]): boolean { +type ResolvedObjectAllOfBranch = { + schema: JSONSchema; + refChain: JSONSchema[]; +}; + +/** + * Resolve only local aliases whose siblings carry no validation semantics. + * Keeping this separate from general ref validation lets allOf merging inspect + * the effective object shape without broadening which refs or sibling + * constraints are accepted. + */ +function resolveObjectAllOfBranch( + schema: JSONSchema, + root: JSONSchema, +): ResolvedObjectAllOfBranch | undefined { + const refChain = [schema]; + const seenRefs = new Set(); + let resolved = schema; + + while (resolved.$ref !== undefined) { + const ref = resolved.$ref; + if (typeof ref !== 'string' || !hasOnlyRefAndAnnotations(resolved) || seenRefs.has(ref)) { + return undefined; + } + seenRefs.add(ref); + + const target = resolveLocalRef(root, ref); + if (typeof target === 'boolean' || !isObject(target)) { + return undefined; + } + + resolved = target; + refChain.push(resolved); + } + + return { schema: resolved, refChain }; +} + +function normalizeObjectAllOfBranches(schema: JSONSchemaDefinition, path: string[], root: JSONSchema): void { + if (typeof schema === 'boolean' || !isObject(schema)) { + return; + } + + if (mergeObjectAllOf(schema, path, root)) { + normalizeObjectAllOfBranches(schema, path, root); + return; + } + + forEachJSONSchemaChild(schema, path, (child, childPath) => { + normalizeObjectAllOfBranches(child as JSONSchemaDefinition, childPath, root); + }); +} + +function mergeObjectAllOf(jsonSchema: JSONSchema, path: string[], root: JSONSchema): boolean { const allOf = jsonSchema.allOf; if (!Array.isArray(allOf) || allOf.length === 0) { return false; } const parentHasObjectShape = hasObjectShapeWithoutAllOf(jsonSchema); - const objectBranches = allOf.filter( - (entry): entry is JSONSchema => isObject(entry) && hasObjectShapeWithoutAllOf(entry), + const resolvedEntries = allOf.map((entry) => + isObject(entry) ? resolveObjectAllOfBranch(entry, root) : undefined, ); + const objectBranches = resolvedEntries + .map((entry) => entry?.schema) + .filter((entry): entry is JSONSchema => entry !== undefined && hasObjectShapeWithoutAllOf(entry)); if (!parentHasObjectShape && objectBranches.length === 0) { return false; } @@ -1047,11 +1123,15 @@ function mergeObjectAllOf(jsonSchema: JSONSchema, path: string[]): boolean { if (parentHasObjectShape) { branches.push(jsonSchema); } - for (const entry of allOf) { + for (const [index, entry] of allOf.entries()) { if (!isObject(entry)) { fail(); } - const branch = entry as JSONSchema; + const resolvedEntry = resolvedEntries[index]; + if (resolvedEntry === undefined) { + return fail(); + } + const branch = resolvedEntry.schema; if (hasObjectShapeWithoutAllOf(branch)) { branches.push(branch); } else if (!Object.keys(branch).every((keyword) => JSON_SCHEMA_ANNOTATION_KEYWORDS.has(keyword))) { @@ -1091,8 +1171,11 @@ function mergeObjectAllOf(jsonSchema: JSONSchema, path: string[]): boolean { }; mergeAnnotations(jsonSchema); - for (const entry of allOf) { - if (isObject(entry)) mergeAnnotations(entry); + for (const resolvedEntry of resolvedEntries) { + if (resolvedEntry === undefined) continue; + for (const entry of resolvedEntry.refChain) { + mergeAnnotations(entry); + } } for (const branch of branches) { diff --git a/tests/lib/transform.test.ts b/tests/lib/transform.test.ts index b466344532..0b1f87e83c 100644 --- a/tests/lib/transform.test.ts +++ b/tests/lib/transform.test.ts @@ -838,6 +838,32 @@ describe('toStrictJsonSchema', () => { }); }); + test.each(['$defs', 'definitions'] as const)( + 'retains nested %s maps beside local refs at their original pointer scope', + (keyword) => { + const ref = '#/properties/value/' + keyword + '/Value'; + const schema: JSONSchema = { + type: 'object', + properties: { + value: { + $ref: ref, + [keyword]: { + Value: { type: 'string' }, + }, + }, + }, + required: ['value'], + }; + + const strictValue = toStrictJsonSchema(schema).properties?.['value'] as JSONSchema; + + expect(strictValue.$ref).toBe(ref); + expect(strictValue[keyword]).toEqual({ + Value: { type: 'string' }, + }); + }, + ); + test('conservatively rejects cyclic refs when checking nullable optional properties', () => { const schema: JSONSchema = { type: 'object', @@ -1004,6 +1030,50 @@ describe('toStrictJsonSchema', () => { `); }); + test('rejects array schemas without items before returning a strict schema', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + tags: { + type: 'array', + }, + }, + required: ['tags'], + }; + + expect(() => toStrictJsonSchema(schema)).toThrow('declares an array without `items`'); + }); + + test('rejects union-typed array schemas without items before returning a strict schema', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + tags: { + type: ['array', 'null'], + }, + }, + required: ['tags'], + }; + + expect(() => toStrictJsonSchema(schema)).toThrow('declares an array without `items`'); + }); + + test('does not require items for schemas that do not declare arrays', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + label: { + type: ['string', 'null'], + }, + }, + required: ['label'], + }; + + expect(toStrictJsonSchema(schema).properties?.['label']).toEqual({ + type: ['string', 'null'], + }); + }); + test('rejects tuple-form items before returning a strict schema', () => { const schema: JSONSchema = { type: 'object', @@ -1544,6 +1614,70 @@ describe('toStrictJsonSchema', () => { }); }); + test('merges compatible object allOf variants through local ref chains', () => { + const schema: JSONSchema = { + type: 'object', + $defs: { + Name: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + }, + NameAlias: { + $ref: '#/$defs/Name', + description: 'Name fields', + }, + Age: { + type: 'object', + properties: { age: { type: 'number' } }, + required: ['age'], + }, + }, + properties: { + value: { + type: 'object', + allOf: [{ $ref: '#/$defs/NameAlias', $comment: 'Generated alias' }, { $ref: '#/$defs/Age' }], + }, + }, + required: ['value'], + }; + + expect(toStrictJsonSchema(schema).properties?.['value']).toEqual({ + type: 'object', + $comment: 'Generated alias', + description: 'Name fields', + properties: { name: { type: 'string' }, age: { type: 'number' } }, + required: ['name', 'age'], + additionalProperties: false, + }); + }); + + test('fails closed on cyclic local ref chains in object allOf variants', () => { + const schema: JSONSchema = { + type: 'object', + $defs: { + A: { $ref: '#/$defs/B' }, + B: { $ref: '#/$defs/A' }, + Age: { + type: 'object', + properties: { age: { type: 'number' } }, + required: ['age'], + }, + }, + properties: { + value: { + type: 'object', + allOf: [{ $ref: '#/$defs/A' }, { $ref: '#/$defs/Age' }], + }, + }, + required: ['value'], + }; + + expect(() => toStrictJsonSchema(schema)).toThrow( + 'cannot be merged without changing Draft 7 validation', + ); + }); + test('merges object allOf properties that match Object prototype names', () => { const specialProperties = Object.fromEntries([ ['constructor', { type: 'string' }], From 5b89db26565ed136a3663d0ae678768de78ef8fc Mon Sep 17 00:00:00 2001 From: Hayden Date: Tue, 21 Jul 2026 19:34:50 +0000 Subject: [PATCH 25/35] fix(helpers): handle strict schema normalization edges --- src/helpers/standard-schema.ts | 12 +-- src/lib/transform.ts | 106 ++++++++++++++++++--- tests/helpers/standard-schema.test.ts | 49 ++++++++++ tests/lib/transform.test.ts | 129 +++++++++++++++++++++++++- 4 files changed, 276 insertions(+), 20 deletions(-) diff --git a/src/helpers/standard-schema.ts b/src/helpers/standard-schema.ts index b6a8100760..b274d8f436 100644 --- a/src/helpers/standard-schema.ts +++ b/src/helpers/standard-schema.ts @@ -188,7 +188,7 @@ function isObjectOnlySchema(schema: unknown): boolean { return types?.size === 1 && types.has('object'); } -function haveDisjointObjectDiscriminator(left: unknown, right: unknown): boolean { +function haveDisjointObjectDiscriminator(left: unknown, right: unknown, root: JSONSchema): boolean { if (!isObjectOnlySchema(left) || !isObjectOnlySchema(right)) return false; const leftRecord = left as Record; @@ -215,8 +215,8 @@ function haveDisjointObjectDiscriminator(left: unknown, right: unknown): boolean typeof property === 'string' && rightRequired.includes(property) && haveDisjointLiteralValues( - (leftProperties as Record)[property], - (rightProperties as Record)[property], + resolveLocalRefForExclusivity((leftProperties as Record)[property], root), + resolveLocalRefForExclusivity((rightProperties as Record)[property], root), ) ) { return true; @@ -226,7 +226,7 @@ function haveDisjointObjectDiscriminator(left: unknown, right: unknown): boolean return false; } -function areMutuallyExclusive(left: unknown, right: unknown): boolean { +function areMutuallyExclusive(left: unknown, right: unknown, root: JSONSchema): boolean { const leftTypes = getSchemaTypes(left); const rightTypes = getSchemaTypes(right); if ( @@ -239,7 +239,7 @@ function areMutuallyExclusive(left: unknown, right: unknown): boolean { return true; } - return haveDisjointLiteralValues(left, right) || haveDisjointObjectDiscriminator(left, right); + return haveDisjointLiteralValues(left, right) || haveDisjointObjectDiscriminator(left, right, root); } function resolveLocalRefForExclusivity( @@ -272,7 +272,7 @@ function areOneOfBranchesMutuallyExclusive(branches: unknown[], root: JSONSchema for (let otherIndex = index + 1; otherIndex < branches.length; otherIndex++) { const left = resolveLocalRefForExclusivity(branches[index], root); const right = resolveLocalRefForExclusivity(branches[otherIndex], root); - if (left === undefined || right === undefined || !areMutuallyExclusive(left, right)) { + if (left === undefined || right === undefined || !areMutuallyExclusive(left, right, root)) { return false; } } diff --git a/src/lib/transform.ts b/src/lib/transform.ts index c7db74cf92..e646bbfd31 100644 --- a/src/lib/transform.ts +++ b/src/lib/transform.ts @@ -128,12 +128,7 @@ export function forEachJSONSchemaChild( export function toStrictJsonSchema(schema: JSONSchema): JSONSchema { const schemaCopy = structuredClone(schema); normalizeSingletonTypeArrays(schemaCopy); - inlineRootRefObject(schemaCopy); - preserveAllOfRefTargets(schemaCopy, true); - normalizeRootAllOf(schemaCopy); - // A singleton root allOf can flatten to a local ref. Run the same root - // inliner again so its chain and cycle checks apply before root validation. - inlineRootRefObject(schemaCopy); + normalizeRootRefAndAllOf(schemaCopy); if (schemaCopy.type !== 'object') { throw new Error( @@ -162,6 +157,32 @@ export function toStrictJsonSchema(schema: JSONSchema): JSONSchema { return strictSchema; } +/** + * Root ref inlining and singleton allOf flattening can expose each other. + * Iterate until flattening no longer produces another root ref so every + * exactly representable chain reaches its final object form before the root + * type check runs. + */ +function normalizeRootRefAndAllOf(schema: JSONSchema): void { + const seenRefs = new Set(); + while (true) { + if (typeof schema.$ref === 'string') { + if (seenRefs.has(schema.$ref)) { + throw new Error('Cyclic local $ref at `` is not supported: ' + JSON.stringify(schema.$ref)); + } + seenRefs.add(schema.$ref); + } + + inlineRootRefObject(schema); + preserveAllOfRefTargets(schema, true); + normalizeRootAllOf(schema); + + if (schema.$ref === undefined) { + return; + } + } +} + /** * Some Standard Schema converters emit the root object through a local ref, * with the referenced schema stored in a root definition map. Structured @@ -519,14 +540,30 @@ function ensureStrictJsonSchema( const allOf = jsonSchema.allOf; if (Array.isArray(allOf)) { if (allOf.length === 1 && hasOnlyAnnotationSiblings(jsonSchema, 'allOf')) { - const resolved = ensureStrictJsonSchema(allOf[0]!, [...path, 'allOf', '0'], root); - const annotations = { ...jsonSchema }; - delete annotations.allOf; - Object.assign(jsonSchema, resolved, annotations); - delete jsonSchema.allOf; + const branch = allOf[0]!; + if (branch === false) { + throw new Error( + `Schema at \`${ + path.join('/') || '' + }\` uses \`allOf: [false]\`, which cannot be represented in strict Structured Outputs.`, + ); + } + if (branch === true) { + // true is the neutral schema for an intersection, so removing this + // branch preserves validation while retaining the parent annotations. + delete jsonSchema.allOf; + } else { + const resolved = ensureStrictJsonSchema(branch, [...path, 'allOf', '0'], root); + const annotations = { ...jsonSchema }; + delete annotations.allOf; + Object.assign(jsonSchema, resolved, annotations); + delete jsonSchema.allOf; + } } } + normalizeArrayUnionWrapper(jsonSchema, root); + for (const keyword of JSON_SCHEMA_UNSUPPORTED_SCHEMA_KEYWORDS) { if (keyword in jsonSchema) { throw new Error( @@ -538,7 +575,8 @@ function ensureStrictJsonSchema( } const type = jsonSchema.type; - if ((type === 'array' || (Array.isArray(type) && type.includes('array'))) && items === undefined) { + const currentItems = jsonSchema.items; + if ((type === 'array' || (Array.isArray(type) && type.includes('array'))) && currentItems === undefined) { throw new Error( `Schema at \`${ path.join('/') || '' @@ -713,6 +751,34 @@ function isObjectOnlySchema( ); } +function isArrayOnlySchema( + schema: JSONSchemaDefinition, + root: JSONSchema, + seenRefs: Set = new Set(), +): boolean { + if (typeof schema === 'boolean' || !isObject(schema)) { + return false; + } + + if (schema.$ref !== undefined) { + if (typeof schema.$ref !== 'string' || !hasOnlyRefAndAnnotations(schema) || seenRefs.has(schema.$ref)) { + return false; + } + + const resolved = resolveLocalRef(root, schema.$ref); + if (resolved === undefined) { + return false; + } + + return isArrayOnlySchema(resolved, root, new Set([...seenRefs, schema.$ref])); + } + + return ( + schema.type === 'array' || + (Array.isArray(schema.type) && schema.type.length === 1 && schema.type[0] === 'array') + ); +} + export function hasOnlyRefAndAnnotations(schema: JSONSchema): boolean { return Object.keys(schema).every( // Definition maps do not add sibling validation constraints, and keeping @@ -794,13 +860,27 @@ function normalizeObjectUnionWrapper(jsonSchema: JSONSchema, path: string[], roo ); } +function normalizeArrayUnionWrapper(jsonSchema: JSONSchema, root: JSONSchema): void { + if ( + jsonSchema.type === 'array' && + jsonSchema.items === undefined && + Array.isArray(jsonSchema.anyOf) && + jsonSchema.anyOf.every((branch) => isArrayOnlySchema(branch, root)) + ) { + // Every union branch already proves the value is an array. Keeping the + // redundant wrapper would require an outer items schema that does not + // contribute any Draft 7 validation. + delete jsonSchema.type; + } +} + export function assertNoNestedSchemaIds(schema: JSONSchema): void { const visit = (value: JSONSchemaDefinition, path: string[]): void => { if (typeof value === 'boolean' || !isObject(value)) { return; } - if (path.length > 0 && '$id' in value) { + if (path.length > 0 && value.$id !== undefined) { throw new Error( 'Nested $id at ' + JSON.stringify(path.join('/')) + diff --git a/tests/helpers/standard-schema.test.ts b/tests/helpers/standard-schema.test.ts index aadbf3f36d..cb20fa0193 100644 --- a/tests/helpers/standard-schema.test.ts +++ b/tests/helpers/standard-schema.test.ts @@ -411,6 +411,55 @@ describe('Standard Schema helpers', () => { }); }); + it('resolves discriminator property ref chains when proving oneOf exclusivity', () => { + const { standardSchema } = makeStandardSchema({ + type: 'object', + $defs: { + fooKind: { $ref: '#/$defs/fooLiteral', description: 'Foo discriminator' }, + fooLiteral: { type: 'string', const: 'foo' }, + barKind: { $ref: '#/$defs/barLiteral', description: 'Bar discriminator' }, + barLiteral: { type: 'string', const: 'bar' }, + }, + properties: { + choice: { + oneOf: [ + { + type: 'object', + properties: { + kind: { $ref: '#/$defs/fooKind', $comment: 'Generated foo discriminator' }, + foo: { type: 'string' }, + }, + required: ['kind', 'foo'], + }, + { + type: 'object', + properties: { + kind: { $ref: '#/$defs/barKind', title: 'Bar discriminator' }, + bar: { type: 'number' }, + }, + required: ['kind', 'bar'], + }, + ], + }, + }, + required: ['choice'], + }); + + const schema = standardResponseFormat(standardSchema, 'choice').json_schema.schema; + + expect(schema).toMatchObject({ + properties: { + choice: { + anyOf: [ + { properties: { kind: { $ref: '#/$defs/fooKind' } } }, + { properties: { kind: { $ref: '#/$defs/barKind' } } }, + ], + }, + }, + }); + expect(JSON.stringify(schema)).not.toContain('"oneOf"'); + }); + it('drops validation-neutral empty object keywords when normalizing oneOf wrappers', () => { const { standardSchema } = makeStandardSchema({ type: 'object', diff --git a/tests/lib/transform.test.ts b/tests/lib/transform.test.ts index 0b1f87e83c..802053ae4a 100644 --- a/tests/lib/transform.test.ts +++ b/tests/lib/transform.test.ts @@ -298,6 +298,41 @@ describe('toStrictJsonSchema', () => { }); }); + test('normalizes root allOf branches exposed by local ref inlining', () => { + const schema: JSONSchema = { + allOf: [{ $ref: '#/$defs/A' }], + $defs: { + A: { + allOf: [ + { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + }, + ], + }, + }, + }; + + expect(toStrictJsonSchema(schema)).toMatchObject({ + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + additionalProperties: false, + }); + }); + + test('rejects alternating root ref and allOf cycles', () => { + const schema: JSONSchema = { + allOf: [{ $ref: '#/$defs/A' }], + $defs: { + A: { allOf: [{ $ref: '#/$defs/A' }] }, + }, + }; + + expect(() => toStrictJsonSchema(schema)).toThrow('Cyclic local $ref'); + }); + test('rejects root local ref cycles exposed by singleton root allOf', () => { const schema: JSONSchema = { allOf: [{ $ref: '#/$defs/A' }], @@ -1338,6 +1373,29 @@ describe('toStrictJsonSchema', () => { `); }); + test('removes a redundant array type from an anyOf wrapper', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + value: { + type: 'array', + anyOf: [ + { type: 'array', items: { type: 'string' } }, + { type: 'array', items: { type: 'number' } }, + ], + }, + }, + required: ['value'], + }; + + expect(toStrictJsonSchema(schema).properties?.['value']).toEqual({ + anyOf: [ + { type: 'array', items: { type: 'string' } }, + { type: 'array', items: { type: 'number' } }, + ], + }); + }); + test('removes a redundant object type from an anyOf wrapper before closing branches', () => { const schema: JSONSchema = { type: 'object', @@ -1521,6 +1579,63 @@ describe('toStrictJsonSchema', () => { }); }); + test('removes a true singleton allOf branch without dropping annotations', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + value: { + description: 'Any value', + allOf: [true], + }, + }, + required: ['value'], + }; + + expect(toStrictJsonSchema(schema)).toEqual({ + type: 'object', + properties: { + value: { + description: 'Any value', + }, + }, + required: ['value'], + additionalProperties: false, + }); + }); + + test('rejects a false singleton allOf branch', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + value: { + allOf: [false], + }, + }, + required: ['value'], + }; + + expect(() => toStrictJsonSchema(schema)).toThrow( + 'uses `allOf: [false]`, which cannot be represented in strict Structured Outputs', + ); + }); + + test('uses array items introduced by a singleton allOf branch', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + values: { + allOf: [{ type: 'array', items: { type: 'string' } }], + }, + }, + required: ['values'], + }; + + expect(toStrictJsonSchema(schema).properties?.['values']).toEqual({ + type: 'array', + items: { type: 'string' }, + }); + }); + test('preserves refs into allOf branches before flattening', () => { const schema: JSONSchema = { type: 'object', @@ -1869,7 +1984,7 @@ describe('toStrictJsonSchema', () => { } }); - test('allows a root $id but rejects nested resource scopes', () => { + test('allows a root $id and undefined nested $id values but rejects defined nested resource scopes', () => { const rootIdSchema: JSONSchema = { $id: 'https://example.com/root.json', type: 'object', @@ -1880,6 +1995,18 @@ describe('toStrictJsonSchema', () => { $id: 'https://example.com/root.json', }); + const undefinedNestedIdSchema: JSONSchema = { + type: 'object', + properties: { + value: { + $id: undefined, + type: 'string', + }, + }, + required: ['value'], + }; + expect(() => toStrictJsonSchema(undefinedNestedIdSchema)).not.toThrow(); + const nestedIdSchema: JSONSchema = { type: 'object', properties: { From ed441884411245e73a962db6f27127ed3cf97ea2 Mon Sep 17 00:00:00 2001 From: Hayden Date: Tue, 21 Jul 2026 20:06:01 +0000 Subject: [PATCH 26/35] fix(helpers): preserve strict schema scopes --- src/lib/transform.ts | 23 ++++++- tests/lib/transform.test.ts | 124 ++++++++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 3 deletions(-) diff --git a/src/lib/transform.ts b/src/lib/transform.ts index e646bbfd31..365304fc38 100644 --- a/src/lib/transform.ts +++ b/src/lib/transform.ts @@ -128,6 +128,10 @@ export function forEachJSONSchemaChild( export function toStrictJsonSchema(schema: JSONSchema): JSONSchema { const schemaCopy = structuredClone(schema); normalizeSingletonTypeArrays(schemaCopy); + // Root ref/allOf normalization can promote a nested branch into the root. + // Reject separate resource scopes while their original nesting is still + // visible so branch-local definitions cannot be rebound at the root. + assertNoNestedSchemaIds(schemaCopy); normalizeRootRefAndAllOf(schemaCopy); if (schemaCopy.type !== 'object') { @@ -143,7 +147,6 @@ export function toStrictJsonSchema(schema: JSONSchema): JSONSchema { ); } - assertNoNestedSchemaIds(schemaCopy); validateRefSchemas(schemaCopy, [], schemaCopy); preserveAllOfRefTargets(schemaCopy); validateRefSchemas(schemaCopy, [], schemaCopy); @@ -564,14 +567,20 @@ function ensureStrictJsonSchema( normalizeArrayUnionWrapper(jsonSchema, root); + const schemaRecord = jsonSchema as Record; for (const keyword of JSON_SCHEMA_UNSUPPORTED_SCHEMA_KEYWORDS) { - if (keyword in jsonSchema) { + // Optional converter output often keeps undefined placeholders on the + // JavaScript object even though JSON serialization omits them. They carry + // no validation semantics, so treat only undefined as absent; every + // defined value remains unsupported. + if (schemaRecord[keyword] !== undefined) { throw new Error( `Schema at \`${ path.join('/') || '' }\` uses unsupported keyword \`${keyword}\` and cannot be represented in strict Structured Outputs.`, ); } + delete schemaRecord[keyword]; } const type = jsonSchema.type; @@ -792,8 +801,16 @@ export function hasOnlyRefAndAnnotations(schema: JSONSchema): boolean { } function hasOnlyAnnotationSiblings(schema: JSONSchema, keyword: string): boolean { + const schemaRecord = schema as Record; return Object.keys(schema).every( - (schemaKeyword) => schemaKeyword === keyword || JSON_SCHEMA_ANNOTATION_KEYWORDS.has(schemaKeyword), + // Definition maps do not add sibling validation constraints. Keep them + // beside a flattened singleton allOf so refs into this nested scope stay + // reachable at their original pointers. + (schemaKeyword) => + schemaKeyword === keyword || + ((schemaKeyword === '$defs' || schemaKeyword === 'definitions') && + isObject(schemaRecord[schemaKeyword])) || + JSON_SCHEMA_ANNOTATION_KEYWORDS.has(schemaKeyword), ); } diff --git a/tests/lib/transform.test.ts b/tests/lib/transform.test.ts index 802053ae4a..1bb50d5a15 100644 --- a/tests/lib/transform.test.ts +++ b/tests/lib/transform.test.ts @@ -388,6 +388,29 @@ describe('toStrictJsonSchema', () => { }); }); + test('rejects nested IDs before root allOf normalization can rebind branch definitions', () => { + const schema: JSONSchema = { + $defs: { + Value: { type: 'string' }, + }, + allOf: [ + { + $id: 'branch.json', + type: 'object', + $defs: { + Value: { type: 'number' }, + }, + properties: { + value: { $ref: '#/$defs/Value' }, + }, + required: ['value'], + }, + ], + }; + + expect(() => toStrictJsonSchema(schema)).toThrow('establishes a separate JSON Schema resource scope'); + }); + test('merges compatible root allOf object branches before validating the type', () => { const schema: JSONSchema = { allOf: [ @@ -1182,6 +1205,64 @@ describe('toStrictJsonSchema', () => { expect(() => toStrictJsonSchema(schema)).toThrow('uses unsupported keyword `uniqueItems`'); }); + const unsupportedKeywords = [ + 'allOf', + 'contains', + 'contentEncoding', + 'contentMediaType', + 'contentSchema', + 'dependentRequired', + 'dependentSchemas', + 'dependencies', + 'else', + 'if', + 'maxContains', + 'maxProperties', + 'minContains', + 'minProperties', + 'not', + 'patternProperties', + 'prefixItems', + 'propertyNames', + 'then', + 'unevaluatedItems', + 'unevaluatedProperties', + 'uniqueItems', + ] as const; + + test.each(unsupportedKeywords)('omits undefined unsupported %s placeholders', (keyword) => { + const schema = { + type: 'object', + properties: { + value: { + type: 'string', + [keyword]: undefined, + }, + }, + required: ['value'], + } as JSONSchema; + + const strict = toStrictJsonSchema(schema); + + expect(strict.properties?.['value']).toEqual({ type: 'string' }); + expect(JSON.stringify(strict)).not.toContain(`"${keyword}"`); + }); + + test.each(unsupportedKeywords)('still rejects defined unsupported %s placeholders', (keyword) => { + const schema = { + type: 'object', + properties: { + value: { + type: 'string', + [keyword]: false, + }, + }, + required: ['value'], + } as JSONSchema; + + expect(() => toStrictJsonSchema(schema)).toThrow(`uses unsupported keyword \`${keyword}\``); + }); + test.each([ ['contentEncoding', 'base64'], ['contentMediaType', 'application/json'], @@ -1579,6 +1660,49 @@ describe('toStrictJsonSchema', () => { }); }); + test.each(['$defs', 'definitions'] as const)( + 'inlines a singleton allOf while preserving scoped %s maps', + (keyword) => { + const schema = { + type: 'object', + properties: { + value: { + [keyword]: { + Value: { type: 'string' }, + }, + allOf: [{ $ref: `#/properties/value/${keyword}/Value` }], + }, + }, + required: ['value'], + } as JSONSchema; + + expect(toStrictJsonSchema(schema).properties?.['value']).toEqual({ + $ref: `#/properties/value/${keyword}/Value`, + [keyword]: { + Value: { type: 'string' }, + }, + }); + }, + ); + + test.each(['$defs', 'definitions'] as const)( + 'does not flatten a singleton allOf with malformed %s siblings', + (keyword) => { + const schema = { + type: 'object', + properties: { + value: { + [keyword]: false, + allOf: [{ type: 'string' }], + }, + }, + required: ['value'], + } as unknown as JSONSchema; + + expect(() => toStrictJsonSchema(schema)).toThrow('uses unsupported keyword `allOf`'); + }, + ); + test('removes a true singleton allOf branch without dropping annotations', () => { const schema: JSONSchema = { type: 'object', From 60486eefdd5d162700669d7f236c8dc2ecaf99db Mon Sep 17 00:00:00 2001 From: Hayden Date: Tue, 21 Jul 2026 20:28:56 +0000 Subject: [PATCH 27/35] fix(helpers): handle strict schema follow-ups --- src/helpers/standard-schema.ts | 28 ++++++--- src/lib/transform.ts | 71 +++++++++++++++++++-- tests/helpers/standard-schema.test.ts | 61 ++++++++++++++++++ tests/lib/transform.test.ts | 90 +++++++++++++++++++++++++++ 4 files changed, 235 insertions(+), 15 deletions(-) diff --git a/src/helpers/standard-schema.ts b/src/helpers/standard-schema.ts index b274d8f436..8035f60257 100644 --- a/src/helpers/standard-schema.ts +++ b/src/helpers/standard-schema.ts @@ -13,6 +13,7 @@ import { assertNoNestedSchemaIds, forEachJSONSchemaChild, hasOnlyRefAndAnnotations, + normalizeObjectAllOfForExclusivity, resolveLocalRef, rewriteLocalRefsIntoMovedOneOfBranches, toStrictJsonSchema, @@ -251,20 +252,27 @@ function resolveLocalRefForExclusivity( const record = schema as Record; const ref = record['$ref']; - if (ref === undefined) return schema; + if (ref !== undefined) { + // Annotation keywords do not affect Draft 7 validation, so they are safe + // to retain while proving the referenced branches are mutually exclusive. + // Keep the proof conservative for every other sibling constraint. + if (typeof ref !== 'string' || !hasOnlyRefAndAnnotations(record as JSONSchema)) { + return undefined; + } + if (seenRefs.has(ref)) return undefined; - // Annotation keywords do not affect Draft 7 validation, so they are safe to - // retain while proving the referenced branches are mutually exclusive. - // Keep the proof conservative for every other sibling constraint. - if (typeof ref !== 'string' || !hasOnlyRefAndAnnotations(record as JSONSchema)) { - return undefined; + const resolved = resolveLocalRef(root, ref); + if (resolved === undefined) return undefined; + + return resolveLocalRefForExclusivity(resolved, root, new Set([...seenRefs, ref])); } - if (seenRefs.has(ref)) return undefined; - const resolved = resolveLocalRef(root, ref); - if (resolved === undefined) return undefined; + if (record['allOf'] !== undefined) { + if (!Array.isArray(record['allOf'])) return undefined; + return normalizeObjectAllOfForExclusivity(record as JSONSchema, root); + } - return resolveLocalRefForExclusivity(resolved, root, new Set([...seenRefs, ref])); + return schema; } function areOneOfBranchesMutuallyExclusive(branches: unknown[], root: JSONSchema): boolean { diff --git a/src/lib/transform.ts b/src/lib/transform.ts index 365304fc38..a6565f6165 100644 --- a/src/lib/transform.ts +++ b/src/lib/transform.ts @@ -127,6 +127,10 @@ export function forEachJSONSchemaChild( export function toStrictJsonSchema(schema: JSONSchema): JSONSchema { const schemaCopy = structuredClone(schema); + // JSON serialization omits undefined object properties. Drop optional + // placeholders before any structural checks so they cannot accidentally + // look like validation-bearing schema keywords. + stripUndefinedSchemaKeywords(schemaCopy); normalizeSingletonTypeArrays(schemaCopy); // Root ref/allOf normalization can promote a nested branch into the root. // Reject separate resource scopes while their original nesting is still @@ -160,6 +164,27 @@ export function toStrictJsonSchema(schema: JSONSchema): JSONSchema { return strictSchema; } +function stripUndefinedSchemaKeywords( + schema: JSONSchemaDefinition, + visited: Set = new Set(), +): void { + if (typeof schema === 'boolean' || !isObject(schema) || visited.has(schema)) { + return; + } + visited.add(schema); + + const schemaRecord = schema as Record; + for (const keyword of Object.keys(schemaRecord)) { + if (schemaRecord[keyword] === undefined) { + delete schemaRecord[keyword]; + } + } + + forEachJSONSchemaChild(schema, [], (child) => { + stripUndefinedSchemaKeywords(child as JSONSchemaDefinition, visited); + }); +} + /** * Root ref inlining and singleton allOf flattening can expose each other. * Iterate until flattening no longer produces another root ref so every @@ -1167,6 +1192,23 @@ function normalizeObjectAllOfBranches(schema: JSONSchemaDefinition, path: string }); } +/** + * Standard Schema needs to prove oneOf branches exclusive before it rewrites + * them to anyOf. Inspect a cloned branch through the same conservative object + * allOf merger without mutating the caller's schema or broadening failures. + */ +export function normalizeObjectAllOfForExclusivity( + schema: JSONSchema, + root: JSONSchema, +): JSONSchema | undefined { + const normalized = structuredClone(schema); + try { + return mergeObjectAllOf(normalized, [], root) ? normalized : undefined; + } catch { + return undefined; + } +} + function mergeObjectAllOf(jsonSchema: JSONSchema, path: string[], root: JSONSchema): boolean { const allOf = jsonSchema.allOf; if (!Array.isArray(allOf) || allOf.length === 0) { @@ -1256,6 +1298,7 @@ function mergeObjectAllOf(jsonSchema: JSONSchema, path: string[], root: JSONSche let sawProperties = false; let sawRequired = false; let hasExplicitObjectType = false; + let hasExplicitNullableObjectType = false; const mergeAnnotations = (schema: JSONSchema) => { for (const keyword of JSON_SCHEMA_ANNOTATION_KEYWORDS) { @@ -1278,7 +1321,12 @@ function mergeObjectAllOf(jsonSchema: JSONSchema, path: string[], root: JSONSche for (const branch of branches) { for (const keyword of Object.keys(branch)) { if (keyword === 'allOf' && branch === jsonSchema) continue; - if ((keyword === '$defs' || keyword === 'definitions') && branch === jsonSchema) continue; + if ( + (keyword === '$defs' || keyword === 'definitions') && + isObject((branch as Record)[keyword]) + ) { + continue; + } if (branch === jsonSchema && path.length === 0 && JSON_SCHEMA_ROOT_METADATA_KEYWORDS.has(keyword)) { continue; } @@ -1288,10 +1336,14 @@ function mergeObjectAllOf(jsonSchema: JSONSchema, path: string[], root: JSONSche } if (branch.type !== undefined) { - if (branch.type !== 'object') { + if (!isMergeableObjectType(branch.type)) { fail(); } - hasExplicitObjectType = true; + if (branch.type === 'object') { + hasExplicitObjectType = true; + } else { + hasExplicitNullableObjectType = true; + } } if (branch.properties !== undefined) { @@ -1331,7 +1383,9 @@ function mergeObjectAllOf(jsonSchema: JSONSchema, path: string[], root: JSONSche fail(); } - if (hasExplicitObjectType) merged.type = 'object'; + if (hasExplicitObjectType || hasExplicitNullableObjectType) { + merged.type = hasExplicitObjectType ? 'object' : ['object', 'null']; + } if (sawProperties) merged.properties = Object.fromEntries(Object.entries(mergedProperties)); if (sawRequired) merged.required = [...mergedRequired]; if (closedPropertySets.length > 0) merged.additionalProperties = false; @@ -1345,11 +1399,18 @@ function mergeObjectAllOf(jsonSchema: JSONSchema, path: string[], root: JSONSche function hasObjectShapeWithoutAllOf(schema: JSONSchema): boolean { if (schema.type !== undefined) { - return schema.type === 'object'; + return isMergeableObjectType(schema.type); } return Object.keys(schema).some((keyword) => JSON_SCHEMA_OBJECT_KEYWORDS.has(keyword)); } +function isMergeableObjectType(type: JSONSchema['type']): boolean { + return ( + type === 'object' || + (Array.isArray(type) && type.length === 2 && type.includes('object') && type.includes('null')) + ); +} + function schemasEqual(left: unknown, right: unknown): boolean { if (left === right) { return true; diff --git a/tests/helpers/standard-schema.test.ts b/tests/helpers/standard-schema.test.ts index cb20fa0193..9e85b8a86a 100644 --- a/tests/helpers/standard-schema.test.ts +++ b/tests/helpers/standard-schema.test.ts @@ -460,6 +460,67 @@ describe('Standard Schema helpers', () => { expect(JSON.stringify(schema)).not.toContain('"oneOf"'); }); + it('inspects mergeable allOf branches when proving oneOf exclusivity', () => { + const { standardSchema } = makeStandardSchema({ + type: 'object', + properties: { + choice: { + oneOf: [ + { + allOf: [ + { + type: 'object', + properties: { foo: { type: 'string' } }, + required: ['foo'], + }, + { + type: 'object', + properties: { kind: { type: 'string', const: 'foo' } }, + required: ['kind'], + }, + ], + }, + { + allOf: [ + { + type: 'object', + properties: { bar: { type: 'number' } }, + required: ['bar'], + }, + { + type: 'object', + properties: { kind: { type: 'string', const: 'bar' } }, + required: ['kind'], + }, + ], + }, + ], + }, + }, + required: ['choice'], + }); + + const schema = standardResponseFormat(standardSchema, 'choice').json_schema.schema; + + expect(schema).toMatchObject({ + properties: { + choice: { + anyOf: [ + { + type: 'object', + properties: { kind: { type: 'string', const: 'foo' }, foo: { type: 'string' } }, + }, + { + type: 'object', + properties: { kind: { type: 'string', const: 'bar' }, bar: { type: 'number' } }, + }, + ], + }, + }, + }); + expect(JSON.stringify(schema)).not.toContain('"oneOf"'); + }); + it('drops validation-neutral empty object keywords when normalizing oneOf wrappers', () => { const { standardSchema } = makeStandardSchema({ type: 'object', diff --git a/tests/lib/transform.test.ts b/tests/lib/transform.test.ts index 1bb50d5a15..9b218b034b 100644 --- a/tests/lib/transform.test.ts +++ b/tests/lib/transform.test.ts @@ -1132,6 +1132,23 @@ describe('toStrictJsonSchema', () => { }); }); + test('ignores undefined object-keyword placeholders when normalizing unions', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + value: { + properties: undefined, + anyOf: [{ type: 'string' }, { type: 'number' }], + }, + }, + required: ['value'], + }; + + expect(toStrictJsonSchema(schema).properties?.['value']).toEqual({ + anyOf: [{ type: 'string' }, { type: 'number' }], + }); + }); + test('rejects tuple-form items before returning a strict schema', () => { const schema: JSONSchema = { type: 'object', @@ -1891,6 +1908,79 @@ describe('toStrictJsonSchema', () => { }); }); + test.each(['$defs', 'definitions'] as const)( + 'merges ref-backed object allOf variants with scoped %s maps', + (keyword) => { + const schema = { + type: 'object', + [keyword]: { + Name: { + type: 'object', + [keyword]: { + Value: { type: 'string' }, + }, + properties: { + name: { $ref: `#/${keyword}/Name/${keyword}/Value` }, + }, + required: ['name'], + }, + Age: { + type: 'object', + properties: { age: { type: 'number' } }, + required: ['age'], + }, + }, + properties: { + value: { + type: 'object', + allOf: [{ $ref: `#/${keyword}/Name` }, { $ref: `#/${keyword}/Age` }], + }, + }, + required: ['value'], + } as JSONSchema; + + expect(toStrictJsonSchema(schema).properties?.['value']).toMatchObject({ + type: 'object', + properties: { + name: { $ref: `#/${keyword}/Name/${keyword}/Value` }, + age: { type: 'number' }, + }, + required: ['name', 'age'], + additionalProperties: false, + }); + }, + ); + + test('merges compatible nullable object allOf variants', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + value: { + allOf: [ + { + type: ['object', 'null'], + properties: { name: { type: 'string' } }, + required: ['name'], + }, + { + type: ['object', 'null'], + properties: { age: { type: 'number' } }, + required: ['age'], + }, + ], + }, + }, + required: ['value'], + }; + + expect(toStrictJsonSchema(schema).properties?.['value']).toEqual({ + type: ['object', 'null'], + properties: { name: { type: 'string' }, age: { type: 'number' } }, + required: ['name', 'age'], + additionalProperties: false, + }); + }); + test('fails closed on cyclic local ref chains in object allOf variants', () => { const schema: JSONSchema = { type: 'object', From 5385043dacee1c80f205ad1b54d42356d6aeb8f3 Mon Sep 17 00:00:00 2001 From: Hayden Date: Tue, 21 Jul 2026 21:11:32 +0000 Subject: [PATCH 28/35] fix(helpers): normalize strict allOf branches --- src/lib/transform.ts | 40 +++++++++-- tests/lib/transform.test.ts | 129 ++++++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+), 6 deletions(-) diff --git a/src/lib/transform.ts b/src/lib/transform.ts index a6565f6165..95d26f508f 100644 --- a/src/lib/transform.ts +++ b/src/lib/transform.ts @@ -316,8 +316,20 @@ function inlineRootRefObject(schema: JSONSchema): void { */ function normalizeRootAllOf(schema: JSONSchema): void { while (schema.allOf !== undefined) { + // Root normalization runs before recursive strictification so the root + // type check can see an inlined object. Normalize nested intersections in + // its branches first for the same associative allOf case handled by the + // later recursive pass. + if (Array.isArray(schema.allOf)) { + for (const [index, branch] of schema.allOf.entries()) { + normalizeObjectAllOfBranches(branch, ['allOf', String(index)], schema); + } + } + if (mergeObjectAllOf(schema, [], schema)) { - return; + // Removing neutral true branches can leave a singleton allOf behind; + // keep normalizing until the root reaches its final object form. + continue; } const allOf = schema.allOf; @@ -1182,14 +1194,17 @@ function normalizeObjectAllOfBranches(schema: JSONSchemaDefinition, path: string return; } - if (mergeObjectAllOf(schema, path, root)) { - normalizeObjectAllOfBranches(schema, path, root); - return; - } - forEachJSONSchemaChild(schema, path, (child, childPath) => { normalizeObjectAllOfBranches(child as JSONSchemaDefinition, childPath, root); }); + + // Intersections are associative, so normalize nested object intersections + // before classifying their parents. Otherwise an inner allOf wrapper has no + // directly visible object shape and makes an exactly mergeable outer allOf + // fail closed. + if (mergeObjectAllOf(schema, path, root)) { + normalizeObjectAllOfBranches(schema, path, root); + } } /** @@ -1215,6 +1230,19 @@ function mergeObjectAllOf(jsonSchema: JSONSchema, path: string[], root: JSONSche return false; } + // `true` is the identity element for Draft 7 intersections. Remove it + // before deciding whether the remaining branches are object-mergeable so a + // neutral branch cannot make an otherwise exact merge fail closed. + const nonNeutralBranches = allOf.filter((entry) => entry !== true); + if (nonNeutralBranches.length !== allOf.length) { + if (nonNeutralBranches.length === 0) { + delete jsonSchema.allOf; + } else { + jsonSchema.allOf = nonNeutralBranches; + } + return true; + } + const parentHasObjectShape = hasObjectShapeWithoutAllOf(jsonSchema); const resolvedEntries = allOf.map((entry) => isObject(entry) ? resolveObjectAllOfBranch(entry, root) : undefined, diff --git a/tests/lib/transform.test.ts b/tests/lib/transform.test.ts index 9b218b034b..f9698aa247 100644 --- a/tests/lib/transform.test.ts +++ b/tests/lib/transform.test.ts @@ -435,6 +435,63 @@ describe('toStrictJsonSchema', () => { }); }); + test('removes neutral root allOf branches before inlining an object branch', () => { + const schema: JSONSchema = { + allOf: [ + true, + { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + }, + ], + }; + + expect(toStrictJsonSchema(schema)).toEqual({ + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + additionalProperties: false, + }); + }); + + test('normalizes nested root allOf variants before validating the type', () => { + const schema: JSONSchema = { + allOf: [ + { + allOf: [ + { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + }, + { + type: 'object', + properties: { age: { type: 'number' } }, + required: ['age'], + }, + ], + }, + { + type: 'object', + properties: { active: { type: 'boolean' } }, + required: ['active'], + }, + ], + }; + + expect(toStrictJsonSchema(schema)).toEqual({ + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' }, + active: { type: 'boolean' }, + }, + required: ['name', 'age', 'active'], + additionalProperties: false, + }); + }); + test('preserves root dialect and base metadata while merging root allOf branches', () => { const schema: JSONSchema = { $schema: 'http://json-schema.org/draft-07/schema#', @@ -1870,6 +1927,78 @@ describe('toStrictJsonSchema', () => { }); }); + test('removes neutral true branches before merging object allOf variants', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + value: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + allOf: [ + true, + { + type: 'object', + properties: { age: { type: 'number' } }, + required: ['age'], + }, + ], + }, + }, + required: ['value'], + }; + + expect(toStrictJsonSchema(schema).properties?.['value']).toEqual({ + type: 'object', + properties: { name: { type: 'string' }, age: { type: 'number' } }, + required: ['name', 'age'], + additionalProperties: false, + }); + }); + + test('normalizes nested object allOf variants before merging their parent', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + value: { + allOf: [ + { + allOf: [ + { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + }, + { + type: 'object', + properties: { age: { type: 'number' } }, + required: ['age'], + }, + ], + }, + { + type: 'object', + properties: { active: { type: 'boolean' } }, + required: ['active'], + }, + ], + }, + }, + required: ['value'], + }; + + expect(toStrictJsonSchema(schema).properties?.['value']).toEqual({ + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' }, + active: { type: 'boolean' }, + }, + required: ['name', 'age', 'active'], + additionalProperties: false, + }); + }); + test('merges compatible object allOf variants through local ref chains', () => { const schema: JSONSchema = { type: 'object', From 20418aaac02cb4bef4eb92c20ea85e6c3823cfbb Mon Sep 17 00:00:00 2001 From: Hayden Date: Tue, 21 Jul 2026 21:41:26 +0000 Subject: [PATCH 29/35] fix(helpers): handle strict schema edge cases --- src/lib/transform.ts | 36 ++++++++++++- tests/helpers/standard-schema.test.ts | 74 +++++++++++++++++++++++++++ tests/lib/transform.test.ts | 2 + 3 files changed, 111 insertions(+), 1 deletion(-) diff --git a/src/lib/transform.ts b/src/lib/transform.ts index 95d26f508f..020526f22e 100644 --- a/src/lib/transform.ts +++ b/src/lib/transform.ts @@ -49,6 +49,8 @@ const JSON_SCHEMA_MAP_SCHEMA_KEYWORDS = [ ]; const JSON_SCHEMA_UNSUPPORTED_SCHEMA_KEYWORDS = new Set([ + '$dynamicAnchor', + '$dynamicRef', 'allOf', 'contains', 'contentEncoding', @@ -1216,9 +1218,41 @@ export function normalizeObjectAllOfForExclusivity( schema: JSONSchema, root: JSONSchema, ): JSONSchema | undefined { + if (schema.allOf === undefined) { + return undefined; + } + const normalized = structuredClone(schema); try { - return mergeObjectAllOf(normalized, [], root) ? normalized : undefined; + // Removing neutral branches or flattening a singleton can expose another + // mergeable allOf wrapper. Keep applying the recursive merger and safe + // singleton flattening until the proof sees the branch's final shape. + while (normalized.allOf !== undefined) { + normalizeObjectAllOfBranches(normalized, [], root); + if (normalized.allOf === undefined) { + break; + } + + const allOf = normalized.allOf; + if (!Array.isArray(allOf) || allOf.length !== 1 || !hasOnlyAnnotationSiblings(normalized, 'allOf')) { + return undefined; + } + + const branch = allOf[0]; + if (typeof branch === 'boolean' || !isObject(branch)) { + return undefined; + } + + const siblings = { ...normalized }; + delete siblings.allOf; + const flattened = structuredClone(branch); + for (const keyword of Object.keys(normalized)) { + delete (normalized as Record)[keyword]; + } + Object.assign(normalized, flattened, siblings); + } + + return normalized; } catch { return undefined; } diff --git a/tests/helpers/standard-schema.test.ts b/tests/helpers/standard-schema.test.ts index 9e85b8a86a..21e6d91bd2 100644 --- a/tests/helpers/standard-schema.test.ts +++ b/tests/helpers/standard-schema.test.ts @@ -521,6 +521,60 @@ describe('Standard Schema helpers', () => { expect(JSON.stringify(schema)).not.toContain('"oneOf"'); }); + it('normalizes oneOf allOf branches to a fixed point before proving exclusivity', () => { + const { standardSchema } = makeStandardSchema({ + type: 'object', + properties: { + choice: { + oneOf: [ + { + allOf: [ + true, + { + type: 'object', + properties: { kind: { type: 'string', const: 'foo' }, foo: { type: 'string' } }, + required: ['kind', 'foo'], + }, + ], + }, + { + allOf: [ + true, + { + type: 'object', + properties: { kind: { type: 'string', const: 'bar' }, bar: { type: 'number' } }, + required: ['kind', 'bar'], + }, + ], + }, + ], + }, + }, + required: ['choice'], + }); + + const schema = standardResponseFormat(standardSchema, 'choice').json_schema.schema; + + expect(schema).toMatchObject({ + properties: { + choice: { + anyOf: [ + { + type: 'object', + properties: { kind: { type: 'string', const: 'foo' }, foo: { type: 'string' } }, + }, + { + type: 'object', + properties: { kind: { type: 'string', const: 'bar' }, bar: { type: 'number' } }, + }, + ], + }, + }, + }); + expect(JSON.stringify(schema)).not.toContain('"allOf"'); + expect(JSON.stringify(schema)).not.toContain('"oneOf"'); + }); + it('drops validation-neutral empty object keywords when normalizing oneOf wrappers', () => { const { standardSchema } = makeStandardSchema({ type: 'object', @@ -896,6 +950,26 @@ describe('Standard Schema helpers', () => { ); }); + it.each([ + ['$dynamicRef', '#node'], + ['$dynamicAnchor', 'node'], + ] as const)('rejects unsupported %s before returning a strict schema', (keyword, value) => { + const { standardSchema } = makeStandardSchema({ + type: 'object', + properties: { + value: { + type: 'string', + [keyword]: value, + }, + }, + required: ['value'], + }); + + expect(() => standardResponseFormat(standardSchema, 'value')).toThrow( + `uses unsupported keyword \`${keyword}\``, + ); + }); + it.each(['minProperties', 'maxProperties'] as const)( 'rejects %s before returning a strict schema', (keyword) => { diff --git a/tests/lib/transform.test.ts b/tests/lib/transform.test.ts index f9698aa247..6a6cb90010 100644 --- a/tests/lib/transform.test.ts +++ b/tests/lib/transform.test.ts @@ -1288,6 +1288,8 @@ describe('toStrictJsonSchema', () => { 'dependentRequired', 'dependentSchemas', 'dependencies', + '$dynamicAnchor', + '$dynamicRef', 'else', 'if', 'maxContains', From 49772b4dbd17f632d7a5c8b8ea4ef550be4866f7 Mon Sep 17 00:00:00 2001 From: Hayden Date: Tue, 21 Jul 2026 22:03:39 +0000 Subject: [PATCH 30/35] fix(helpers): handle strict schema union edge cases --- src/helpers/standard-schema.ts | 8 +- src/lib/transform.ts | 35 ++++- tests/helpers/standard-schema.test.ts | 197 ++++++++++++++++++++++++++ tests/lib/transform.test.ts | 178 +++++++++++++++++++++++ 4 files changed, 411 insertions(+), 7 deletions(-) diff --git a/src/helpers/standard-schema.ts b/src/helpers/standard-schema.ts index 8035f60257..7a450ed021 100644 --- a/src/helpers/standard-schema.ts +++ b/src/helpers/standard-schema.ts @@ -269,7 +269,13 @@ function resolveLocalRefForExclusivity( if (record['allOf'] !== undefined) { if (!Array.isArray(record['allOf'])) return undefined; - return normalizeObjectAllOfForExclusivity(record as JSONSchema, root); + const normalized = normalizeObjectAllOfForExclusivity(record as JSONSchema, root); + if (normalized === undefined) return undefined; + + // Flattening a singleton allOf can expose a bare local ref. Feed that + // result through this same resolver so URI-fragment decoding and the + // existing local-ref cycle guard still apply before exclusivity analysis. + return resolveLocalRefForExclusivity(normalized, root, seenRefs); } return schema; diff --git a/src/lib/transform.ts b/src/lib/transform.ts index 020526f22e..d53c12c288 100644 --- a/src/lib/transform.ts +++ b/src/lib/transform.ts @@ -877,6 +877,13 @@ function hasObjectShape(schema: JSONSchema): boolean { ); } +function isRedundantUnionWrapperType(type: JSONSchema['type'], branchType: 'object' | 'array'): boolean { + return ( + type === branchType || + (Array.isArray(type) && type.length === 2 && type.includes(branchType) && type.includes('null')) + ); +} + function normalizeObjectUnionWrapper(jsonSchema: JSONSchema, path: string[], root: JSONSchema): void { if (jsonSchema.anyOf === undefined) { return; @@ -900,11 +907,14 @@ function normalizeObjectUnionWrapper(jsonSchema: JSONSchema, path: string[], roo JSON_SCHEMA_OBJECT_KEYWORDS.has(keyword), ); if ( - jsonSchema.type === 'object' && + isRedundantUnionWrapperType(jsonSchema.type, 'object') && !hasOwnObjectConstraints && Array.isArray(jsonSchema.anyOf) && jsonSchema.anyOf.every((branch) => isObjectOnlySchema(branch, root)) ) { + // The union already excludes null and every non-object value, so both a + // scalar object wrapper and a nullable object wrapper are redundant under + // Draft 7's conjunctive keyword semantics. delete jsonSchema.type; return; } @@ -918,14 +928,14 @@ function normalizeObjectUnionWrapper(jsonSchema: JSONSchema, path: string[], roo function normalizeArrayUnionWrapper(jsonSchema: JSONSchema, root: JSONSchema): void { if ( - jsonSchema.type === 'array' && + isRedundantUnionWrapperType(jsonSchema.type, 'array') && jsonSchema.items === undefined && Array.isArray(jsonSchema.anyOf) && jsonSchema.anyOf.every((branch) => isArrayOnlySchema(branch, root)) ) { - // Every union branch already proves the value is an array. Keeping the - // redundant wrapper would require an outer items schema that does not - // contribute any Draft 7 validation. + // Every union branch already proves the value is an array and excludes + // null. Keeping either redundant wrapper would require an outer items + // schema that does not contribute any Draft 7 validation. delete jsonSchema.type; } } @@ -1333,9 +1343,13 @@ function mergeObjectAllOf(jsonSchema: JSONSchema, path: string[], root: JSONSche return fail(); } const branch = resolvedEntry.schema; + // Definition maps do not constrain instances. Any refs into an allOf + // branch were preserved under stable root definitions before this merge, + // so a valid definitions-only branch can now be discarded like an + // annotation-only branch. if (hasObjectShapeWithoutAllOf(branch)) { branches.push(branch); - } else if (!Object.keys(branch).every((keyword) => JSON_SCHEMA_ANNOTATION_KEYWORDS.has(keyword))) { + } else if (!hasOnlyNeutralAllOfBranchKeywords(branch)) { fail(); } } @@ -1466,6 +1480,15 @@ function hasObjectShapeWithoutAllOf(schema: JSONSchema): boolean { return Object.keys(schema).some((keyword) => JSON_SCHEMA_OBJECT_KEYWORDS.has(keyword)); } +function hasOnlyNeutralAllOfBranchKeywords(schema: JSONSchema): boolean { + const schemaRecord = schema as Record; + return Object.keys(schema).every( + (keyword) => + JSON_SCHEMA_ANNOTATION_KEYWORDS.has(keyword) || + ((keyword === '$defs' || keyword === 'definitions') && isObject(schemaRecord[keyword])), + ); +} + function isMergeableObjectType(type: JSONSchema['type']): boolean { return ( type === 'object' || diff --git a/tests/helpers/standard-schema.test.ts b/tests/helpers/standard-schema.test.ts index 21e6d91bd2..85d91fe0f0 100644 --- a/tests/helpers/standard-schema.test.ts +++ b/tests/helpers/standard-schema.test.ts @@ -80,6 +80,29 @@ function makeStandardSchema( }; } +function makeStrictSchemaFactories(jsonSchema: Record) { + const { standardSchema } = makeStandardSchema(jsonSchema); + + return [ + () => standardResponseFormat(standardSchema, 'weather').json_schema.schema, + () => standardTextFormat(standardSchema, 'weather').schema, + () => + standardFunction({ + name: 'get_weather', + parameters: standardSchema, + }).function.parameters, + () => + standardResponsesFunction({ + name: 'get_weather', + parameters: standardSchema, + }).parameters, + ]; +} + +function strictSchemasForAllHelpers(jsonSchema: Record) { + return makeStrictSchemaFactories(jsonSchema).map((makeSchema) => makeSchema()); +} + function makeValidationOnlySchema() { return { '~standard': { @@ -290,6 +313,75 @@ describe('Standard Schema helpers', () => { expect(choice).not.toHaveProperty('additionalProperties'); }); + it('normalizes redundant nullable object oneOf wrappers across all helper surfaces', () => { + const schemas = strictSchemasForAllHelpers({ + type: 'object', + properties: { + choice: { + type: ['object', 'null'], + oneOf: [ + { + type: 'object', + properties: { kind: { type: 'string', const: 'foo' }, foo: { type: 'string' } }, + required: ['kind', 'foo'], + }, + { + type: 'object', + properties: { kind: { type: 'string', const: 'bar' }, bar: { type: 'number' } }, + required: ['kind', 'bar'], + }, + ], + }, + }, + required: ['choice'], + }); + + for (const schema of schemas) { + const choice = (schema as JSONSchema).properties?.['choice'] as JSONSchema; + expect(choice).toEqual({ + anyOf: [ + { + type: 'object', + properties: { kind: { type: 'string', const: 'foo' }, foo: { type: 'string' } }, + required: ['kind', 'foo'], + additionalProperties: false, + }, + { + type: 'object', + properties: { kind: { type: 'string', const: 'bar' }, bar: { type: 'number' } }, + required: ['kind', 'bar'], + additionalProperties: false, + }, + ], + }); + } + }); + + it('normalizes redundant nullable array anyOf wrappers across all helper surfaces', () => { + const schemas = strictSchemasForAllHelpers({ + type: 'object', + properties: { + choice: { + type: ['array', 'null'], + anyOf: [ + { type: 'array', items: { type: 'string' } }, + { type: 'array', items: { type: 'number' } }, + ], + }, + }, + required: ['choice'], + }); + + for (const schema of schemas) { + expect((schema as JSONSchema).properties?.['choice']).toEqual({ + anyOf: [ + { type: 'array', items: { type: 'string' } }, + { type: 'array', items: { type: 'number' } }, + ], + }); + } + }); + it('updates refs into oneOf branches after moving them to anyOf', () => { const { standardSchema } = makeStandardSchema({ type: 'object', @@ -575,6 +667,88 @@ describe('Standard Schema helpers', () => { expect(JSON.stringify(schema)).not.toContain('"oneOf"'); }); + it('resolves local refs exposed by oneOf allOf normalization across all helper surfaces', () => { + const schemas = strictSchemasForAllHelpers({ + type: 'object', + $defs: { + 'foo branch': { + type: 'object', + properties: { kind: { type: 'string', const: 'foo' }, foo: { type: 'string' } }, + required: ['kind', 'foo'], + }, + 'bar branch': { + type: 'object', + properties: { kind: { type: 'string', const: 'bar' }, bar: { type: 'number' } }, + required: ['kind', 'bar'], + }, + }, + properties: { + choice: { + oneOf: [ + { allOf: [{ $ref: '#/$defs/foo%20branch' }] }, + { allOf: [{ $ref: '#/$defs/bar%20branch' }] }, + ], + }, + }, + required: ['choice'], + }); + + for (const schema of schemas) { + expect((schema as JSONSchema).properties?.['choice']).toEqual({ + anyOf: [{ $ref: '#/$defs/foo%20branch' }, { $ref: '#/$defs/bar%20branch' }], + }); + expect(JSON.stringify(schema)).not.toContain('"allOf"'); + expect(JSON.stringify(schema)).not.toContain('"oneOf"'); + } + }); + + it.each(['$defs', 'definitions'] as const)( + 'discards neutral %s-only allOf branches across all helper surfaces', + (keyword) => { + const schemas = strictSchemasForAllHelpers({ + type: 'object', + properties: { + value: { + allOf: [ + { + [keyword]: { + Name: { type: 'string' }, + }, + }, + { + type: 'object', + properties: { + name: { $ref: `#/properties/value/allOf/0/${keyword}/Name` }, + }, + required: ['name'], + }, + ], + }, + }, + required: ['value'], + }); + + for (const schema of schemas) { + expect(schema).toMatchObject({ + $defs: { + __openai_strict_allOf_ref_0: { type: 'string' }, + }, + properties: { + value: { + type: 'object', + properties: { + name: { $ref: '#/$defs/__openai_strict_allOf_ref_0' }, + }, + required: ['name'], + additionalProperties: false, + }, + }, + }); + expect(JSON.stringify(schema)).not.toContain('"allOf"'); + } + }, + ); + it('drops validation-neutral empty object keywords when normalizing oneOf wrappers', () => { const { standardSchema } = makeStandardSchema({ type: 'object', @@ -845,6 +1019,29 @@ describe('Standard Schema helpers', () => { } }); + it.each([ + ['missing', { allOf: [{ $ref: '#/$defs/missing' }] }, {}], + ['external', { allOf: [{ $ref: 'https://example.com/schema.json#/$defs/foo' }] }, {}], + ['cyclic', { allOf: [{ $ref: '#/$defs/cycle' }] }, { cycle: { allOf: [{ $ref: '#/$defs/cycle' }] } }], + ])('keeps %s refs exposed by oneOf allOf normalization fail closed', (_name, branch, defs) => { + const factories = makeStrictSchemaFactories({ + type: 'object', + $defs: defs, + properties: { + choice: { + oneOf: [branch, { type: 'string' }], + }, + }, + required: ['choice'], + }); + + for (const makeSchema of factories) { + expect(makeSchema).toThrow( + 'Standard JSON Schema generated a `oneOf` whose branches are not provably mutually exclusive', + ); + } + }); + it('does not normalize oneOf keys inside literal schema values', () => { const schemaWithLiterals = { type: 'object', diff --git a/tests/lib/transform.test.ts b/tests/lib/transform.test.ts index 6a6cb90010..ca40276507 100644 --- a/tests/lib/transform.test.ts +++ b/tests/lib/transform.test.ts @@ -1553,6 +1553,46 @@ describe('toStrictJsonSchema', () => { }); }); + test('removes a redundant nullable array type from an anyOf wrapper', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + value: { + type: ['array', 'null'], + anyOf: [ + { type: 'array', items: { type: 'string' } }, + { type: 'array', items: { type: 'number' } }, + ], + }, + }, + required: ['value'], + }; + + expect(toStrictJsonSchema(schema).properties?.['value']).toEqual({ + anyOf: [ + { type: 'array', items: { type: 'string' } }, + { type: 'array', items: { type: 'number' } }, + ], + }); + }); + + test('keeps nullable array union wrappers fail closed when a branch is not array-only', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + value: { + type: ['array', 'null'], + anyOf: [{ type: 'array', items: { type: 'string' } }, { type: 'null' }], + }, + }, + required: ['value'], + }; + + expect(() => toStrictJsonSchema(schema)).toThrow( + 'declares an array without `items`, which cannot be represented', + ); + }); + test('removes a redundant object type from an anyOf wrapper before closing branches', () => { const schema: JSONSchema = { type: 'object', @@ -1595,6 +1635,72 @@ describe('toStrictJsonSchema', () => { }); }); + test('removes a redundant nullable object type from an anyOf wrapper before closing branches', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + value: { + type: ['object', 'null'], + anyOf: [ + { + type: 'object', + properties: { kind: { type: 'string', const: 'foo' }, foo: { type: 'string' } }, + required: ['kind', 'foo'], + }, + { + type: 'object', + properties: { kind: { type: 'string', const: 'bar' }, bar: { type: 'number' } }, + required: ['kind', 'bar'], + }, + ], + }, + }, + required: ['value'], + }; + + const strict = toStrictJsonSchema(schema); + expect(strict.properties?.['value']).toEqual({ + anyOf: [ + { + type: 'object', + properties: { kind: { type: 'string', const: 'foo' }, foo: { type: 'string' } }, + required: ['kind', 'foo'], + additionalProperties: false, + }, + { + type: 'object', + properties: { kind: { type: 'string', const: 'bar' }, bar: { type: 'number' } }, + required: ['kind', 'bar'], + additionalProperties: false, + }, + ], + }); + }); + + test('keeps nullable object union wrappers fail closed when a branch is not object-only', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + value: { + type: ['object', 'null'], + anyOf: [ + { + type: 'object', + properties: { value: { type: 'string' } }, + required: ['value'], + }, + { type: 'null' }, + ], + }, + }, + required: ['value'], + }; + + expect(() => toStrictJsonSchema(schema)).toThrow( + 'Object anyOf schema at `properties/value` cannot be represented', + ); + }); + test('removes empty object keywords from an untyped anyOf wrapper', () => { const schema: JSONSchema = { type: 'object', @@ -1958,6 +2064,78 @@ describe('toStrictJsonSchema', () => { }); }); + test.each(['$defs', 'definitions'] as const)( + 'discards neutral %s-only allOf branches after preserving their refs', + (keyword) => { + const schema = { + type: 'object', + properties: { + value: { + allOf: [ + { + [keyword]: { + Name: { type: 'string' }, + }, + }, + { + type: 'object', + properties: { + name: { $ref: `#/properties/value/allOf/0/${keyword}/Name` }, + }, + required: ['name'], + }, + ], + }, + }, + required: ['value'], + } as JSONSchema; + + const strict = toStrictJsonSchema(schema); + expect(strict).toMatchObject({ + $defs: { + __openai_strict_allOf_ref_0: { type: 'string' }, + }, + properties: { + value: { + type: 'object', + properties: { + name: { $ref: '#/$defs/__openai_strict_allOf_ref_0' }, + }, + required: ['name'], + additionalProperties: false, + }, + }, + }); + expect(JSON.stringify(strict)).not.toContain('"allOf"'); + }, + ); + + test.each(['$defs', 'definitions'] as const)( + 'keeps malformed %s-only allOf branches fail closed', + (keyword) => { + const schema = { + type: 'object', + properties: { + value: { + allOf: [ + { [keyword]: false }, + { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + }, + ], + }, + }, + required: ['value'], + } as unknown as JSONSchema; + + expect(() => toStrictJsonSchema(schema)).toThrow( + 'cannot be merged without changing Draft 7 validation', + ); + }, + ); + test('normalizes nested object allOf variants before merging their parent', () => { const schema: JSONSchema = { type: 'object', From 489a430261817ee60d039c6ab125c5e69a203c19 Mon Sep 17 00:00:00 2001 From: Hayden Date: Tue, 21 Jul 2026 22:49:26 +0000 Subject: [PATCH 31/35] fix(helpers): normalize strict schema edge cases --- src/helpers/standard-schema.ts | 52 ++++- src/lib/transform.ts | 186 +++++++++++++--- tests/helpers/standard-schema.test.ts | 303 ++++++++++++++++++++++++++ tests/lib/transform.test.ts | 208 ++++++++++++++++++ 4 files changed, 713 insertions(+), 36 deletions(-) diff --git a/src/helpers/standard-schema.ts b/src/helpers/standard-schema.ts index 7a450ed021..5cde39326d 100644 --- a/src/helpers/standard-schema.ts +++ b/src/helpers/standard-schema.ts @@ -227,6 +227,52 @@ function haveDisjointObjectDiscriminator(left: unknown, right: unknown, root: JS return false; } +function getClosedObjectPropertySet( + schema: unknown, +): { properties: Set; required: string[] } | undefined { + if (!isObjectOnlySchema(schema)) return undefined; + + const record = schema as Record; + const properties = record['properties']; + const required = record['required']; + if ( + record['additionalProperties'] !== false || + !properties || + typeof properties !== 'object' || + Array.isArray(properties) || + !Array.isArray(required) || + required.some((property) => typeof property !== 'string') + ) { + return undefined; + } + + const propertySet = new Set(Object.keys(properties)); + const requiredProperties = required as string[]; + // A required undeclared property makes a closed branch unsatisfiable, but + // the strictifier rejects that shape rather than representing it. Keep this + // exclusivity proof conservative and let the normal validation path fail. + if (requiredProperties.some((property) => !propertySet.has(property))) { + return undefined; + } + + return { properties: propertySet, required: requiredProperties }; +} + +function haveDisjointClosedObjectPropertySets(left: unknown, right: unknown): boolean { + const leftShape = getClosedObjectPropertySet(left); + const rightShape = getClosedObjectPropertySet(right); + if (!leftShape || !rightShape) return false; + + // If either closed branch requires a property the other branch does not + // declare, every instance satisfying the first is rejected by the second as + // an additional property. This proves oneOf exclusivity without widening + // overlapping closed shapes. + return ( + leftShape.required.some((property) => !rightShape.properties.has(property)) || + rightShape.required.some((property) => !leftShape.properties.has(property)) + ); +} + function areMutuallyExclusive(left: unknown, right: unknown, root: JSONSchema): boolean { const leftTypes = getSchemaTypes(left); const rightTypes = getSchemaTypes(right); @@ -240,7 +286,11 @@ function areMutuallyExclusive(left: unknown, right: unknown, root: JSONSchema): return true; } - return haveDisjointLiteralValues(left, right) || haveDisjointObjectDiscriminator(left, right, root); + return ( + haveDisjointLiteralValues(left, right) || + haveDisjointObjectDiscriminator(left, right, root) || + haveDisjointClosedObjectPropertySets(left, right) + ); } function resolveLocalRefForExclusivity( diff --git a/src/lib/transform.ts b/src/lib/transform.ts index d53c12c288..1b0423bfb1 100644 --- a/src/lib/transform.ts +++ b/src/lib/transform.ts @@ -51,6 +51,8 @@ const JSON_SCHEMA_MAP_SCHEMA_KEYWORDS = [ const JSON_SCHEMA_UNSUPPORTED_SCHEMA_KEYWORDS = new Set([ '$dynamicAnchor', '$dynamicRef', + '$recursiveAnchor', + '$recursiveRef', 'allOf', 'contains', 'contentEncoding', @@ -502,6 +504,13 @@ function ensureStrictJsonSchema( return ensureStrictJsonSchema(jsonSchema, path, root); } + // false is the identity element for a union. Remove it before proving + // whether an outer object/array wrapper is redundant so an impossible + // alternative cannot hide the shape of every real branch. Keep all-false + // and boolean-only unions intact so the existing boolean-schema rejection + // remains fail closed. + normalizeAnyOfFalseBranches(jsonSchema); + // Closing a type: object wrapper around object union branches without any // own properties would turn it into an empty object and forbid every branch // property. A bare object type is redundant when every branch already proves @@ -793,6 +802,21 @@ function isObjectOnlySchema( return isObjectOnlySchema(resolved, root, new Set([...seenRefs, schema.$ref])); } + if (schema.allOf !== undefined) { + if ( + !Array.isArray(schema.allOf) || + schema.allOf.length !== 1 || + !hasOnlyAnnotationSiblings(schema, 'allOf') + ) { + return false; + } + + const branch = schema.allOf[0]; + return branch !== undefined && branch !== true && branch !== false ? + isObjectOnlySchema(branch, root, seenRefs) + : false; + } + return ( schema.type === 'object' || (Array.isArray(schema.type) && schema.type.length === 1 && schema.type[0] === 'object') @@ -821,6 +845,21 @@ function isArrayOnlySchema( return isArrayOnlySchema(resolved, root, new Set([...seenRefs, schema.$ref])); } + if (schema.allOf !== undefined) { + if ( + !Array.isArray(schema.allOf) || + schema.allOf.length !== 1 || + !hasOnlyAnnotationSiblings(schema, 'allOf') + ) { + return false; + } + + const branch = schema.allOf[0]; + return branch !== undefined && branch !== true && branch !== false ? + isArrayOnlySchema(branch, root, seenRefs) + : false; + } + return ( schema.type === 'array' || (Array.isArray(schema.type) && schema.type.length === 1 && schema.type[0] === 'array') @@ -940,6 +979,17 @@ function normalizeArrayUnionWrapper(jsonSchema: JSONSchema, root: JSONSchema): v } } +function normalizeAnyOfFalseBranches(jsonSchema: JSONSchema): void { + if (!Array.isArray(jsonSchema.anyOf)) { + return; + } + + const realBranches = jsonSchema.anyOf.filter((branch) => branch !== false); + if (realBranches.length > 0 && realBranches.length !== jsonSchema.anyOf.length) { + jsonSchema.anyOf = realBranches; + } +} + export function assertNoNestedSchemaIds(schema: JSONSchema): void { const visit = (value: JSONSchemaDefinition, path: string[]): void => { if (typeof value === 'boolean' || !isObject(value)) { @@ -1177,45 +1227,83 @@ type ResolvedObjectAllOfBranch = { function resolveObjectAllOfBranch( schema: JSONSchema, root: JSONSchema, + normalizing: Set, ): ResolvedObjectAllOfBranch | undefined { const refChain = [schema]; const seenRefs = new Set(); let resolved = schema; + let resolvedPath: string[] = []; - while (resolved.$ref !== undefined) { - const ref = resolved.$ref; - if (typeof ref !== 'string' || !hasOnlyRefAndAnnotations(resolved) || seenRefs.has(ref)) { - return undefined; - } - seenRefs.add(ref); + while (true) { + while (resolved.$ref !== undefined) { + const ref = resolved.$ref; + if (typeof ref !== 'string' || !hasOnlyRefAndAnnotations(resolved) || seenRefs.has(ref)) { + return undefined; + } + seenRefs.add(ref); - const target = resolveLocalRef(root, ref); - if (typeof target === 'boolean' || !isObject(target)) { - return undefined; + const target = resolveLocalRef(root, ref); + if (typeof target === 'boolean' || !isObject(target)) { + return undefined; + } + const targetPath = parseLocalRef(ref); + if (targetPath === undefined) { + return undefined; + } + + resolved = target; + resolvedPath = targetPath; + refChain.push(resolved); + } + + // A ref can point forward to a target whose own mergeable allOf has not + // been visited yet. Normalize that target before its consumer classifies + // the resolved shape, making property order irrelevant. An active target + // is a ref/allOf cycle; leave it wrapped so the caller still fails closed. + if (resolved.allOf !== undefined && !normalizing.has(resolved)) { + const previousAllOf = resolved.allOf; + normalizeObjectAllOfBranches(resolved, resolvedPath, root, normalizing); + // Singleton flattening can expose another local ref. Follow it through + // the same guarded loop before returning the effective branch. + if (resolved.$ref !== undefined || resolved.allOf !== previousAllOf) { + continue; + } } - resolved = target; - refChain.push(resolved); + return { schema: resolved, refChain }; } - - return { schema: resolved, refChain }; } -function normalizeObjectAllOfBranches(schema: JSONSchemaDefinition, path: string[], root: JSONSchema): void { +function normalizeObjectAllOfBranches( + schema: JSONSchemaDefinition, + path: string[], + root: JSONSchema, + normalizing: Set = new Set(), +): void { if (typeof schema === 'boolean' || !isObject(schema)) { return; } + if (normalizing.has(schema)) { + return; + } - forEachJSONSchemaChild(schema, path, (child, childPath) => { - normalizeObjectAllOfBranches(child as JSONSchemaDefinition, childPath, root); - }); - - // Intersections are associative, so normalize nested object intersections - // before classifying their parents. Otherwise an inner allOf wrapper has no - // directly visible object shape and makes an exactly mergeable outer allOf - // fail closed. - if (mergeObjectAllOf(schema, path, root)) { - normalizeObjectAllOfBranches(schema, path, root); + normalizing.add(schema); + try { + while (true) { + forEachJSONSchemaChild(schema, path, (child, childPath) => { + normalizeObjectAllOfBranches(child as JSONSchemaDefinition, childPath, root, normalizing); + }); + + // Intersections are associative, so normalize nested object + // intersections before classifying their parents. Otherwise an inner + // allOf wrapper has no directly visible object shape and makes an + // exactly mergeable outer allOf fail closed. + if (!mergeObjectAllOf(schema, path, root, normalizing)) { + return; + } + } + } finally { + normalizing.delete(schema); } } @@ -1268,7 +1356,12 @@ export function normalizeObjectAllOfForExclusivity( } } -function mergeObjectAllOf(jsonSchema: JSONSchema, path: string[], root: JSONSchema): boolean { +function mergeObjectAllOf( + jsonSchema: JSONSchema, + path: string[], + root: JSONSchema, + normalizing: Set = new Set(), +): boolean { const allOf = jsonSchema.allOf; if (!Array.isArray(allOf) || allOf.length === 0) { return false; @@ -1289,7 +1382,7 @@ function mergeObjectAllOf(jsonSchema: JSONSchema, path: string[], root: JSONSche const parentHasObjectShape = hasObjectShapeWithoutAllOf(jsonSchema); const resolvedEntries = allOf.map((entry) => - isObject(entry) ? resolveObjectAllOfBranch(entry, root) : undefined, + isObject(entry) ? resolveObjectAllOfBranch(entry, root, normalizing) : undefined, ); const objectBranches = resolvedEntries .map((entry) => entry?.schema) @@ -1371,6 +1464,7 @@ function mergeObjectAllOf(jsonSchema: JSONSchema, path: string[], root: JSONSche const mergedProperties = Object.create(null) as Record; const mergedRequired = new Set(); const closedPropertySets: Set[] = []; + const propertyEntries: Array<[string, JSONSchemaDefinition]> = []; let sawProperties = false; let sawRequired = false; let hasExplicitObjectType = false; @@ -1428,13 +1522,7 @@ function mergeObjectAllOf(jsonSchema: JSONSchema, path: string[], root: JSONSche } sawProperties = true; for (const [key, propertySchema] of Object.entries(branch.properties)) { - if ( - Object.prototype.hasOwnProperty.call(mergedProperties, key) && - !schemasEqual(mergedProperties[key], propertySchema) - ) { - fail(); - } - mergedProperties[key] = propertySchema; + propertyEntries.push([key, propertySchema]); } } @@ -1454,11 +1542,39 @@ function mergeObjectAllOf(jsonSchema: JSONSchema, path: string[], root: JSONSche } } - const mergedPropertyNames = Object.keys(mergedProperties); - if (closedPropertySets.some((keys) => mergedPropertyNames.some((key) => !keys.has(key)))) { + // A closed branch forbids every property it does not declare. Intersect all + // closed property sets before merging schemas so optional declarations + // excluded by another closed branch are discarded, while required excluded + // properties remain unrepresentable and fail closed. + const allowedClosedProperties = + closedPropertySets.length === 0 ? + undefined + : closedPropertySets + .slice(1) + .reduce( + (allowed, keys) => new Set([...allowed].filter((key) => keys.has(key))), + new Set(closedPropertySets[0]), + ); + if ( + allowedClosedProperties !== undefined && + [...mergedRequired].some((key) => !allowedClosedProperties.has(key)) + ) { fail(); } + for (const [key, propertySchema] of propertyEntries) { + if (allowedClosedProperties !== undefined && !allowedClosedProperties.has(key)) { + continue; + } + if ( + Object.prototype.hasOwnProperty.call(mergedProperties, key) && + !schemasEqual(mergedProperties[key], propertySchema) + ) { + fail(); + } + mergedProperties[key] = propertySchema; + } + if (hasExplicitObjectType || hasExplicitNullableObjectType) { merged.type = hasExplicitObjectType ? 'object' : ['object', 'null']; } diff --git a/tests/helpers/standard-schema.test.ts b/tests/helpers/standard-schema.test.ts index 85d91fe0f0..64be9a3459 100644 --- a/tests/helpers/standard-schema.test.ts +++ b/tests/helpers/standard-schema.test.ts @@ -382,6 +382,108 @@ describe('Standard Schema helpers', () => { } }); + it('normalizes singleton allOf wrappers inside object unions across all helper surfaces', () => { + const schemas = strictSchemasForAllHelpers({ + type: 'object', + properties: { + choice: { + type: 'object', + anyOf: [ + { + allOf: [ + { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + }, + ], + }, + { + type: 'object', + properties: { age: { type: 'number' } }, + required: ['age'], + }, + ], + }, + }, + required: ['choice'], + }); + + for (const schema of schemas) { + expect((schema as JSONSchema).properties?.['choice']).toEqual({ + anyOf: [ + { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + additionalProperties: false, + }, + { + type: 'object', + properties: { age: { type: 'number' } }, + required: ['age'], + additionalProperties: false, + }, + ], + }); + } + }); + + it('normalizes singleton allOf wrappers inside array unions across all helper surfaces', () => { + const schemas = strictSchemasForAllHelpers({ + type: 'object', + properties: { + choice: { + type: 'array', + anyOf: [ + { allOf: [{ type: 'array', items: { type: 'string' } }] }, + { type: 'array', items: { type: 'number' } }, + ], + }, + }, + required: ['choice'], + }); + + for (const schema of schemas) { + expect((schema as JSONSchema).properties?.['choice']).toEqual({ + anyOf: [ + { type: 'array', items: { type: 'string' } }, + { type: 'array', items: { type: 'number' } }, + ], + }); + } + }); + + it('removes false anyOf alternatives across all helper surfaces but keeps boolean unions fail closed', () => { + const schemas = strictSchemasForAllHelpers({ + type: 'object', + properties: { + choice: { + anyOf: [false, { type: 'string' }], + }, + }, + required: ['choice'], + }); + + for (const schema of schemas) { + expect((schema as JSONSchema).properties?.['choice']).toEqual({ + anyOf: [{ type: 'string' }], + }); + } + + for (const makeSchema of makeStrictSchemaFactories({ + type: 'object', + properties: { + choice: { + anyOf: [false, false], + }, + }, + required: ['choice'], + })) { + expect(makeSchema).toThrow('Expected object schema but got boolean'); + } + }); + it('updates refs into oneOf branches after moving them to anyOf', () => { const { standardSchema } = makeStandardSchema({ type: 'object', @@ -453,6 +555,77 @@ describe('Standard Schema helpers', () => { ); }); + it('proves disjoint closed object property sets across all helper surfaces', () => { + const schemas = strictSchemasForAllHelpers({ + type: 'object', + properties: { + choice: { + oneOf: [ + { + type: 'object', + properties: { a: { type: 'string' } }, + required: ['a'], + additionalProperties: false, + }, + { + type: 'object', + properties: { b: { type: 'number' } }, + required: ['b'], + additionalProperties: false, + }, + ], + }, + }, + required: ['choice'], + }); + + for (const schema of schemas) { + expect((schema as JSONSchema).properties?.['choice']).toEqual({ + anyOf: [ + { + type: 'object', + properties: { a: { type: 'string' } }, + required: ['a'], + additionalProperties: false, + }, + { + type: 'object', + properties: { b: { type: 'number' } }, + required: ['b'], + additionalProperties: false, + }, + ], + }); + } + }); + + it('keeps overlapping closed object property sets fail closed across all helper surfaces', () => { + for (const makeSchema of makeStrictSchemaFactories({ + type: 'object', + properties: { + choice: { + oneOf: [ + { + type: 'object', + properties: { shared: { type: 'string' } }, + required: ['shared'], + additionalProperties: false, + }, + { + type: 'object', + properties: { shared: { type: 'string' }, optional: { type: 'number' } }, + required: ['shared'], + additionalProperties: false, + }, + ], + }, + }, + required: ['choice'], + })) { + expect(makeSchema).toThrow('whose branches are not provably mutually exclusive'); + } + }); + it('normalizes provably exclusive oneOf branches behind local refs', () => { const referencedOneOfSchema = { type: 'object', @@ -702,6 +875,118 @@ describe('Standard Schema helpers', () => { } }); + it('normalizes ref-resolved allOf targets before earlier consumers across all helper surfaces', () => { + const makeSchema = (consumerFirst: boolean) => { + const consumer = { + type: 'object', + allOf: [ + { $ref: '#/properties/target' }, + { + type: 'object', + properties: { active: { type: 'boolean' } }, + required: ['active'], + }, + ], + }; + const target = { + allOf: [ + { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + }, + { + type: 'object', + properties: { age: { type: 'number' } }, + required: ['age'], + }, + ], + }; + return { + type: 'object', + properties: consumerFirst ? { consumer, target } : { target, consumer }, + required: ['consumer', 'target'], + }; + }; + + for (const consumerFirst of [true, false]) { + for (const schema of strictSchemasForAllHelpers(makeSchema(consumerFirst))) { + expect((schema as JSONSchema).properties?.['consumer']).toEqual({ + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' }, + active: { type: 'boolean' }, + }, + required: ['name', 'age', 'active'], + additionalProperties: false, + }); + expect(JSON.stringify(schema)).not.toContain('"allOf"'); + } + } + }); + + it('intersects closed allOf property sets across all helper surfaces', () => { + const schemas = strictSchemasForAllHelpers({ + type: 'object', + properties: { + value: { + allOf: [ + { + type: 'object', + properties: { x: { type: 'string' } }, + required: ['x'], + additionalProperties: false, + }, + { + type: 'object', + properties: { x: { type: 'string' }, y: { type: 'number' } }, + required: ['x'], + additionalProperties: false, + }, + ], + }, + }, + required: ['value'], + }); + + for (const schema of schemas) { + expect((schema as JSONSchema).properties?.['value']).toEqual({ + type: 'object', + properties: { x: { type: 'string' } }, + required: ['x'], + additionalProperties: false, + }); + } + }); + + it('rejects closed allOf property sets that exclude required properties across all helper surfaces', () => { + for (const makeSchema of makeStrictSchemaFactories({ + type: 'object', + properties: { + value: { + allOf: [ + { + type: 'object', + properties: { x: { type: 'string' } }, + required: ['x'], + additionalProperties: false, + }, + { + type: 'object', + properties: { x: { type: 'string' }, y: { type: 'number' } }, + required: ['x', 'y'], + additionalProperties: false, + }, + ], + }, + }, + required: ['value'], + })) { + expect(makeSchema).toThrow('cannot be merged without changing Draft 7 validation'); + } + }); + it.each(['$defs', 'definitions'] as const)( 'discards neutral %s-only allOf branches across all helper surfaces', (keyword) => { @@ -1167,6 +1452,24 @@ describe('Standard Schema helpers', () => { ); }); + it.each([ + ['$recursiveRef', '#'], + ['$recursiveAnchor', true], + ] as const)('rejects unsupported %s across all helper surfaces', (keyword, value) => { + for (const makeSchema of makeStrictSchemaFactories({ + type: 'object', + properties: { + value: { + type: 'string', + [keyword]: value, + }, + }, + required: ['value'], + })) { + expect(makeSchema).toThrow('uses unsupported keyword'); + } + }); + it.each(['minProperties', 'maxProperties'] as const)( 'rejects %s before returning a strict schema', (keyword) => { diff --git a/tests/lib/transform.test.ts b/tests/lib/transform.test.ts index ca40276507..f1217285ce 100644 --- a/tests/lib/transform.test.ts +++ b/tests/lib/transform.test.ts @@ -1290,6 +1290,8 @@ describe('toStrictJsonSchema', () => { 'dependencies', '$dynamicAnchor', '$dynamicRef', + '$recursiveAnchor', + '$recursiveRef', 'else', 'if', 'maxContains', @@ -1553,6 +1555,29 @@ describe('toStrictJsonSchema', () => { }); }); + test('removes a redundant array type through singleton allOf union branches', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + value: { + type: 'array', + anyOf: [ + { allOf: [{ type: 'array', items: { type: 'string' } }] }, + { type: 'array', items: { type: 'number' } }, + ], + }, + }, + required: ['value'], + }; + + expect(toStrictJsonSchema(schema).properties?.['value']).toEqual({ + anyOf: [ + { type: 'array', items: { type: 'string' } }, + { type: 'array', items: { type: 'number' } }, + ], + }); + }); + test('removes a redundant nullable array type from an anyOf wrapper', () => { const schema: JSONSchema = { type: 'object', @@ -1635,6 +1660,51 @@ describe('toStrictJsonSchema', () => { }); }); + test('removes a redundant object type through singleton allOf union branches', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + value: { + type: 'object', + anyOf: [ + { + allOf: [ + { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + }, + ], + }, + { + type: 'object', + properties: { age: { type: 'number' } }, + required: ['age'], + }, + ], + }, + }, + required: ['value'], + }; + + expect(toStrictJsonSchema(schema).properties?.['value']).toEqual({ + anyOf: [ + { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + additionalProperties: false, + }, + { + type: 'object', + properties: { age: { type: 'number' } }, + required: ['age'], + additionalProperties: false, + }, + ], + }); + }); + test('removes a redundant nullable object type from an anyOf wrapper before closing branches', () => { const schema: JSONSchema = { type: 'object', @@ -1701,6 +1771,33 @@ describe('toStrictJsonSchema', () => { ); }); + test('removes false anyOf branches while retaining all-false failures', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + value: { + anyOf: [false, { type: 'string' }], + }, + }, + required: ['value'], + }; + + expect(toStrictJsonSchema(schema).properties?.['value']).toEqual({ + anyOf: [{ type: 'string' }], + }); + + const allFalseSchema: JSONSchema = { + type: 'object', + properties: { + value: { + anyOf: [false, false], + }, + }, + required: ['value'], + }; + expect(() => toStrictJsonSchema(allFalseSchema)).toThrow('Expected object schema but got boolean'); + }); + test('removes empty object keywords from an untyped anyOf wrapper', () => { const schema: JSONSchema = { type: 'object', @@ -2179,6 +2276,56 @@ describe('toStrictJsonSchema', () => { }); }); + test('normalizes ref-resolved allOf targets before earlier consumers', () => { + const makeSchema = (consumerFirst: boolean): JSONSchema => { + const consumer = { + type: 'object', + allOf: [ + { $ref: '#/properties/target' }, + { + type: 'object', + properties: { active: { type: 'boolean' } }, + required: ['active'], + }, + ], + }; + const target = { + allOf: [ + { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + }, + { + type: 'object', + properties: { age: { type: 'number' } }, + required: ['age'], + }, + ], + }; + return { + type: 'object', + properties: consumerFirst ? { consumer, target } : { target, consumer }, + required: ['consumer', 'target'], + }; + }; + + for (const consumerFirst of [true, false]) { + const strict = toStrictJsonSchema(makeSchema(consumerFirst)); + expect(strict.properties?.['consumer']).toEqual({ + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' }, + active: { type: 'boolean' }, + }, + required: ['name', 'age', 'active'], + additionalProperties: false, + }); + expect(JSON.stringify(strict)).not.toContain('"allOf"'); + } + }); + test('merges compatible object allOf variants through local ref chains', () => { const schema: JSONSchema = { type: 'object', @@ -2290,6 +2437,67 @@ describe('toStrictJsonSchema', () => { }); }); + test('intersects closed object allOf property sets and drops excluded optionals', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + value: { + allOf: [ + { + type: 'object', + properties: { x: { type: 'string' } }, + required: ['x'], + additionalProperties: false, + }, + { + type: 'object', + properties: { x: { type: 'string' }, y: { type: 'number' } }, + required: ['x'], + additionalProperties: false, + }, + ], + }, + }, + required: ['value'], + }; + + expect(toStrictJsonSchema(schema).properties?.['value']).toEqual({ + type: 'object', + properties: { x: { type: 'string' } }, + required: ['x'], + additionalProperties: false, + }); + }); + + test('rejects closed object allOf intersections that exclude required properties', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + value: { + allOf: [ + { + type: 'object', + properties: { x: { type: 'string' } }, + required: ['x'], + additionalProperties: false, + }, + { + type: 'object', + properties: { x: { type: 'string' }, y: { type: 'number' } }, + required: ['x', 'y'], + additionalProperties: false, + }, + ], + }, + }, + required: ['value'], + }; + + expect(() => toStrictJsonSchema(schema)).toThrow( + 'cannot be merged without changing Draft 7 validation', + ); + }); + test('fails closed on cyclic local ref chains in object allOf variants', () => { const schema: JSONSchema = { type: 'object', From 3ebbe7a9b3bbc1b8645c2debaa8ca6af376039f8 Mon Sep 17 00:00:00 2001 From: Hayden Date: Tue, 21 Jul 2026 23:37:41 +0000 Subject: [PATCH 32/35] fix(helpers): preserve standard schema inference and refs --- src/helpers/standard-schema.ts | 5 +- src/lib/transform.ts | 83 +++++++++++++++++++++ tests/helpers/standard-schema.test.ts | 101 +++++++++++++++++++++++++- 3 files changed, 184 insertions(+), 5 deletions(-) diff --git a/src/helpers/standard-schema.ts b/src/helpers/standard-schema.ts index 5cde39326d..f53651ada6 100644 --- a/src/helpers/standard-schema.ts +++ b/src/helpers/standard-schema.ts @@ -62,9 +62,8 @@ type StandardSchemaLike = { }; }; -type InferStandardOutput = NonNullable< - Schema['~standard']['types'] ->['output']; +type InferStandardOutput = + Schema['~standard'] extends { readonly types: { readonly output: infer Output } } ? Output : unknown; type StandardSchemaJSONSchemaProps = { /** diff --git a/src/lib/transform.ts b/src/lib/transform.ts index 1b0423bfb1..55de2da592 100644 --- a/src/lib/transform.ts +++ b/src/lib/transform.ts @@ -158,6 +158,7 @@ export function toStrictJsonSchema(schema: JSONSchema): JSONSchema { validateRefSchemas(schemaCopy, [], schemaCopy); preserveAllOfRefTargets(schemaCopy); validateRefSchemas(schemaCopy, [], schemaCopy); + rewriteLocalRefsIntoFilteredAnyOfBranches(schemaCopy); // Resolve representable object intersections before recursive // strictification closes referenced definitions. Otherwise a definition // reached through an allOf ref would be closed in isolation before its @@ -1102,6 +1103,88 @@ export function rewriteLocalRefsIntoMovedOneOfBranches(root: JSONSchema): void { rewriteRefs(root); } +/** + * Strictification removes false anyOf alternatives. Rewrite pointers into + * surviving alternatives before that filtering happens so each local ref + * still names the same schema after earlier indices disappear. + */ +function rewriteLocalRefsIntoFilteredAnyOfBranches(root: JSONSchema): void { + const rewriteRef = (ref: string): string => { + const originalParts = parseLocalRef(ref); + if (originalParts === undefined || originalParts.length === 0) { + return ref; + } + + const rewrittenParts = [...originalParts]; + let resolved: unknown = root; + let changed = false; + + for (const [index, part] of originalParts.entries()) { + const resolvedRecord = + typeof resolved === 'object' && resolved !== null && !Array.isArray(resolved) ? + (resolved as Record) + : undefined; + if ( + part === 'anyOf' && + index < originalParts.length - 1 && + resolvedRecord !== undefined && + Array.isArray(resolvedRecord['anyOf']) + ) { + const branches = resolvedRecord['anyOf']; + const branchIndexPart = originalParts[index + 1]!; + if (!/^(?:0|[1-9]\d*)$/.test(branchIndexPart)) { + return ref; + } + + const branchIndex = Number(branchIndexPart); + if (!Object.prototype.hasOwnProperty.call(branches, branchIndex)) { + return ref; + } + + const realBranches = branches.filter((branch) => branch !== false); + if (realBranches.length > 0 && realBranches.length !== branches.length) { + // A ref to a removed false schema is already rejected by the first + // ref-validation pass. Leave it untouched so it remains fail closed. + if (branches[branchIndex] === false) { + return ref; + } + + const rewrittenIndex = branches.slice(0, branchIndex).filter((branch) => branch !== false).length; + if (rewrittenIndex !== branchIndex) { + rewrittenParts[index + 1] = String(rewrittenIndex); + changed = true; + } + } + } + + // Resolve through the original pointer, not the rewritten one, so a + // nested anyOf can also be remapped after its parent index shifts. + resolved = resolvePointerPart(resolved, part); + if (resolved === undefined) { + return ref; + } + } + + return changed ? '#/' + rewrittenParts.map(encodeJSONPointerTokenForURIFragment).join('/') : ref; + }; + + const rewriteRefs = (value: JSONSchemaDefinition): void => { + if (typeof value === 'boolean' || !isObject(value)) { + return; + } + + if (typeof value.$ref === 'string') { + value.$ref = rewriteRef(value.$ref); + } + + forEachJSONSchemaChild(value, [], (child) => { + rewriteRefs(child as JSONSchemaDefinition); + }); + }; + + rewriteRefs(root); +} + /** * Strictification removes every representable allOf. Preserve any schema * referenced through an allOf branch under a stable root definition first so diff --git a/tests/helpers/standard-schema.test.ts b/tests/helpers/standard-schema.test.ts index 64be9a3459..2286916455 100644 --- a/tests/helpers/standard-schema.test.ts +++ b/tests/helpers/standard-schema.test.ts @@ -484,6 +484,73 @@ describe('Standard Schema helpers', () => { } }); + it('rewrites nested refs into shifted false anyOf alternatives across all helper surfaces', () => { + const definitionName = 'union/name~% value'; + const definitionRef = '#/$defs/union~1name~0%25%20value'; + const schemas = strictSchemasForAllHelpers({ + type: 'object', + $defs: { + [definitionName]: { + anyOf: [ + false, + { + type: 'object', + properties: { + nested: { + anyOf: [false, { type: 'string' }], + }, + }, + required: ['nested'], + }, + ], + }, + }, + properties: { + value: { $ref: `${definitionRef}/anyOf/1` }, + nested: { $ref: `${definitionRef}/anyOf/1/properties/nested/anyOf/1` }, + }, + required: ['value', 'nested'], + }); + + for (const schema of schemas) { + expect(schema).toMatchObject({ + $defs: { + [definitionName]: { + anyOf: [ + { + type: 'object', + properties: { + nested: { + anyOf: [{ type: 'string' }], + }, + }, + }, + ], + }, + }, + properties: { + value: { $ref: `${definitionRef}/anyOf/0` }, + nested: { $ref: `${definitionRef}/anyOf/0/properties/nested/anyOf/0` }, + }, + }); + } + }); + + it('keeps refs into removed false anyOf alternatives fail closed across all helper surfaces', () => { + for (const makeSchema of makeStrictSchemaFactories({ + type: 'object', + properties: { + choice: { + anyOf: [false, { type: 'string' }], + }, + alias: { $ref: '#/properties/choice/anyOf/0' }, + }, + required: ['choice', 'alias'], + })) { + expect(makeSchema).toThrow('Expected object schema but got boolean'); + } + }); + it('updates refs into oneOf branches after moving them to anyOf', () => { const { standardSchema } = makeStandardSchema({ type: 'object', @@ -1643,8 +1710,12 @@ describe('Standard Schema helpers', () => { function _typeTests() { const { standardSchema } = makeStandardSchema(); - expectType(standardResponseFormat(standardSchema, 'weather').__output); - expectType(standardTextFormat(standardSchema, 'weather').__output); + const responseFormat = standardResponseFormat(standardSchema, 'weather'); + const textFormat = standardTextFormat(standardSchema, 'weather'); + expectType(responseFormat.__output); + expectType(textFormat.__output); + compareType(true); + compareType(true); const chatTool = standardFunction({ name: 'get_weather', @@ -1670,9 +1741,35 @@ function _typeTests() { }, }, }; + const typelessStandardSchema = { + '~standard': { + version: 1 as const, + vendor: 'test', + validate: validateWeather, + jsonSchema: { + input: () => weatherJSONSchema as unknown as Record, + }, + }, + }; + const typelessResponseFormat = standardResponseFormat(typelessStandardSchema, 'weather'); + const typelessTextFormat = standardTextFormat(typelessStandardSchema, 'weather'); + const typelessChatTool = standardFunction({ + name: 'get_weather', + parameters: typelessStandardSchema, + function: (args) => compareType(true), + }); + const typelessResponseTool = standardResponsesFunction({ + name: 'get_weather', + parameters: typelessStandardSchema, + function: (args) => compareType(true), + }); compareType, WeatherOutput>(true); compareType, WeatherOutput>(true); + compareType(true); + compareType(true); + compareType, unknown>(true); + compareType, unknown>(true); expectType(chatTool.__hasFunction); expectType(callbacklessChatTool.__hasFunction); // @ts-expect-error callback-less tools cannot be passed to runTools From e5c4c1869c0463c960d24ee60b8d2647f5016d10 Mon Sep 17 00:00:00 2001 From: Hayden Date: Wed, 22 Jul 2026 19:47:54 +0000 Subject: [PATCH 33/35] fix(helpers): address standard schema review feedback --- src/helpers/standard-schema.ts | 26 ++++- src/lib/transform.ts | 63 ++++++++++- tests/helpers/standard-schema.test.ts | 147 ++++++++++++++++++++++++++ 3 files changed, 230 insertions(+), 6 deletions(-) diff --git a/src/helpers/standard-schema.ts b/src/helpers/standard-schema.ts index f53651ada6..28c9eb75eb 100644 --- a/src/helpers/standard-schema.ts +++ b/src/helpers/standard-schema.ts @@ -63,7 +63,9 @@ type StandardSchemaLike = { }; type InferStandardOutput = - Schema['~standard'] extends { readonly types: { readonly output: infer Output } } ? Output : unknown; + [NonNullable] extends [never] ? unknown + : NonNullable extends { readonly output: infer Output } ? Output + : unknown; type StandardSchemaJSONSchemaProps = { /** @@ -133,6 +135,9 @@ function getSchemaTypes(schema: unknown): Set | undefined { if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return undefined; const type = (schema as Record)['type']; + if (type === undefined) { + return getLiteralSchemaTypes(schema); + } const types = Array.isArray(type) ? type : [type]; if ( types.length === 0 || @@ -169,6 +174,18 @@ function getLiteralValues(schema: unknown): JSONPrimitive[] | undefined { return undefined; } +function getLiteralSchemaTypes(schema: unknown): Set | undefined { + const literalValues = getLiteralValues(schema); + if (!literalValues) return undefined; + + return new Set( + literalValues.map((value) => { + if (value === null) return 'null'; + return typeof value; + }), + ); +} + function haveDisjointLiteralValues(left: unknown, right: unknown): boolean { const leftValues = getLiteralValues(left); const rightValues = getLiteralValues(right); @@ -367,7 +384,12 @@ function normalizeStructuredOutputSchema(schema: JSONSchema): JSONSchema { 'Standard JSON Schema generated both `anyOf` and `oneOf`, which cannot be represented in an OpenAI strict schema', ); } - if (!areOneOfBranchesMutuallyExclusive(record['oneOf'], normalizedSchema)) { + // `false` can never validate, so it cannot overlap another oneOf + // branch. Keep it in place until the existing anyOf normalization runs + // so local refs into surviving branch indices can be rewritten before + // the impossible alternatives are removed. + const possibleBranches = record['oneOf'].filter((branch) => branch !== false); + if (!areOneOfBranchesMutuallyExclusive(possibleBranches, normalizedSchema)) { throw new OpenAIError( 'Standard JSON Schema generated a `oneOf` whose branches are not provably mutually exclusive. OpenAI strict schemas do not support `oneOf`; use `anyOf` or add a discriminator with distinct literal values.', ); diff --git a/src/lib/transform.ts b/src/lib/transform.ts index 55de2da592..9a8895af99 100644 --- a/src/lib/transform.ts +++ b/src/lib/transform.ts @@ -49,6 +49,7 @@ const JSON_SCHEMA_MAP_SCHEMA_KEYWORDS = [ ]; const JSON_SCHEMA_UNSUPPORTED_SCHEMA_KEYWORDS = new Set([ + '$anchor', '$dynamicAnchor', '$dynamicRef', '$recursiveAnchor', @@ -209,8 +210,9 @@ function normalizeRootRefAndAllOf(schema: JSONSchema): void { inlineRootRefObject(schema); preserveAllOfRefTargets(schema, true); normalizeRootAllOf(schema); + const normalizedAnyOf = normalizeRootAnyOf(schema); - if (schema.$ref === undefined) { + if (schema.$ref === undefined && !normalizedAnyOf) { return; } } @@ -359,6 +361,34 @@ function normalizeRootAllOf(schema: JSONSchema): void { } } +/** + * A singleton root anyOf with no validating siblings is equivalent to its + * only branch. Flatten it before the root-union check so converters that + * retain a redundant object wrapper can still produce a strict root object. + */ +function normalizeRootAnyOf(schema: JSONSchema): boolean { + const anyOf = schema.anyOf; + if (!Array.isArray(anyOf) || anyOf.length !== 1 || !hasOnlyRootAnyOfMetadataSiblings(schema)) { + return false; + } + + const branch = anyOf[0]; + if (typeof branch === 'boolean' || !isObject(branch) || !isObjectOnlySchema(branch, schema)) { + return false; + } + + const rootMetadata = { ...schema }; + delete rootMetadata.anyOf; + const normalized = structuredClone(branch); + const schemaRecord = schema as Record; + + for (const keyword of Object.keys(schema)) { + delete schemaRecord[keyword]; + } + Object.assign(schema, normalized, rootMetadata); + return true; +} + function assertLocalRootRef(ref: unknown): asserts ref is string { if (typeof ref !== 'string') { throw new TypeError('Received non-string $ref - ' + String(ref) + '; path='); @@ -904,6 +934,18 @@ function hasOnlyRootAllOfMetadataSiblings(schema: JSONSchema): boolean { ); } +function hasOnlyRootAnyOfMetadataSiblings(schema: JSONSchema): boolean { + return Object.keys(schema).every( + (keyword) => + keyword === 'anyOf' || + keyword === '$defs' || + keyword === 'definitions' || + (keyword === 'type' && schema.type === 'object') || + JSON_SCHEMA_ROOT_METADATA_KEYWORDS.has(keyword) || + JSON_SCHEMA_ANNOTATION_KEYWORDS.has(keyword), + ); +} + function hasObjectKeywords(schema: JSONSchema): boolean { return Object.keys(schema).some((keyword) => JSON_SCHEMA_OBJECT_KEYWORDS.has(keyword)); } @@ -1556,10 +1598,12 @@ function mergeObjectAllOf( const mergeAnnotations = (schema: JSONSchema) => { for (const keyword of JSON_SCHEMA_ANNOTATION_KEYWORDS) { if (!(keyword in schema)) continue; - if (keyword in merged && !schemasEqual((merged as any)[keyword], (schema as any)[keyword])) { - fail(); + // Annotation keywords do not affect Draft 7 validation. Preserve the + // first value (the outer schema, then earlier branches) instead of + // rejecting an otherwise exactly mergeable intersection. + if (!(keyword in merged)) { + (merged as any)[keyword] = (schema as any)[keyword]; } - (merged as any)[keyword] = (schema as any)[keyword]; } }; @@ -1642,6 +1686,17 @@ function mergeObjectAllOf( allowedClosedProperties !== undefined && [...mergedRequired].some((key) => !allowedClosedProperties.has(key)) ) { + // Object keywords do not constrain null. If every explicit object type + // also admits null, the object portion is contradictory but null remains + // an exact representation of the intersection. + if (!hasExplicitObjectType && hasExplicitNullableObjectType) { + merged.type = 'null'; + for (const keyword of Object.keys(jsonSchema)) { + delete (jsonSchema as any)[keyword]; + } + Object.assign(jsonSchema, merged); + return true; + } fail(); } diff --git a/tests/helpers/standard-schema.test.ts b/tests/helpers/standard-schema.test.ts index 2286916455..2011f9bc5c 100644 --- a/tests/helpers/standard-schema.test.ts +++ b/tests/helpers/standard-schema.test.ts @@ -7,6 +7,7 @@ import { import type OpenAI from 'openai'; import type { JSONSchema } from 'openai/lib/jsonschema'; import { compareType, expectType } from '../utils/typing'; +import { z as zv4 } from 'zod/v4'; declare const runTools: OpenAI['chat']['completions']['runTools']; @@ -484,6 +485,36 @@ describe('Standard Schema helpers', () => { } }); + it('removes false oneOf alternatives across all helper surfaces but keeps boolean unions fail closed', () => { + const schemas = strictSchemasForAllHelpers({ + type: 'object', + properties: { + choice: { + oneOf: [false, { type: 'string' }], + }, + }, + required: ['choice'], + }); + + for (const schema of schemas) { + expect((schema as JSONSchema).properties?.['choice']).toEqual({ + anyOf: [{ type: 'string' }], + }); + } + + for (const makeSchema of makeStrictSchemaFactories({ + type: 'object', + properties: { + choice: { + oneOf: [false, false], + }, + }, + required: ['choice'], + })) { + expect(makeSchema).toThrow('Expected object schema but got boolean'); + } + }); + it('rewrites nested refs into shifted false anyOf alternatives across all helper surfaces', () => { const definitionName = 'union/name~% value'; const definitionRef = '#/$defs/union~1name~0%25%20value'; @@ -573,6 +604,24 @@ describe('Standard Schema helpers', () => { }); }); + it('infers literal types when proving oneOf exclusivity across all helper surfaces', () => { + const schemas = strictSchemasForAllHelpers({ + type: 'object', + properties: { + choice: { + oneOf: [{ const: 'foo' }, { type: 'number' }], + }, + }, + required: ['choice'], + }); + + for (const schema of schemas) { + expect((schema as JSONSchema).properties?.['choice']).toEqual({ + anyOf: [{ const: 'foo' }, { type: 'number' }], + }); + } + }); + it('re-encodes rewritten local refs after moving oneOf branches', () => { const definitionName = 'folder/name~% value'; const { standardSchema } = makeStandardSchema({ @@ -1475,6 +1524,70 @@ describe('Standard Schema helpers', () => { }); }); + it('preserves the first conflicting allOf annotation across all helper surfaces', () => { + const schemas = strictSchemasForAllHelpers({ + type: 'object', + properties: { + choice: { + allOf: [ + { + type: 'object', + description: 'first description', + properties: { value: { type: 'string' } }, + required: ['value'], + }, + { + type: 'object', + description: 'second description', + title: 'second title', + properties: { value: { type: 'string' } }, + required: ['value'], + }, + ], + }, + }, + required: ['choice'], + }); + + for (const schema of schemas) { + expect((schema as JSONSchema).properties?.['choice']).toEqual({ + type: 'object', + description: 'first description', + title: 'second title', + properties: { value: { type: 'string' } }, + required: ['value'], + additionalProperties: false, + }); + } + }); + + it('collapses contradictory nullable object allOf branches to null across all helper surfaces', () => { + const schemas = strictSchemasForAllHelpers({ + type: 'object', + properties: { + choice: { + allOf: [ + { + type: ['object', 'null'], + properties: {}, + additionalProperties: false, + }, + { + type: ['object', 'null'], + properties: { value: { type: 'string' } }, + required: ['value'], + }, + ], + }, + }, + required: ['choice'], + }); + + for (const schema of schemas) { + expect((schema as JSONSchema).properties?.['choice']).toEqual({ type: 'null' }); + } + }); + it('rejects patternProperties before returning a strict schema', () => { const { standardSchema } = makeStandardSchema({ type: 'object', @@ -1520,6 +1633,7 @@ describe('Standard Schema helpers', () => { }); it.each([ + ['$anchor', 'node'], ['$recursiveRef', '#'], ['$recursiveAnchor', true], ] as const)('rejects unsupported %s across all helper surfaces', (keyword, value) => { @@ -1595,6 +1709,30 @@ describe('Standard Schema helpers', () => { ); }); + it('flattens singleton root anyOf object wrappers across all helper surfaces', () => { + const schemas = strictSchemasForAllHelpers({ + type: 'object', + description: 'root description', + anyOf: [ + { + type: 'object', + properties: { value: { type: 'string' } }, + required: ['value'], + }, + ], + }); + + for (const schema of schemas) { + expect(schema).toEqual({ + type: 'object', + description: 'root description', + properties: { value: { type: 'string' } }, + required: ['value'], + additionalProperties: false, + }); + } + }); + it('rejects unsupported nested schema keywords before returning a strict schema', () => { const { standardSchema } = makeStandardSchema({ type: 'object', @@ -1709,6 +1847,7 @@ describe('Standard Schema helpers', () => { function _typeTests() { const { standardSchema } = makeStandardSchema(); + const vendorStandardSchema = zv4.object({ city: zv4.string() }); const responseFormat = standardResponseFormat(standardSchema, 'weather'); const textFormat = standardTextFormat(standardSchema, 'weather'); @@ -1763,6 +1902,12 @@ function _typeTests() { parameters: typelessStandardSchema, function: (args) => compareType(true), }); + const vendorResponseFormat = standardResponseFormat(vendorStandardSchema, 'weather'); + const vendorChatTool = standardFunction({ + name: 'get_weather', + parameters: vendorStandardSchema, + function: (args) => expectType<{ city: string }>(args), + }); compareType, WeatherOutput>(true); compareType, WeatherOutput>(true); @@ -1770,6 +1915,8 @@ function _typeTests() { compareType(true); compareType, unknown>(true); compareType, unknown>(true); + compareType(true); + compareType, { city: string }>(true); expectType(chatTool.__hasFunction); expectType(callbacklessChatTool.__hasFunction); // @ts-expect-error callback-less tools cannot be passed to runTools From 2fe00db58e1b86ad0b423cb09b53537846f2ce88 Mon Sep 17 00:00:00 2001 From: Hayden Date: Wed, 22 Jul 2026 20:02:48 +0000 Subject: [PATCH 34/35] fix(helpers): preserve strict schema refs --- src/lib/transform.ts | 159 ++++++++++++++++++++++++-- tests/helpers/standard-schema.test.ts | 66 +++++++++++ 2 files changed, 214 insertions(+), 11 deletions(-) diff --git a/src/lib/transform.ts b/src/lib/transform.ts index 9a8895af99..d1f788ecb9 100644 --- a/src/lib/transform.ts +++ b/src/lib/transform.ts @@ -377,6 +377,7 @@ function normalizeRootAnyOf(schema: JSONSchema): boolean { return false; } + rewriteLocalRefsIntoPromotedRootAnyOfBranch(schema); const rootMetadata = { ...schema }; delete rootMetadata.anyOf; const normalized = structuredClone(branch); @@ -389,6 +390,41 @@ function normalizeRootAnyOf(schema: JSONSchema): boolean { return true; } +/** + * Promoting a singleton root anyOf branch removes the original anyOf/0 + * pointer prefix. Rewrite refs through that prefix while the old tree still + * exists so the promoted schema keeps naming the same targets. + */ +function rewriteLocalRefsIntoPromotedRootAnyOfBranch(root: JSONSchema): void { + const rewriteRef = (ref: string): string => { + const parts = parseLocalRef(ref); + if (parts === undefined || parts[0] !== 'anyOf' || parts[1] !== '0') { + return ref; + } + + const promotedParts = parts.slice(2); + return promotedParts.length === 0 ? + '#' + : '#/' + promotedParts.map(encodeJSONPointerTokenForURIFragment).join('/'); + }; + + const rewriteRefs = (value: JSONSchemaDefinition): void => { + if (typeof value === 'boolean' || !isObject(value)) { + return; + } + + if (typeof value.$ref === 'string') { + value.$ref = rewriteRef(value.$ref); + } + + forEachJSONSchemaChild(value, [], (child) => { + rewriteRefs(child as JSONSchemaDefinition); + }); + }; + + rewriteRefs(root); +} + function assertLocalRootRef(ref: unknown): asserts ref is string { if (typeof ref !== 'string') { throw new TypeError('Received non-string $ref - ' + String(ref) + '; path='); @@ -1296,6 +1332,83 @@ function preserveAllOfRefTargets(root: JSONSchema, rootOnly = false): void { rewriteRefs(root); } +/** + * Closed allOf merges can discard optional property declarations. Preserve + * only local refs into declarations that are about to disappear, then rewrite + * those refs to stable root definitions before the merge removes their paths. + */ +function preserveDiscardedAllOfPropertyRefTargets(root: JSONSchema, discardedPaths: string[][]): void { + if (discardedPaths.length === 0) { + return; + } + + const refsToPreserve = new Set(); + const collectRefs = (value: JSONSchemaDefinition): void => { + if (typeof value === 'boolean' || !isObject(value)) { + return; + } + + if (typeof value.$ref === 'string') { + const parts = parseLocalRef(value.$ref); + if ( + parts !== undefined && + discardedPaths.some( + (discardedPath) => + parts.length >= discardedPath.length && + discardedPath.every((part, index) => parts[index] === part), + ) + ) { + refsToPreserve.add(value.$ref); + } + } + + forEachJSONSchemaChild(value, [], (child) => { + collectRefs(child as JSONSchemaDefinition); + }); + }; + collectRefs(root); + + if (refsToPreserve.size === 0) { + return; + } + + if (root.$defs !== undefined && !isObject(root.$defs)) { + throw new Error('Root schema has invalid $defs and cannot preserve discarded allOf properties.'); + } + const definitions = (root.$defs ??= {}); + const rewrittenRefs = new Map(); + let aliasIndex = 0; + + for (const ref of refsToPreserve) { + const target = resolveLocalRef(root, ref); + if (!isSchemaDefinition(target)) { + throw new Error('Local $ref cannot be preserved before allOf property removal: ' + JSON.stringify(ref)); + } + + let alias = '__openai_strict_allOf_property_ref_' + aliasIndex++; + while (Object.prototype.hasOwnProperty.call(definitions, alias)) { + alias = '__openai_strict_allOf_property_ref_' + aliasIndex++; + } + definitions[alias] = structuredClone(target); + rewrittenRefs.set(ref, '#/$defs/' + escapeJSONPointerToken(alias)); + } + + const rewriteRefs = (value: JSONSchemaDefinition): void => { + if (typeof value === 'boolean' || !isObject(value)) { + return; + } + + if (typeof value.$ref === 'string') { + value.$ref = rewrittenRefs.get(value.$ref) ?? value.$ref; + } + + forEachJSONSchemaChild(value, [], (child) => { + rewriteRefs(child as JSONSchemaDefinition); + }); + }; + rewriteRefs(root); +} + function validateRefSchemas(schema: JSONSchemaDefinition, path: string[], root: JSONSchema): void { if (typeof schema === 'boolean' || !isObject(schema)) { return; @@ -1548,9 +1661,9 @@ function mergeObjectAllOf( } } - const branches: JSONSchema[] = []; + const branches: Array<{ schema: JSONSchema; sourcePath: string[] | undefined }> = []; if (parentHasObjectShape) { - branches.push(jsonSchema); + branches.push({ schema: jsonSchema, sourcePath: path }); } for (const [index, entry] of allOf.entries()) { if (!isObject(entry)) { @@ -1566,7 +1679,10 @@ function mergeObjectAllOf( // so a valid definitions-only branch can now be discarded like an // annotation-only branch. if (hasObjectShapeWithoutAllOf(branch)) { - branches.push(branch); + branches.push({ + schema: branch, + sourcePath: branch === entry ? [...path, 'allOf', String(index)] : undefined, + }); } else if (!hasOnlyNeutralAllOfBranchKeywords(branch)) { fail(); } @@ -1589,7 +1705,11 @@ function mergeObjectAllOf( const mergedProperties = Object.create(null) as Record; const mergedRequired = new Set(); const closedPropertySets: Set[] = []; - const propertyEntries: Array<[string, JSONSchemaDefinition]> = []; + const propertyEntries: Array<{ + key: string; + propertySchema: JSONSchemaDefinition; + sourcePath: string[] | undefined; + }> = []; let sawProperties = false; let sawRequired = false; let hasExplicitObjectType = false; @@ -1615,7 +1735,7 @@ function mergeObjectAllOf( } } - for (const branch of branches) { + for (const { schema: branch, sourcePath } of branches) { for (const keyword of Object.keys(branch)) { if (keyword === 'allOf' && branch === jsonSchema) continue; if ( @@ -1649,7 +1769,11 @@ function mergeObjectAllOf( } sawProperties = true; for (const [key, propertySchema] of Object.entries(branch.properties)) { - propertyEntries.push([key, propertySchema]); + propertyEntries.push({ + key, + propertySchema, + sourcePath: sourcePath === undefined ? undefined : [...sourcePath, 'properties', key], + }); } } @@ -1682,14 +1806,27 @@ function mergeObjectAllOf( (allowed, keys) => new Set([...allowed].filter((key) => keys.has(key))), new Set(closedPropertySets[0]), ); - if ( + const excludesRequiredProperty = allowedClosedProperties !== undefined && - [...mergedRequired].some((key) => !allowedClosedProperties.has(key)) - ) { + [...mergedRequired].some((key) => !allowedClosedProperties.has(key)); + const collapsesToNull = excludesRequiredProperty && !hasExplicitObjectType && hasExplicitNullableObjectType; + const discardedPropertyPaths = propertyEntries + .filter( + ({ key, sourcePath }) => + sourcePath !== undefined && + (collapsesToNull || (allowedClosedProperties !== undefined && !allowedClosedProperties.has(key))), + ) + .map(({ sourcePath }) => sourcePath!); + preserveDiscardedAllOfPropertyRefTargets(root, discardedPropertyPaths); + if (jsonSchema === root && root.$defs !== undefined) { + merged.$defs = root.$defs; + } + + if (excludesRequiredProperty) { // Object keywords do not constrain null. If every explicit object type // also admits null, the object portion is contradictory but null remains // an exact representation of the intersection. - if (!hasExplicitObjectType && hasExplicitNullableObjectType) { + if (collapsesToNull) { merged.type = 'null'; for (const keyword of Object.keys(jsonSchema)) { delete (jsonSchema as any)[keyword]; @@ -1700,7 +1837,7 @@ function mergeObjectAllOf( fail(); } - for (const [key, propertySchema] of propertyEntries) { + for (const { key, propertySchema } of propertyEntries) { if (allowedClosedProperties !== undefined && !allowedClosedProperties.has(key)) { continue; } diff --git a/tests/helpers/standard-schema.test.ts b/tests/helpers/standard-schema.test.ts index 2011f9bc5c..9dfd963f7c 100644 --- a/tests/helpers/standard-schema.test.ts +++ b/tests/helpers/standard-schema.test.ts @@ -1076,6 +1076,43 @@ describe('Standard Schema helpers', () => { } }); + it('preserves refs to optional allOf properties discarded by closed branches', () => { + const schemas = strictSchemasForAllHelpers({ + type: 'object', + properties: { + wrapper: { + type: 'object', + properties: { x: { type: 'string' } }, + allOf: [ + { + type: 'object', + properties: {}, + additionalProperties: false, + }, + ], + }, + alias: { $ref: '#/properties/wrapper/properties/x' }, + }, + required: ['wrapper', 'alias'], + }); + + for (const schema of schemas) { + expect(schema).toMatchObject({ + $defs: { + __openai_strict_allOf_property_ref_0: { type: 'string' }, + }, + properties: { + wrapper: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + alias: { $ref: '#/$defs/__openai_strict_allOf_property_ref_0' }, + }, + }); + } + }); + it('rejects closed allOf property sets that exclude required properties across all helper surfaces', () => { for (const makeSchema of makeStrictSchemaFactories({ type: 'object', @@ -1733,6 +1770,35 @@ describe('Standard Schema helpers', () => { } }); + it('rewrites refs into flattened singleton root anyOf branches across all helper surfaces', () => { + const schemas = strictSchemasForAllHelpers({ + type: 'object', + anyOf: [ + { + type: 'object', + $defs: { + Value: { type: 'string' }, + }, + properties: { + value: { $ref: '#/anyOf/0/$defs/Value' }, + }, + required: ['value'], + }, + ], + }); + + for (const schema of schemas) { + expect(schema).toMatchObject({ + $defs: { + Value: { type: 'string' }, + }, + properties: { + value: { $ref: '#/$defs/Value' }, + }, + }); + } + }); + it('rejects unsupported nested schema keywords before returning a strict schema', () => { const { standardSchema } = makeStandardSchema({ type: 'object', From 345f5274a29ab4934bb68b52b4aaeb2df6ed2274 Mon Sep 17 00:00:00 2001 From: Hayden Date: Wed, 22 Jul 2026 20:45:18 +0000 Subject: [PATCH 35/35] fix(helpers): handle strict schema normalization edge cases --- src/lib/transform.ts | 113 +++++++++++++++++++++-- tests/helpers/standard-schema.test.ts | 128 ++++++++++++++++++++++++++ 2 files changed, 235 insertions(+), 6 deletions(-) diff --git a/src/lib/transform.ts b/src/lib/transform.ts index d1f788ecb9..b4c90f54be 100644 --- a/src/lib/transform.ts +++ b/src/lib/transform.ts @@ -368,19 +368,48 @@ function normalizeRootAllOf(schema: JSONSchema): void { */ function normalizeRootAnyOf(schema: JSONSchema): boolean { const anyOf = schema.anyOf; - if (!Array.isArray(anyOf) || anyOf.length !== 1 || !hasOnlyRootAnyOfMetadataSiblings(schema)) { + if (!Array.isArray(anyOf) || !hasOnlyRootAnyOfMetadataSiblings(schema)) { return false; } - const branch = anyOf[0]; + // `false` contributes no instances to a union. Keep the original array in + // place until promotion so refs into the surviving branch can be rewritten + // from their original index, while refs into removed false branches become + // dangling and remain fail closed after the wrapper disappears. + const realBranches = anyOf + .map((branch, index) => ({ branch, index })) + .filter(({ branch }) => branch !== false); + if (realBranches.length !== 1) { + return false; + } + + const { branch, index: branchIndex } = realBranches[0]!; if (typeof branch === 'boolean' || !isObject(branch) || !isObjectOnlySchema(branch, schema)) { return false; } - rewriteLocalRefsIntoPromotedRootAnyOfBranch(schema); + const definitionRenames = planPromotedRootAnyOfDefinitionRenames(schema, branch); + rewriteLocalRefsIntoPromotedRootAnyOfBranch(schema, branchIndex, definitionRenames); const rootMetadata = { ...schema }; delete rootMetadata.anyOf; const normalized = structuredClone(branch); + + for (const keyword of ['$defs', 'definitions'] as const) { + const rootDefinitions = schema[keyword]; + const branchDefinitions = normalized[keyword]; + if (!isObject(rootDefinitions) || !isObject(branchDefinitions)) { + continue; + } + + const renames = definitionRenames.get(keyword); + const mergedDefinitions: Record = { ...rootDefinitions }; + for (const [name, definition] of Object.entries(branchDefinitions)) { + mergedDefinitions[renames?.get(name) ?? name] = definition as JSONSchemaDefinition; + } + normalized[keyword] = mergedDefinitions; + delete rootMetadata[keyword]; + } + const schemaRecord = schema as Record; for (const keyword of Object.keys(schema)) { @@ -390,19 +419,80 @@ function normalizeRootAnyOf(schema: JSONSchema): boolean { return true; } +type RootDefinitionKeyword = '$defs' | 'definitions'; +type PromotedRootAnyOfDefinitionRenames = Map>; + /** - * Promoting a singleton root anyOf branch removes the original anyOf/0 + * Root and promoted branch definition maps occupy the same pointer after + * promotion. Give conflicting branch definitions stable aliases before refs + * are rewritten so neither original target is rebound. + */ +function planPromotedRootAnyOfDefinitionRenames( + root: JSONSchema, + branch: JSONSchema, +): PromotedRootAnyOfDefinitionRenames { + const renames: PromotedRootAnyOfDefinitionRenames = new Map(); + + for (const keyword of ['$defs', 'definitions'] as const) { + const rootDefinitions = root[keyword]; + const branchDefinitions = branch[keyword]; + if (!isObject(rootDefinitions) || !isObject(branchDefinitions)) { + continue; + } + + const usedNames = new Set([...Object.keys(rootDefinitions), ...Object.keys(branchDefinitions)]); + const keywordRenames = new Map(); + let aliasIndex = 0; + + for (const [name, definition] of Object.entries(branchDefinitions)) { + if ( + !Object.prototype.hasOwnProperty.call(rootDefinitions, name) || + schemasEqual(rootDefinitions[name], definition) + ) { + continue; + } + + let alias = '__openai_strict_anyOf_definition_' + aliasIndex++; + while (usedNames.has(alias)) { + alias = '__openai_strict_anyOf_definition_' + aliasIndex++; + } + usedNames.add(alias); + keywordRenames.set(name, alias); + } + + if (keywordRenames.size > 0) { + renames.set(keyword, keywordRenames); + } + } + + return renames; +} + +/** + * Promoting a singleton root anyOf branch removes the original anyOf/index * pointer prefix. Rewrite refs through that prefix while the old tree still * exists so the promoted schema keeps naming the same targets. */ -function rewriteLocalRefsIntoPromotedRootAnyOfBranch(root: JSONSchema): void { +function rewriteLocalRefsIntoPromotedRootAnyOfBranch( + root: JSONSchema, + branchIndex: number, + definitionRenames: PromotedRootAnyOfDefinitionRenames, +): void { const rewriteRef = (ref: string): string => { const parts = parseLocalRef(ref); - if (parts === undefined || parts[0] !== 'anyOf' || parts[1] !== '0') { + if (parts === undefined || parts[0] !== 'anyOf' || parts[1] !== String(branchIndex)) { return ref; } const promotedParts = parts.slice(2); + const definitionKeyword = promotedParts[0]; + if (promotedParts.length > 1 && (definitionKeyword === '$defs' || definitionKeyword === 'definitions')) { + const renamed = definitionRenames.get(definitionKeyword)?.get(promotedParts[1]!); + if (renamed !== undefined) { + promotedParts[1] = renamed; + } + } + return promotedParts.length === 0 ? '#' : '#/' + promotedParts.map(encodeJSONPointerTokenForURIFragment).join('/'); @@ -1605,6 +1695,17 @@ function mergeObjectAllOf( return false; } + // Intersections are idempotent. Collapse structurally identical branches + // before object-shape classification so duplicate scalar and array schemas + // can reach the existing safe singleton-flattening path. + const uniqueBranches = allOf.filter( + (branch, index) => !allOf.slice(0, index).some((candidate) => schemasEqual(candidate, branch)), + ); + if (uniqueBranches.length !== allOf.length) { + jsonSchema.allOf = uniqueBranches; + return true; + } + // `true` is the identity element for Draft 7 intersections. Remove it // before deciding whether the remaining branches are object-mergeable so a // neutral branch cannot make an otherwise exact merge fail closed. diff --git a/tests/helpers/standard-schema.test.ts b/tests/helpers/standard-schema.test.ts index 9dfd963f7c..cef98b5281 100644 --- a/tests/helpers/standard-schema.test.ts +++ b/tests/helpers/standard-schema.test.ts @@ -455,6 +455,31 @@ describe('Standard Schema helpers', () => { } }); + it('deduplicates identical non-object allOf branches across all helper surfaces', () => { + const schemas = strictSchemasForAllHelpers({ + type: 'object', + properties: { + scalar: { + allOf: [{ type: 'string' }, { type: 'string' }], + }, + array: { + allOf: [ + { type: 'array', items: { type: 'string' } }, + { items: { type: 'string' }, type: 'array' }, + ], + }, + }, + required: ['scalar', 'array'], + }); + + for (const schema of schemas) { + expect((schema as JSONSchema).properties).toEqual({ + scalar: { type: 'string' }, + array: { type: 'array', items: { type: 'string' } }, + }); + } + }); + it('removes false anyOf alternatives across all helper surfaces but keeps boolean unions fail closed', () => { const schemas = strictSchemasForAllHelpers({ type: 'object', @@ -1799,6 +1824,109 @@ describe('Standard Schema helpers', () => { } }); + it.each(['anyOf', 'oneOf'] as const)( + 'filters false root %s alternatives before singleton promotion across all helper surfaces', + (keyword) => { + const schemas = strictSchemasForAllHelpers({ + type: 'object', + [keyword]: [ + false, + { + type: 'object', + $defs: { + Value: { type: 'string' }, + }, + properties: { + value: { $ref: `#/${keyword}/1/$defs/Value` }, + }, + required: ['value'], + }, + ], + }); + + for (const schema of schemas) { + expect(schema).toEqual({ + type: 'object', + $defs: { + Value: { type: 'string' }, + }, + properties: { + value: { $ref: '#/$defs/Value' }, + }, + required: ['value'], + additionalProperties: false, + }); + } + }, + ); + + it.each(['anyOf', 'oneOf'] as const)( + 'keeps refs into removed false root %s alternatives fail closed across all helper surfaces', + (keyword) => { + for (const makeSchema of makeStrictSchemaFactories({ + type: 'object', + [keyword]: [ + false, + { + type: 'object', + properties: { + value: { $ref: `#/${keyword}/0` }, + }, + required: ['value'], + }, + ], + })) { + expect(makeSchema).toThrow('does not resolve to an object or boolean schema'); + } + }, + ); + + it.each(['$defs', 'definitions'] as const)( + 'preserves colliding root and promoted branch %s maps across all helper surfaces', + (keyword) => { + const schemas = strictSchemasForAllHelpers({ + type: 'object', + [keyword]: { + Value: { type: 'number' }, + RootOnly: { type: 'boolean' }, + }, + anyOf: [ + { + type: 'object', + [keyword]: { + Value: { type: 'string' }, + BranchOnly: { type: 'integer' }, + }, + properties: { + branchValue: { $ref: `#/anyOf/0/${keyword}/Value` }, + branchOnly: { $ref: `#/anyOf/0/${keyword}/BranchOnly` }, + rootValue: { $ref: `#/${keyword}/Value` }, + rootOnly: { $ref: `#/${keyword}/RootOnly` }, + }, + required: ['branchValue', 'branchOnly', 'rootValue', 'rootOnly'], + }, + ], + }); + + for (const schema of schemas) { + expect(schema).toMatchObject({ + [keyword]: { + Value: { type: 'number' }, + RootOnly: { type: 'boolean' }, + __openai_strict_anyOf_definition_0: { type: 'string' }, + BranchOnly: { type: 'integer' }, + }, + properties: { + branchValue: { $ref: `#/${keyword}/__openai_strict_anyOf_definition_0` }, + branchOnly: { $ref: `#/${keyword}/BranchOnly` }, + rootValue: { $ref: `#/${keyword}/Value` }, + rootOnly: { $ref: `#/${keyword}/RootOnly` }, + }, + }); + } + }, + ); + it('rejects unsupported nested schema keywords before returning a strict schema', () => { const { standardSchema } = makeStandardSchema({ type: 'object',