151 lines
6.0 KiB
JavaScript
151 lines
6.0 KiB
JavaScript
export function toStrictJsonSchema(schema) {
|
|
if (schema.type !== 'object') {
|
|
throw new Error(`Root schema must have type: 'object' but got type: ${schema.type ? `'${schema.type}'` : 'undefined'}`);
|
|
}
|
|
const schemaCopy = structuredClone(schema);
|
|
return ensureStrictJsonSchema(schemaCopy, [], schemaCopy);
|
|
}
|
|
function isNullable(schema) {
|
|
if (typeof schema === 'boolean') {
|
|
return false;
|
|
}
|
|
if (schema.type === 'null') {
|
|
return true;
|
|
}
|
|
for (const oneOfVariant of schema.oneOf ?? []) {
|
|
if (isNullable(oneOfVariant)) {
|
|
return true;
|
|
}
|
|
}
|
|
for (const allOfVariant of schema.anyOf ?? []) {
|
|
if (isNullable(allOfVariant)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
/**
|
|
* Mutates the given JSON schema to ensure it conforms to the `strict` standard
|
|
* that the API expects.
|
|
*/
|
|
function ensureStrictJsonSchema(jsonSchema, path, root) {
|
|
if (typeof jsonSchema === 'boolean') {
|
|
throw new TypeError(`Expected object schema but got boolean; path=${path.join('/')}`);
|
|
}
|
|
if (!isObject(jsonSchema)) {
|
|
throw new TypeError(`Expected ${JSON.stringify(jsonSchema)} to be an object; path=${path.join('/')}`);
|
|
}
|
|
// Handle $defs (non-standard but sometimes used)
|
|
const defs = jsonSchema.$defs;
|
|
if (isObject(defs)) {
|
|
for (const [defName, defSchema] of Object.entries(defs)) {
|
|
ensureStrictJsonSchema(defSchema, [...path, '$defs', defName], root);
|
|
}
|
|
}
|
|
// Handle definitions (draft-04 style, deprecated in draft-07 but still used)
|
|
const definitions = jsonSchema.definitions;
|
|
if (isObject(definitions)) {
|
|
for (const [definitionName, definitionSchema] of Object.entries(definitions)) {
|
|
ensureStrictJsonSchema(definitionSchema, [...path, 'definitions', definitionName], root);
|
|
}
|
|
}
|
|
// Add additionalProperties: false to object types
|
|
const typ = jsonSchema.type;
|
|
if (typ === 'object' && !('additionalProperties' in jsonSchema)) {
|
|
jsonSchema.additionalProperties = false;
|
|
}
|
|
const required = jsonSchema.required ?? [];
|
|
// Handle object properties
|
|
const properties = jsonSchema.properties;
|
|
if (isObject(properties)) {
|
|
for (const [key, value] of Object.entries(properties)) {
|
|
if (!isNullable(value) && !required.includes(key)) {
|
|
throw new Error(`Zod 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
|
|
const items = jsonSchema.items;
|
|
if (isObject(items)) {
|
|
jsonSchema.items = ensureStrictJsonSchema(items, [...path, 'items'], root);
|
|
}
|
|
// Handle unions (anyOf)
|
|
const anyOf = jsonSchema.anyOf;
|
|
if (Array.isArray(anyOf)) {
|
|
jsonSchema.anyOf = anyOf.map((variant, i) => ensureStrictJsonSchema(variant, [...path, 'anyOf', String(i)], root));
|
|
}
|
|
// Handle intersections (allOf)
|
|
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));
|
|
}
|
|
}
|
|
// Strip `null` defaults as there's no meaningful distinction
|
|
if (jsonSchema.default === null) {
|
|
delete jsonSchema.default;
|
|
}
|
|
// Handle $ref with additional properties
|
|
const ref = jsonSchema.$ref;
|
|
if (ref && hasMoreThanNKeys(jsonSchema, 1)) {
|
|
if (typeof ref !== 'string') {
|
|
throw new TypeError(`Received non-string $ref - ${ref}; path=${path.join('/')}`);
|
|
}
|
|
const resolved = resolveRef(root, ref);
|
|
if (typeof resolved === 'boolean') {
|
|
throw new Error(`Expected \`$ref: ${ref}\` to resolve to an object schema but got boolean`);
|
|
}
|
|
if (!isObject(resolved)) {
|
|
throw new Error(`Expected \`$ref: ${ref}\` to resolve to an object but got ${JSON.stringify(resolved)}`);
|
|
}
|
|
// Properties from the json schema take priority over the ones on the `$ref`
|
|
Object.assign(jsonSchema, { ...resolved, ...jsonSchema });
|
|
delete jsonSchema.$ref;
|
|
// Since the schema expanded from `$ref` might not have `additionalProperties: false` applied,
|
|
// we call `ensureStrictJsonSchema` again to fix the inlined schema and ensure it's valid.
|
|
return ensureStrictJsonSchema(jsonSchema, path, root);
|
|
}
|
|
return jsonSchema;
|
|
}
|
|
function resolveRef(root, ref) {
|
|
if (!ref.startsWith('#/')) {
|
|
throw new Error(`Unexpected $ref format ${JSON.stringify(ref)}; Does not start with #/`);
|
|
}
|
|
const pathParts = ref.slice(2).split('/');
|
|
let resolved = root;
|
|
for (const key of pathParts) {
|
|
if (!isObject(resolved)) {
|
|
throw new Error(`encountered non-object entry while resolving ${ref} - ${JSON.stringify(resolved)}`);
|
|
}
|
|
const value = resolved[key];
|
|
if (value === undefined) {
|
|
throw new Error(`Key ${key} not found while resolving ${ref}`);
|
|
}
|
|
resolved = value;
|
|
}
|
|
return resolved;
|
|
}
|
|
function isObject(obj) {
|
|
return typeof obj === 'object' && obj !== null && !Array.isArray(obj);
|
|
}
|
|
function hasMoreThanNKeys(obj, n) {
|
|
let i = 0;
|
|
for (const _ in obj) {
|
|
i++;
|
|
if (i > n) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
//# sourceMappingURL=transform.mjs.map
|