SPB Git

Repository Public

schema.ts 4.9 KB 110 lines typescript

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
Files main
Menu

Theme