Repository Public
1/**2 * zod → JSON Schema in the subset accepted by strict tool calling on both providers:3 * every object has `additionalProperties:false` and lists ALL its properties in `required`; optional fields are4 * modelled as nullable (`anyOf [T, null]`); validation keywords that strict modes reject are dropped (zod still5 * enforces them when the engine validates the model's input).6 */7import { z } from 'zod';89type Json = Record<string, unknown>;1011const DROP = new Set([12 '$schema', 'default', 'minLength', 'maxLength', 'pattern', 'format', 'minimum', 'maximum', 'exclusiveMinimum',13 'exclusiveMaximum', 'multipleOf', 'minItems', 'maxItems', 'uniqueItems', 'minProperties', 'maxProperties',14]);1516function isObj(v: unknown): v is Json {17 return typeof v === 'object' && v !== null && !Array.isArray(v);18}1920function allowsNull(s: Json): boolean {21 if (s.type === 'null') return true;22 if (Array.isArray(s.type) && s.type.includes('null')) return true;23 if (Array.isArray(s.anyOf)) return s.anyOf.some((x) => isObj(x) && allowsNull(x));24 return false;25}2627function strictify(node: unknown): unknown {28 if (Array.isArray(node)) return node.map(strictify);29 if (!isObj(node)) return node;30 const out: Json = {};31 for (const [k, v] of Object.entries(node)) {32 if (DROP.has(k)) continue;33 if (k === 'properties' && isObj(v)) {34 const props: Json = {};35 for (const [pk, pv] of Object.entries(v)) props[pk] = strictify(pv);36 out.properties = props;37 } else if (k === 'enum' || k === 'const' || k === 'required') {38 out[k] = v;39 } else {40 out[k] = strictify(v);41 }42 }43 // `type: ['x','null']` → anyOf, one uniform nullable form.44 if (Array.isArray(out.type)) {45 const types = out.type as string[];46 const { type: _t, description, ...rest } = out;47 void _t;48 const nonNull = types.filter((t) => t !== 'null');49 const variants: Json[] = nonNull.map((t) => ({ ...rest, type: t }));50 if (types.includes('null')) variants.push({ type: 'null' });51 const merged: Json = { anyOf: variants };52 if (description !== undefined) merged.description = description;53 return merged;54 }55 if (out.type === 'object') {56 const props = isObj(out.properties) ? out.properties : {};57 const required = new Set(Array.isArray(out.required) ? (out.required as string[]) : []);58 for (const [pk, pv] of Object.entries(props)) {59 if (!required.has(pk)) {60 // optional ⇒ must be nullable so the model can send null61 if (isObj(pv) && !allowsNull(pv)) {62 const { description, ...rest } = pv;63 const wrapped: Json = { anyOf: [rest, { type: 'null' }] };64 if (description !== undefined) wrapped.description = description;65 props[pk] = wrapped;66 }67 }68 }69 out.properties = props;70 out.required = Object.keys(props);71 out.additionalProperties = false;72 }73 return out;74}7576export function toStrictJsonSchema(schema: z.ZodType): Record<string, unknown> {77 const raw = z.toJSONSchema(schema, { io: 'input' }) as Json;78 const strict = strictify(raw) as Json;79 if (strict.type !== 'object') throw new Error('tool input schema must be an object');80 return strict;81}8283/** Problems that would make a schema unusable with strict tool calling ([] = valid). Used by tests and by the engine at startup. */84export function strictSchemaProblems(schema: unknown, at = '$'): string[] {85 const problems: string[] = [];86 if (!isObj(schema)) return [`${at}: not an object`];87 for (const k of Object.keys(schema)) if (DROP.has(k)) problems.push(`${at}: unsupported keyword ${k}`);88 if ('$ref' in schema || '$defs' in schema) problems.push(`${at}: $ref/$defs not allowed`);89 if (Array.isArray(schema.type)) problems.push(`${at}: type arrays not allowed (use anyOf)`);90 const hasShape = typeof schema.type === 'string' || Array.isArray(schema.anyOf) || Array.isArray(schema.enum);91 if (!hasShape) problems.push(`${at}: no type/anyOf/enum`);92 if (schema.type === 'object') {93 if (schema.additionalProperties !== false) problems.push(`${at}: additionalProperties must be false`);94 const props = isObj(schema.properties) ? schema.properties : {};95 const req = Array.isArray(schema.required) ? (schema.required as string[]) : [];96 const keys = Object.keys(props);97 if (keys.length !== req.length || keys.some((k) => !req.includes(k))) problems.push(`${at}: every property must be listed in required`);98 for (const [k, v] of Object.entries(props)) problems.push(...strictSchemaProblems(v, `${at}.${k}`));99 }100 if (schema.type === 'array') {101 if (!isObj(schema.items)) problems.push(`${at}: array without items`);102 else problems.push(...strictSchemaProblems(schema.items, `${at}[]`));103 }104 if (Array.isArray(schema.anyOf)) schema.anyOf.forEach((v, i) => problems.push(...strictSchemaProblems(v, `${at}|${i}`)));105 return problems;106}107108/** Optional field: the model sends null (strict mode) or omits it (non-strict); both validate. */109export const opt = <T extends z.ZodType>(t: T) => t.nullable().optional();110