diff --git a/jsr.json b/jsr.json index c31c1a9ed..947c933dd 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 000000000..28c9eb75e --- /dev/null +++ b/src/helpers/standard-schema.ts @@ -0,0 +1,570 @@ +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 { + assertNoNestedSchemaIds, + forEachJSONSchemaChild, + hasOnlyRefAndAnnotations, + normalizeObjectAllOfForExclusivity, + resolveLocalRef, + rewriteLocalRefsIntoMovedOneOfBranches, + 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: 'draft-07'; + 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] extends [never] ? unknown + : NonNullable extends { readonly output: infer Output } ? Output + : unknown; + +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 StandardToolFunction = ( + args: InferStandardOutput, +) => unknown | Promise; + +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?: 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'; +} + +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('; '); +} + +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']; + if (type === undefined) { + return getLiteralSchemaTypes(schema); + } + 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 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); + 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, root: JSONSchema): 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( + resolveLocalRefForExclusivity((leftProperties as Record)[property], root), + resolveLocalRefForExclusivity((rightProperties as Record)[property], root), + ) + ) { + return true; + } + } + + 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); + if ( + leftTypes && + rightTypes && + [...leftTypes].every((leftType) => + [...rightTypes].every((rightType) => !schemaTypesOverlap(leftType, rightType)), + ) + ) { + return true; + } + + return ( + haveDisjointLiteralValues(left, right) || + haveDisjointObjectDiscriminator(left, right, root) || + haveDisjointClosedObjectPropertySets(left, right) + ); +} + +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) { + // 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; + + const resolved = resolveLocalRef(root, ref); + if (resolved === undefined) return undefined; + + return resolveLocalRefForExclusivity(resolved, root, new Set([...seenRefs, ref])); + } + + if (record['allOf'] !== undefined) { + if (!Array.isArray(record['allOf'])) return undefined; + 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; +} + +function areOneOfBranchesMutuallyExclusive(branches: unknown[], root: JSONSchema): boolean { + for (let index = 0; index < branches.length; index++) { + 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, root)) { + return false; + } + } + } + + return true; +} + +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( + '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', + ); + } + // `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.', + ); + } + 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; +} + +function parseStandardSchema( + standardSchema: Schema, + content: string, +): InferStandardOutput { + 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.', + ); + } + + 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(normalizeStructuredOutputSchema(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< + 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 | undefined>>; +export function standardFunction( + options: StandardToolOptions, +) { + 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< + 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< + StandardToolReturnOptions | undefined> +>; +export function standardResponsesFunction( + options: StandardToolOptions, +) { + 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/ResponsesParser.ts b/src/lib/ResponsesParser.ts index 5bcf7ae66..311371641 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,12 @@ 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), + ) + ); } type ToolOptions = { diff --git a/src/lib/jsonschema.ts b/src/lib/jsonschema.ts index 45eda0527..70f12f579 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 d241c215c..b4c90f54b 100644 --- a/src/lib/transform.ts +++ b/src/lib/transform.ts @@ -1,34 +1,639 @@ import type { JSONSchema, JSONSchemaDefinition } from './jsonschema'; +const JSON_SCHEMA_ANNOTATION_KEYWORDS = new Set([ + '$comment', + 'default', + 'description', + 'examples', + 'readOnly', + 'title', + 'writeOnly', +]); + +const JSON_SCHEMA_ROOT_METADATA_KEYWORDS = new Set(['$id', '$schema']); + +const JSON_SCHEMA_OBJECT_KEYWORDS = new Set([ + 'additionalProperties', + 'dependencies', + 'maxProperties', + 'minProperties', + 'patternProperties', + 'properties', + 'propertyNames', + '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 JSON_SCHEMA_UNSUPPORTED_SCHEMA_KEYWORDS = new Set([ + '$anchor', + '$dynamicAnchor', + '$dynamicRef', + '$recursiveAnchor', + '$recursiveRef', + 'allOf', + 'contains', + 'contentEncoding', + 'contentMediaType', + 'contentSchema', + 'dependentRequired', + 'dependentSchemas', + 'dependencies', + 'else', + 'if', + 'maxContains', + 'maxProperties', + 'minContains', + 'minProperties', + 'not', + 'patternProperties', + 'prefixItems', + 'propertyNames', + 'then', + 'unevaluatedItems', + 'unevaluatedProperties', + 'uniqueItems', +]); + +const MERGEABLE_OBJECT_ALL_OF_KEYWORDS = new Set([ + ...JSON_SCHEMA_ANNOTATION_KEYWORDS, + 'additionalProperties', + 'properties', + 'required', + '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') { + 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 + // visible so branch-local definitions cannot be rebound at the root. + assertNoNestedSchemaIds(schemaCopy); + normalizeRootRefAndAllOf(schemaCopy); + + if (schemaCopy.type !== 'object') { + throw new Error( + `Root schema must have type: 'object' but got type: ${ + schemaCopy.type ? `'${schemaCopy.type}'` : 'undefined' + }`, + ); + } + if (schemaCopy.anyOf !== undefined) { throw new Error( - `Root schema must have type: 'object' but got type: ${schema.type ? `'${schema.type}'` : 'undefined'}`, + 'Root schema must not use `anyOf` because strict Structured Outputs requires a root object without a union.', ); } - const schemaCopy = structuredClone(schema); - return ensureStrictJsonSchema(schemaCopy, [], schemaCopy); + 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 + // sibling object properties can be merged. + normalizeObjectAllOfBranches(schemaCopy, [], schemaCopy); + const strictSchema = ensureStrictJsonSchema(schemaCopy, [], schemaCopy); + validateRefSchemas(strictSchema, [], strictSchema); + 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 + * 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); + const normalizedAnyOf = normalizeRootAnyOf(schema); + + if (schema.$ref === undefined && !normalizedAnyOf) { + return; + } + } +} + +/** + * 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 { + let ref = schema.$ref; + if (ref === undefined) { + return; + } + + assertLocalRootRef(ref); + if (!hasOnlyRootRefAndDefinitions(schema)) { + throw new Error( + 'Schema $ref at `` has non-metadata siblings that Draft 7 ignores and cannot be represented in strict Structured Outputs.', + ); + } + + 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)) { + 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.', + ); + } + 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; + 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); + // 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)) { + delete schemaRecord[keyword]; + } + Object.assign(schema, inlined, inheritedAnnotations, rootMetadata); + if (rootDefinitions !== undefined) { + schema.$defs = rootDefinitions; + } + if (legacyDefinitions !== undefined) { + schema.definitions = legacyDefinitions; + } +} + +/** + * 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) { + // 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)) { + // 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; + 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); + } +} + +/** + * 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) || !hasOnlyRootAnyOfMetadataSiblings(schema)) { + return false; + } + + // `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; + } + + 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)) { + delete schemaRecord[keyword]; + } + Object.assign(schema, normalized, rootMetadata); + return true; +} + +type RootDefinitionKeyword = '$defs' | 'definitions'; +type PromotedRootAnyOfDefinitionRenames = Map>; + +/** + * 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, + branchIndex: number, + definitionRenames: PromotedRootAnyOfDefinitionRenames, +): void { + const rewriteRef = (ref: string): string => { + const parts = parseLocalRef(ref); + 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('/'); + }; + + 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='); + } + 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) => + keyword === '$ref' || + keyword === '$defs' || + keyword === 'definitions' || + JSON_SCHEMA_ROOT_METADATA_KEYWORDS.has(keyword) || + 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 + * 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): boolean { +function isNullable( + schema: JSONSchemaDefinition, + root: JSONSchema, + seenRefs: Set = new Set(), +): boolean { if (typeof schema === 'boolean') { + return schema; + } + + const ref = schema.$ref; + if (ref !== undefined) { + // 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; + } + + const resolved = resolveLocalRef(root, ref); + if (resolved === undefined) { + return false; + } + + return isNullable(resolved, root, new Set([...seenRefs, ref])); + } + + if ( + schema.type !== undefined && + schema.type !== 'null' && + !(Array.isArray(schema.type) && schema.type.includes('null')) + ) { return false; } - if (schema.type === 'null') { - return true; + + if ('const' in schema && schema.const !== null) { + return false; } - for (const oneOfVariant of schema.oneOf ?? []) { - if (isNullable(oneOfVariant)) { - return true; + + if (schema.enum !== undefined && (!Array.isArray(schema.enum) || !schema.enum.includes(null))) { + return false; + } + + 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; } /** @@ -48,146 +653,1368 @@ 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); - } + // 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, root)) { + return ensureStrictJsonSchema(jsonSchema, path, 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); - } - } + // 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); - // Add additionalProperties: false to object types - const typ = jsonSchema.type; - if (typ === 'object' && !('additionalProperties' in jsonSchema)) { - jsonSchema.additionalProperties = false; + // 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 + // cannot be represented in Structured Outputs strict mode. + if (hasObjectShape(jsonSchema)) { + 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 ?? []; + 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) && !required.includes(key)) { + if (!isNullable(value, root) && !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`, ); } } jsonSchema.required = Object.keys(properties); - jsonSchema.properties = Object.fromEntries( - Object.entries(properties).map(([key, propSchema]) => [ - key, - ensureStrictJsonSchema(propSchema, [...path, 'properties', key], root), - ]), - ); } - // Handle arrays + // 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; - if (isObject(items)) { - jsonSchema.items = ensureStrictJsonSchema(items, [...path, 'items'], root); + 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.`, + ); } - - // Handle unions (anyOf) - const anyOf = jsonSchema.anyOf; - if (Array.isArray(anyOf)) { - jsonSchema.anyOf = anyOf.map((variant, i) => - ensureStrictJsonSchema(variant, [...path, 'anyOf', String(i)], root), + if (additionalItems !== undefined) { + throw new Error( + `Schema at \`${ + path.join('/') || '' + }\` uses unsupported keyword \`additionalItems\` and cannot be represented in strict Structured Outputs.`, ); } // Handle intersections (allOf) const allOf = jsonSchema.allOf; if (Array.isArray(allOf)) { - if (allOf.length === 1) { - const resolved = ensureStrictJsonSchema(allOf[0]!, [...path, 'allOf', '0'], root); - Object.assign(jsonSchema, resolved); - delete jsonSchema.allOf; - } else { - jsonSchema.allOf = allOf.map((entry, i) => - ensureStrictJsonSchema(entry, [...path, 'allOf', String(i)], root), - ); + if (allOf.length === 1 && hasOnlyAnnotationSiblings(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; + } } } - // 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)) { - if (typeof ref !== 'string') { - throw new TypeError(`Received non-string $ref - ${ref}; path=${path.join('/')}`); - } + normalizeArrayUnionWrapper(jsonSchema, root); - 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)) { + const schemaRecord = jsonSchema as Record; + for (const keyword of JSON_SCHEMA_UNSUPPORTED_SCHEMA_KEYWORDS) { + // 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( - `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.`, ); } + delete schemaRecord[keyword]; + } + + const type = jsonSchema.type; + const currentItems = jsonSchema.items; + if ((type === 'array' || (Array.isArray(type) && type.includes('array'))) && currentItems === 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 + // additionalItems does not contain a nested schema to strictify. + if (typeof child === 'boolean' && (keyword === 'additionalProperties' || keyword === 'additionalItems')) { + return; + } - // Properties from the json schema take priority over the ones on the `$ref` - Object.assign(jsonSchema, { ...resolved, ...jsonSchema }); - delete (jsonSchema as any).$ref; + ensureStrictJsonSchema(child as JSONSchemaDefinition, childPath, root); + }); - // 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); + // Strip `null` defaults as there's no meaningful distinction + if (jsonSchema.default === null) { + delete jsonSchema.default; } return jsonSchema; } -function resolveRef(root: JSONSchema, ref: string): JSONSchemaDefinition { - if (!ref.startsWith('#/')) { - throw new Error(`Unexpected $ref format ${JSON.stringify(ref)}; Does not start with #/`); +function parseLocalRef(ref: string): string[] | undefined { + if (!ref.startsWith('#')) { + return undefined; } - const pathParts = ref.slice(2).split('/'); - let resolved: any = root; + 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; + } - 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}`); + if (pointer === '') { + return []; + } + if (!pointer.startsWith('/')) { + return undefined; + } + + const parts: string[] = []; + 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(encodedPart)) { + return undefined; } - resolved = value; + parts.push(encodedPart.replace(/~1/g, '/').replace(/~0/g, '~')); } - return resolved; + return parts; } -function isObject(obj: T | Array): obj is Extract> { - return typeof obj === 'object' && obj !== null && !Array.isArray(obj); -} +function resolvePointerPart(resolved: unknown, part: string): unknown | undefined { + if (Array.isArray(resolved)) { + if (!/^(?:0|[1-9]\d*)$/.test(part)) { + return undefined; + } -function hasMoreThanNKeys(obj: Record, n: number): boolean { - let i = 0; - for (const _ in obj) { - i++; - if (i > n) { - return true; + const index = Number(part); + if (!Object.prototype.hasOwnProperty.call(resolved, index)) { + return undefined; } + return resolved[index]; } - return false; + + 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; + } + + // 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 (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 isSchemaDefinition(resolved) ? resolved : undefined; +} + +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 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])); + } + + 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') + ); +} + +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])); + } + + 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') + ); +} + +export function hasOnlyRefAndAnnotations(schema: JSONSchema): boolean { + return Object.keys(schema).every( + // 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), + ); +} + +function hasOnlyAnnotationSiblings(schema: JSONSchema, keyword: string): boolean { + const schemaRecord = schema as Record; + return Object.keys(schema).every( + // 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), + ); +} + +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 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)); +} + +function hasObjectShape(schema: JSONSchema): boolean { + const typ = schema.type; + return ( + typ === 'object' || + (Array.isArray(typ) && typ.includes('object')) || + (typ === undefined && hasObjectKeywords(schema)) + ); +} + +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; + } + + 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 ( + 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; + } + + throw new Error( + 'Object anyOf schema at `' + + (path.join('/') || '') + + '` cannot be represented in strict Structured Outputs without changing Draft 7 validation.', + ); +} + +function normalizeArrayUnionWrapper(jsonSchema: JSONSchema, root: JSONSchema): void { + if ( + 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 and excludes + // null. Keeping either redundant wrapper would require an outer items + // schema that does not contribute any Draft 7 validation. + delete jsonSchema.type; + } +} + +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)) { + return; + } + + if (path.length > 0 && value.$id !== undefined) { + 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'); +} + +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 + * 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; + } + + 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']) + ) { + parts[index] = 'anyOf'; + changed = true; + } + + resolved = resolvePointerPart(resolved, part); + if (resolved === undefined) { + return ref; + } + } + + return changed ? '#/' + parts.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 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 + * structural flattening cannot leave a dangling local pointer behind. + */ +function preserveAllOfRefTargets(root: JSONSchema, rootOnly = false): 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)) { + const pointerParts = parseLocalRef(value.$ref); + if (!rootOnly || pointerParts?.[0] === 'allOf') { + 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)) { + if (rootOnly) { + continue; + } + 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); +} + +/** + * 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; + } + + const ref = schema.$ref; + if (ref !== undefined) { + if (typeof ref !== 'string') { + throw new TypeError(`Received non-string $ref - ${ref}; path=${path.join('/')}`); + } + if (!ref.startsWith('#')) { + throw new Error( + `External $ref at \`${ + path.join('/') || '' + }\` 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 (typeof resolved === 'boolean') { + throw new TypeError(`Expected object schema but got boolean; path=${path.join('/')}`); + } + 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.`, + ); + } + } + + forEachJSONSchemaChild(schema, path, (child, childPath) => { + validateRefSchemas(child as JSONSchemaDefinition, childPath, root); + }); +} + +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, + normalizing: Set, +): ResolvedObjectAllOfBranch | undefined { + const refChain = [schema]; + const seenRefs = new Set(); + let resolved = schema; + let resolvedPath: string[] = []; + + 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 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; + } + } + + return { schema: resolved, refChain }; + } +} + +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; + } + + 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); + } +} + +/** + * 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 { + if (schema.allOf === undefined) { + return undefined; + } + + const normalized = structuredClone(schema); + try { + // 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; + } +} + +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; + } + + // 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. + 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, normalizing) : undefined, + ); + const objectBranches = resolvedEntries + .map((entry) => entry?.schema) + .filter((entry): entry is JSONSchema => entry !== undefined && 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' && + !(path.length === 0 && JSON_SCHEMA_ROOT_METADATA_KEYWORDS.has(keyword)) && + !MERGEABLE_OBJECT_ALL_OF_KEYWORDS.has(keyword) + ) { + fail(); + } + } + + const branches: Array<{ schema: JSONSchema; sourcePath: string[] | undefined }> = []; + if (parentHasObjectShape) { + branches.push({ schema: jsonSchema, sourcePath: path }); + } + for (const [index, entry] of allOf.entries()) { + if (!isObject(entry)) { + fail(); + } + const resolvedEntry = resolvedEntries[index]; + if (resolvedEntry === undefined) { + 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({ + schema: branch, + sourcePath: branch === entry ? [...path, 'allOf', String(index)] : undefined, + }); + } else if (!hasOnlyNeutralAllOfBranchKeywords(branch)) { + fail(); + } + } + + const merged: JSONSchema = {}; + for (const keyword of ['$defs', 'definitions'] as const) { + if (jsonSchema[keyword] !== undefined) { + 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(); + const closedPropertySets: Set[] = []; + const propertyEntries: Array<{ + key: string; + propertySchema: JSONSchemaDefinition; + sourcePath: string[] | undefined; + }> = []; + let sawProperties = false; + let sawRequired = false; + let hasExplicitObjectType = false; + let hasExplicitNullableObjectType = false; + + const mergeAnnotations = (schema: JSONSchema) => { + for (const keyword of JSON_SCHEMA_ANNOTATION_KEYWORDS) { + if (!(keyword in schema)) continue; + // 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]; + } + } + }; + + mergeAnnotations(jsonSchema); + for (const resolvedEntry of resolvedEntries) { + if (resolvedEntry === undefined) continue; + for (const entry of resolvedEntry.refChain) { + mergeAnnotations(entry); + } + } + + for (const { schema: branch, sourcePath } of branches) { + for (const keyword of Object.keys(branch)) { + if (keyword === 'allOf' && 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; + } + if (!MERGEABLE_OBJECT_ALL_OF_KEYWORDS.has(keyword)) { + fail(); + } + } + + if (branch.type !== undefined) { + if (!isMergeableObjectType(branch.type)) { + fail(); + } + if (branch.type === 'object') { + hasExplicitObjectType = true; + } else { + hasExplicitNullableObjectType = true; + } + } + + if (branch.properties !== undefined) { + if (!isObject(branch.properties)) { + fail(); + } + sawProperties = true; + for (const [key, propertySchema] of Object.entries(branch.properties)) { + propertyEntries.push({ + key, + propertySchema, + sourcePath: sourcePath === undefined ? undefined : [...sourcePath, 'properties', key], + }); + } + } + + 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 ?? {}))); + } + } + + // 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]), + ); + const excludesRequiredProperty = + allowedClosedProperties !== undefined && + [...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 (collapsesToNull) { + merged.type = 'null'; + for (const keyword of Object.keys(jsonSchema)) { + delete (jsonSchema as any)[keyword]; + } + Object.assign(jsonSchema, merged); + return true; + } + 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']; + } + if (sawProperties) merged.properties = Object.fromEntries(Object.entries(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 isMergeableObjectType(schema.type); + } + 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' || + (Array.isArray(type) && type.length === 2 && type.includes('object') && type.includes('null')) + ); +} + +function schemasEqual(left: unknown, right: unknown): boolean { + 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/helpers/standard-schema.test.ts b/tests/helpers/standard-schema.test.ts new file mode 100644 index 000000000..cef98b528 --- /dev/null +++ b/tests/helpers/standard-schema.test.ts @@ -0,0 +1,2119 @@ +import { + standardFunction, + standardResponseFormat, + 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'; +import { z as zv4 } from 'zod/v4'; + +declare const runTools: OpenAI['chat']['completions']['runTools']; + +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( + jsonSchema: Record = weatherJSONSchema as unknown as Record, +) { + const input = jest.fn(() => jsonSchema); + 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 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': { + 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('normalizes provably exclusive oneOf branches before strictifying schemas', () => { + const oneOfSchema = { + type: 'object', + properties: { + choice: { + 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 { standardSchema } = makeStandardSchema(oneOfSchema); + + const schema = standardResponseFormat(standardSchema, 'choice').json_schema.schema; + + expect(schema).toEqual({ + type: 'object', + 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, + }, + ], + }, + }, + required: ['choice'], + additionalProperties: false, + }); + expect(JSON.stringify(schema)).not.toContain('"oneOf"'); + 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', + 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('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('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('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', + 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('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'; + 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', + 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('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({ + 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', + 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('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', + $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('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('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('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('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('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('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', + 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) => { + 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', + 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('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', + 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: { + 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', $comment: 'Generated foo alias', 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', $comment: 'Generated foo alias', 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' }], + [{ $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.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', + 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('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('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', + properties: { + metadata: { + type: 'object', + patternProperties: { + '^x-': { + type: 'object', + properties: { value: { type: 'string' } }, + required: ['value'], + }, + }, + additionalProperties: false, + }, + }, + required: ['metadata'], + }); + + expect(() => standardResponseFormat(standardSchema, 'metadata')).toThrow( + 'uses unsupported keyword `patternProperties`', + ); + }); + + 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([ + ['$anchor', 'node'], + ['$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) => { + 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.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', + properties: { + 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('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('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.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', + 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' }], + ])('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('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(); + + 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', + ); + }); + + 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() { + const { standardSchema } = makeStandardSchema(); + const vendorStandardSchema = zv4.object({ city: zv4.string() }); + + 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', + parameters: standardSchema, + function: (args) => expectType(args), + }); + const responseTool = standardResponsesFunction({ + name: 'get_weather', + 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, + }, + }, + }; + 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), + }); + 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); + compareType(true); + 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 + runTools({ model: 'gpt-4o', messages: [], tools: [callbacklessChatTool] }); + standardResponseFormat(standardTargetSchema, 'weather'); +} diff --git a/tests/helpers/zod.test.ts b/tests/helpers/zod.test.ts index 74c2055f4..d9f9434c8 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/ResponsesParser.test.ts b/tests/lib/ResponsesParser.test.ts index c3605d3ab..006fe23d8 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' }, + }); + }); }); diff --git a/tests/lib/transform.test.ts b/tests/lib/transform.test.ts index 87195d998..f1217285c 100644 --- a/tests/lib/transform.test.ts +++ b/tests/lib/transform.test.ts @@ -19,226 +19,2626 @@ 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']); + }); + + 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'); + }); + + 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', + $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('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)( + 'preserves nested %s maps at their original pointer scope when inlining a root local ref', + (keyword) => { + const schema = { + $ref: '#/' + keyword + '/Input', + [keyword]: { + Input: { + type: 'object', + [keyword]: { Nested: { type: 'string' } }, + properties: { + nested: { $ref: '#/' + keyword + '/Input/' + keyword + '/Nested' }, + outer: { $ref: '#/' + keyword + '/Nested' }, + }, + required: ['nested', 'outer'], + }, + Nested: { type: 'number' }, + }, + } as JSONSchema; + + 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' }, + }, + }); + }, + ); + + 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('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('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' }], + $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 ``'], + ])('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); + }); + + 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='); + }); + + 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('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: [ + { + 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('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#', + $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: [ + { + 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', () => { + test('adds additionalProperties: false to object schemas', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + name: { type: 'string' }, + }, + required: ['name'], + }; + + const strict = toStrictJsonSchema(schema); + const diff = detailedDiff(schema, strict); + + expect(diff).toMatchInlineSnapshot(` + { + "added": { + "additionalProperties": false, + }, + "deleted": {}, + "updated": {}, + } + `); + }); + + test('rejects additionalProperties: true', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + name: { type: 'string' }, + }, + required: ['name'], + additionalProperties: true, + }; + + expect(() => toStrictJsonSchema(schema)).toThrow( + 'must set `additionalProperties: false` to be compatible with strict Structured Outputs', + ); + }); + + 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', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + user: { + type: 'object', + properties: { + name: { type: 'string' }, + }, + required: ['name'], + }, + }, + required: ['user'], + }; + + const strict = toStrictJsonSchema(schema); + const diff = detailedDiff(schema, strict); + + expect(diff).toMatchInlineSnapshot(` + { + "added": { + "additionalProperties": false, + "properties": { + "user": { + "additionalProperties": false, + }, + }, + }, + "deleted": {}, + "updated": {}, + } + `); + }); + + 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, + }); + }); + + 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', () => { + test('makes all properties required when nullable', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + name: { anyOf: [{ type: 'string' }, { type: 'null' }] }, + age: { anyOf: [{ type: 'number' }, { type: 'null' }] }, + }, + }; + + const strict = toStrictJsonSchema(schema); + const diff = detailedDiff(schema, strict); + + expect(diff).toMatchInlineSnapshot(` + { + "added": { + "additionalProperties": false, + "required": [ + "name", + "age", + ], + }, + "deleted": {}, + "updated": {}, + } + `); + }); + + 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('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('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('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', + properties: { + source: { + anyOf: [{ type: 'string' }, { type: 'number' }], + }, + alias: { $ref: '#/properties/source/anyOf/0' }, + }, + required: ['source', 'alias'], + }; + + expect(toStrictJsonSchema(schema).properties?.['alias']).toEqual({ + $ref: '#/properties/source/anyOf/0', + }); + }); + + test('resolves local refs with $comment and other annotation-only siblings', () => { + const schema: JSONSchema = { + type: 'object', + $defs: { + NullableString: { type: ['string', 'null'] }, + }, + properties: { + nickname: { + $ref: '#/$defs/NullableString', + $comment: 'Generated alias', + title: 'Nickname', + description: 'A preferred name', + default: null, + examples: [null], + }, + }, + }; + + const strict = toStrictJsonSchema(schema); + + expect(strict.required).toEqual(['nickname']); + expect(strict.properties?.['nickname']).toEqual({ + $ref: '#/$defs/NullableString', + $comment: 'Generated alias', + title: 'Nickname', + description: 'A preferred name', + examples: [null], + }); + }); + + 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', + $defs: { + NullableString: { type: ['string', 'null'] }, + Cyclic: { $ref: '#/$defs/Cyclic' }, + }, + properties: { + nickname: { $ref: '#/$defs/Cyclic' }, + }, + }; + + expect(() => toStrictJsonSchema(schema)).toThrow( + 'Schema field at `properties/nickname` uses `.optional()` without `.nullable()`', + ); + }); + + 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', + 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', + properties: { + name: { type: 'string' }, + }, + }; + + expect(() => toStrictJsonSchema(schema)).toThrow( + 'Schema field at `properties/name` uses `.optional()` without `.nullable()` which is not supported by the API', + ); + }); + }); + + describe('Nested Schemas', () => { + test('processes nested object properties', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + address: { + type: 'object', + properties: { + street: { type: 'string' }, + city: { anyOf: [{ type: 'string' }, { type: 'null' }] }, + }, + required: ['street'], + }, + }, + required: ['address'], + }; + + const strict = toStrictJsonSchema(schema); + const diff = detailedDiff(schema, strict); + + expect(diff).toMatchInlineSnapshot(` + { + "added": { + "additionalProperties": false, + "properties": { + "address": { + "additionalProperties": false, + "required": { + "1": "city", + }, + }, + }, + }, + "deleted": {}, + "updated": {}, + } + `); + }); + + test('processes array items', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + tags: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + }, + required: ['name'], + }, + }, + }, + required: ['tags'], + }; + + const strict = toStrictJsonSchema(schema); + const diff = detailedDiff(schema, strict); + + expect(diff).toMatchInlineSnapshot(` + { + "added": { + "additionalProperties": false, + "properties": { + "tags": { + "items": { + "additionalProperties": false, + }, + }, + }, + }, + "deleted": {}, + "updated": {}, + } + `); + }); + + 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('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', + properties: { + tuple: { + type: 'array', + items: [{ type: 'string' }, { type: 'number' }], + }, + }, + required: ['tuple'], + }; + + expect(() => toStrictJsonSchema(schema)).toThrow('uses tuple-form `items`'); + }); + + test('rejects additionalItems before returning a strict schema', () => { + 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)).toThrow('uses unsupported keyword `additionalItems`'); + }); + + test('rejects patternProperties schemas', () => { + 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)).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`'); + }); + + const unsupportedKeywords = [ + 'allOf', + 'contains', + 'contentEncoding', + 'contentMediaType', + 'contentSchema', + 'dependentRequired', + 'dependentSchemas', + 'dependencies', + '$dynamicAnchor', + '$dynamicRef', + '$recursiveAnchor', + '$recursiveRef', + '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'], + ] 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', + properties: { + metadata: { + type: 'object', + properties: { value: { type: 'string' } }, + required: ['value'], + [keyword]: 1, + }, + }, + required: ['metadata'], + }; + + 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', + { + 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, + }, + ], + [ + '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', + properties: { + value: propertySchema as JSONSchema, + }, + required: ['value'], + }; + + expect(() => toStrictJsonSchema(schema)).toThrow('uses unsupported keyword'); + }); + }); + + describe('anyOf Handling', () => { + test('processes anyOf variants', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + value: { + anyOf: [ + { + type: 'object', + properties: { num: { type: 'number' } }, + required: ['num'], + }, + { + type: 'object', + properties: { str: { type: 'string' } }, + required: ['str'], + }, + ], + }, + }, + required: ['value'], + }; + + const strict = toStrictJsonSchema(schema); + const diff = detailedDiff(schema, strict); + + expect(diff).toMatchInlineSnapshot(` + { + "added": { + "additionalProperties": false, + "properties": { + "value": { + "anyOf": { + "0": { + "additionalProperties": false, + }, + "1": { + "additionalProperties": false, + }, + }, + }, + }, + }, + "deleted": {}, + "updated": {}, + } + `); + }); + + 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 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', + 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', + 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('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', + 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 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', + 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', + 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('Additional Properties', () => { - test('adds additionalProperties: false to object schemas', () => { + describe('allOf Handling', () => { + test('inlines single allOf variant', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + value: { + allOf: [ + { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + }, + ], + }, + }, + required: ['value'], + }; + + const strict = toStrictJsonSchema(schema); + const diff = detailedDiff(schema, strict); + + expect(diff).toMatchInlineSnapshot(` + { + "added": { + "additionalProperties": false, + "properties": { + "value": { + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + }, + }, + "required": [ + "name", + ], + "type": "object", + }, + }, + }, + "deleted": { + "properties": { + "value": { + "allOf": undefined, + }, + }, + }, + "updated": {}, + } + `); + }); + + 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.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', + 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', + 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: { - name: { type: 'string' }, + value: { + allOf: [{ type: 'string' }], + }, + alias: { $ref: '#/properties/value/allOf/1' }, }, - required: ['name'], + required: ['value', 'alias'], }; - const strict = toStrictJsonSchema(schema); - const diff = detailedDiff(schema, strict); + expect(() => toStrictJsonSchema(schema)).toThrow('does not resolve to an object or boolean schema'); + }); - expect(diff).toMatchInlineSnapshot(` - { - "added": { - "additionalProperties": false, + test('rejects a single allOf variant with sibling constraints', () => { + const schema: JSONSchema = { + type: 'object', + properties: { + value: { + type: 'string', + allOf: [{ type: 'number' }], }, - "deleted": {}, - "updated": {}, - } - `); + }, + required: ['value'], + }; + + expect(() => toStrictJsonSchema(schema)).toThrow('uses unsupported keyword `allOf`'); }); - test('preserves existing additionalProperties value', () => { + test('merges compatible object allOf variants before closing them', () => { const schema: JSONSchema = { type: 'object', properties: { - name: { type: 'string' }, + value: { + allOf: [ + { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + }, + { + type: 'object', + properties: { age: { type: 'number' } }, + required: ['age'], + }, + ], + }, }, - required: ['name'], - additionalProperties: true, + required: ['value'], }; - const strict = toStrictJsonSchema(schema); - const diff = detailedDiff(schema, strict); - - expect(diff).toMatchInlineSnapshot(` - { - "added": {}, - "deleted": {}, - "updated": {}, - } - `); + 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, + }); }); - test('adds additionalProperties: false to nested objects', () => { + test('removes neutral true branches before merging object allOf variants', () => { const schema: JSONSchema = { type: 'object', properties: { - user: { + value: { type: 'object', - properties: { - name: { type: 'string' }, + 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.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', + 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('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', + $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'], + }, }, - required: ['user'], + properties: { + value: { + type: 'object', + allOf: [{ $ref: '#/$defs/NameAlias', $comment: 'Generated alias' }, { $ref: '#/$defs/Age' }], + }, + }, + required: ['value'], }; - const strict = toStrictJsonSchema(schema); - const diff = detailedDiff(schema, strict); + 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, + }); + }); - expect(diff).toMatchInlineSnapshot(` - { - "added": { - "additionalProperties": false, - "properties": { - "user": { - "additionalProperties": false, + 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'], }, }, - "deleted": {}, - "updated": {}, - } - `); + 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, + }); }); - }); - describe('Required Properties', () => { - test('makes all properties required when nullable', () => { + test('intersects closed object allOf property sets and drops excluded optionals', () => { const schema: JSONSchema = { type: 'object', properties: { - name: { anyOf: [{ type: 'string' }, { type: 'null' }] }, - age: { anyOf: [{ type: 'number' }, { type: 'null' }] }, + 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'], }; - const strict = toStrictJsonSchema(schema); - const diff = detailedDiff(schema, strict); + expect(toStrictJsonSchema(schema).properties?.['value']).toEqual({ + type: 'object', + properties: { x: { type: 'string' } }, + required: ['x'], + additionalProperties: false, + }); + }); - expect(diff).toMatchInlineSnapshot(` - { - "added": { - "additionalProperties": false, - "required": [ - "name", - "age", + 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, + }, ], }, - "deleted": {}, - "updated": {}, - } - `); + }, + required: ['value'], + }; + + expect(() => toStrictJsonSchema(schema)).toThrow( + 'cannot be merged without changing Draft 7 validation', + ); }); - test('throws error for optional properties without nullable', () => { + 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: { - name: { type: 'string' }, + value: { + type: 'object', + allOf: [{ $ref: '#/$defs/A' }, { $ref: '#/$defs/Age' }], + }, }, + required: ['value'], }; expect(() => toStrictJsonSchema(schema)).toThrow( - 'Zod field at `properties/name` uses `.optional()` without `.nullable()` which is not supported by the API', + 'cannot be merged without changing Draft 7 validation', ); }); - }); - describe('Nested Schemas', () => { - test('processes nested object properties', () => { + 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: { - address: { - type: 'object', - properties: { - street: { type: 'string' }, - city: { anyOf: [{ type: 'string' }, { type: 'null' }] }, - }, - required: ['street'], + value: { + allOf: [ + { + type: 'object', + properties: specialProperties, + required: ['constructor', 'toString', '__proto__'], + }, + { + type: 'object', + properties: { other: { type: 'number' } }, + required: ['other'], + }, + ], }, }, - required: ['address'], + required: ['value'], }; const strict = toStrictJsonSchema(schema); - const diff = detailedDiff(schema, strict); + const properties = (strict.properties?.['value'] as JSONSchema).properties; - expect(diff).toMatchInlineSnapshot(` - { - "added": { - "additionalProperties": false, - "properties": { - "address": { - "additionalProperties": false, - "required": { - "1": "city", - }, - }, - }, - }, - "deleted": {}, - "updated": {}, - } - `); + 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('processes array items', () => { + test('merges matching object allOf properties with reordered schema keys', () => { const schema: JSONSchema = { type: 'object', properties: { - tags: { - type: 'array', - items: { - type: 'object', - properties: { - name: { type: 'string' }, + value: { + allOf: [ + { + type: 'object', + properties: { + shared: { + type: 'object', + description: 'shared object', + properties: { name: { type: 'string', description: 'display name' } }, + required: ['name'], + }, + }, + required: ['shared'], }, - required: ['name'], - }, + { + required: ['shared'], + properties: { + shared: { + required: ['name'], + properties: { name: { description: 'display name', type: 'string' } }, + description: 'shared object', + type: 'object', + }, + }, + type: 'object', + }, + ], }, }, - required: ['tags'], + required: ['value'], }; - const strict = toStrictJsonSchema(schema); - const diff = detailedDiff(schema, strict); - - expect(diff).toMatchInlineSnapshot(` - { - "added": { - "additionalProperties": false, - "properties": { - "tags": { - "items": { - "additionalProperties": false, - }, + 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, }, - "deleted": {}, - "updated": {}, - } - `); + }, + required: ['value'], + additionalProperties: false, + }); }); - }); - describe('anyOf Handling', () => { - test('processes anyOf variants', () => { + test('rejects matching object allOf properties with reordered array values', () => { const schema: JSONSchema = { type: 'object', properties: { value: { - anyOf: [ + allOf: [ { type: 'object', - properties: { num: { type: 'number' } }, - required: ['num'], + properties: { shared: { type: 'string', enum: ['first', 'second'] } }, + required: ['shared'], }, { type: 'object', - properties: { str: { type: 'string' } }, - required: ['str'], + properties: { shared: { enum: ['second', 'first'], type: 'string' } }, + required: ['shared'], }, ], }, @@ -246,35 +2646,12 @@ describe('toStrictJsonSchema', () => { required: ['value'], }; - const strict = toStrictJsonSchema(schema); - const diff = detailedDiff(schema, strict); - - expect(diff).toMatchInlineSnapshot(` - { - "added": { - "additionalProperties": false, - "properties": { - "value": { - "anyOf": { - "0": { - "additionalProperties": false, - }, - "1": { - "additionalProperties": false, - }, - }, - }, - }, - }, - "deleted": {}, - "updated": {}, - } - `); + expect(() => toStrictJsonSchema(schema)).toThrow( + 'cannot be merged without changing Draft 7 validation', + ); }); - }); - describe('allOf Handling', () => { - test('inlines single allOf variant', () => { + test('rejects object allOf variants that cannot be merged exactly', () => { const schema: JSONSchema = { type: 'object', properties: { @@ -282,8 +2659,13 @@ describe('toStrictJsonSchema', () => { allOf: [ { type: 'object', - properties: { name: { type: 'string' } }, - required: ['name'], + properties: { shared: { type: 'string' } }, + required: ['shared'], + }, + { + type: 'object', + properties: { shared: { type: 'number' } }, + required: ['shared'], }, ], }, @@ -291,90 +2673,146 @@ describe('toStrictJsonSchema', () => { required: ['value'], }; - const strict = toStrictJsonSchema(schema); - const diff = detailedDiff(schema, strict); + expect(() => toStrictJsonSchema(schema)).toThrow( + 'cannot be merged without changing Draft 7 validation', + ); + }); + }); - expect(diff).toMatchInlineSnapshot(` - { - "added": { - "additionalProperties": false, - "properties": { - "value": { - "additionalProperties": false, - "properties": { - "name": { - "type": "string", - }, - }, - "required": [ - "name", - ], - "type": "object", + 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 and undefined nested $id values but rejects defined 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 undefinedNestedIdSchema: JSONSchema = { + type: 'object', + properties: { + value: { + $id: undefined, + type: 'string', + }, + }, + required: ['value'], + }; + expect(() => toStrictJsonSchema(undefinedNestedIdSchema)).not.toThrow(); + + 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', + $defs: { + Node: { + type: 'object', + properties: { + value: { type: 'string' }, + next: { + anyOf: [{ $ref: '#/$defs/Node', description: 'The next node' }, { type: 'null' }], }, }, + required: ['value', 'next'], }, - "deleted": { - "properties": { - "value": { - "allOf": undefined, + }, + 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' }], }, }, }, - "updated": {}, - } - `); + }, + properties: { + root: { $ref: '#/$defs/Node', description: 'The root node' }, + }, + }); }); - test('processes multiple allOf variants', () => { + test('retains validation through annotated local reference chains', () => { const schema: JSONSchema = { type: 'object', + $defs: { + Text: { type: 'string' }, + Alias: { $ref: '#/$defs/Text' }, + }, properties: { - value: { - allOf: [ - { - type: 'object', - properties: { name: { type: 'string' } }, - required: ['name'], - }, - { - type: 'object', - properties: { age: { type: 'number' } }, - required: ['age'], - }, - ], - }, + value: { $ref: '#/$defs/Alias', description: 'A text value' }, }, required: ['value'], }; const strict = toStrictJsonSchema(schema); - const diff = detailedDiff(schema, strict); - expect(diff).toMatchInlineSnapshot(` - { - "added": { - "additionalProperties": false, - "properties": { - "value": { - "allOf": { - "0": { - "additionalProperties": false, - }, - "1": { - "additionalProperties": false, - }, - }, - }, - }, - }, - "deleted": {}, - "updated": {}, - } - `); + expect(strict.$defs?.['Alias']).toEqual({ $ref: '#/$defs/Text' }); + expect(strict.properties?.['value']).toEqual({ + $ref: '#/$defs/Alias', + description: 'A text value', + }); }); - }); - describe('$ref Resolution', () => { test('processes definitions', () => { const schema: JSONSchema = { type: 'object',