UNPKG

@tanstack/ai

Version:

Type-safe TypeScript AI SDK for streaming chat, tool calling, agents, structured outputs, and multimodal generation.

361 lines (360 loc) • 14.6 kB
import { StandardSchemaValidationError, convertSchemaToJsonSchema, isStandardJSONSchema, isStandardSchema, validateWithStandardSchema } from "./schema-converter.js"; import { dereference, initialBaseURI, validate } from "@cfworker/json-schema"; //#region src/activities/chat/tools/input-validation.ts var compiled = /* @__PURE__ */ new WeakMap(); /** Keywords whose value is data, not a schema. */ var DATA_KEYWORDS = /* @__PURE__ */ new Set([ "const", "enum", "default", "examples", "example" ]); /** Keywords whose value maps names to schemas. */ var SCHEMA_MAP_KEYWORDS = /* @__PURE__ */ new Set([ "properties", "patternProperties", "$defs", "definitions", "dependentSchemas", "dependencies" ]); function isRecord(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } /** Draft-04 and OpenAPI 3.0 write these bounds as booleans. */ var BOOLEAN_BOUNDS = [["exclusiveMinimum", "minimum"], ["exclusiveMaximum", "maximum"]]; /** Keywords that can reject `null` even when `type` allows it. */ var NULL_REJECTING_KEYWORDS = [ "$ref", "allOf", "anyOf", "oneOf", "not" ]; /** Keywords that name a schema. They stay on a wrapped schema. */ var NAMING_KEYWORDS = /* @__PURE__ */ new Set([ "$id", "id", "$schema", "$anchor" ]); /** A key as a JSON Pointer token, URI-encoded as cfworker stores paths. */ function pointerToken(key) { return encodeURI(key.replaceAll("~", "~0").replaceAll("/", "~1")); } /** Calls `visit` on each schema in `node`, inner schemas first. */ function eachSchema(node, path, visit) { if (Array.isArray(node)) { node.forEach((item, index) => eachSchema(item, `${path}/${index}`, visit)); return; } if (!isRecord(node)) return; for (const [key, value] of Object.entries(node)) { if (DATA_KEYWORDS.has(key)) continue; const keyPath = `${path}/${pointerToken(key)}`; if (SCHEMA_MAP_KEYWORDS.has(key) && isRecord(value)) for (const [name, schema] of Object.entries(value)) eachSchema(schema, `${keyPath}/${pointerToken(name)}`, visit); else eachSchema(value, keyPath, visit); } visit(node, path); } /** * Make one schema of the JSON copy ready for the validator, in place: * - `format` goes away. It is an annotation, as in 2019-09 and later: a tool * that declares `format: 'uri'` still gets a plain path. * - A boolean bound (`exclusiveMinimum: true`, draft-04 and OpenAPI 3.0) * becomes the number form, so it works in every draft. * - OpenAPI 3.0 `nullable: true` lets the value be `null`: `null` joins * `type` and `enum`. A schema with a `$ref`, `allOf`, `anyOf`, `oneOf`, or * `not` becomes `{ if: { type: 'null' }, else: schema }`, and its path goes * in `wrapped`. A failed `else` is an `if` error, which the error text * leaves out, so the model never reads "Expected null". */ function prepareSchema(schema, path, wrapped) { if (typeof schema.format === "string") delete schema.format; for (const [bound, limit] of BOOLEAN_BOUNDS) { if (typeof schema[bound] !== "boolean") continue; if (schema[bound] && typeof schema[limit] === "number") { schema[bound] = schema[limit]; delete schema[limit]; } else delete schema[bound]; } if (schema.nullable !== true) return; if (schema.type !== void 0) schema.type = [...new Set([schema.type, "null"].flat())]; if (Array.isArray(schema.enum) && !schema.enum.includes(null)) schema.enum.push(null); if (!NULL_REJECTING_KEYWORDS.some((key) => key in schema)) return; const inner = {}; for (const [key, value] of Object.entries(schema)) { if (NAMING_KEYWORDS.has(key)) continue; inner[key] = value; delete schema[key]; } schema.if = { type: "null" }; schema.else = inner; wrapped.push(path); } /** Any base works. A ref resolves as a URL, as cfworker reads it. */ var REF_BASE = "https://ref.invalid/"; /** A `$ref` into a wrapped schema, moved to where that schema went. */ function movedRef(ref, wrappedPath) { if (!ref.startsWith("#")) return ref; const target = new URL(ref, REF_BASE).href; const from = new URL(wrappedPath, REF_BASE).href; return target.startsWith(`${from}/`) ? `${from}/else${target.slice(from.length)}`.slice(20) : ref; } /** The draft named by `$schema`, else 2020-12. */ function draftOf(schema) { const uri = typeof schema.$schema === "string" ? schema.$schema : ""; if (/draft-0[467]/.test(uri)) return "7"; if (uri.includes("2019-09")) return "2019-09"; return "2020-12"; } function compile(schema) { if (typeof schema === "boolean") return { schema, draft: "2020-12", lookup: dereference(schema) }; const cached = compiled.get(schema); if (cached) return cached; const copy = JSON.parse(JSON.stringify(schema)); const wrapped = []; eachSchema(copy, "#", (node, path) => prepareSchema(node, path, wrapped)); if (wrapped.length > 0) eachSchema(copy, "#", (node) => { if (typeof node.$ref === "string") node.$ref = wrapped.reduce(movedRef, node.$ref); }); const result = { schema: copy, draft: draftOf(schema), lookup: dereference(copy) }; compiled.set(schema, result); return result; } var DECIMAL = /^-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?$/; function convertPrimitive(value, type) { switch (type) { case "number": case "integer": { if (value === null) return 0; if (typeof value === "boolean") return value ? 1 : 0; if (typeof value !== "string" || !DECIMAL.test(value)) return value; const number = Number(value); return (type === "integer" ? Number.isSafeInteger(number) : Number.isFinite(number)) ? number : value; } case "boolean": if (value === null || value === "false" || value === 0) return false; if (value === "true" || value === 1) return true; return value; case "string": if (value === null) return ""; return typeof value === "number" || typeof value === "boolean" ? String(value) : value; case "null": return value === "" || value === 0 || value === false ? null : value; default: return value; } } function matchesType(value, type) { switch (type) { case "null": return value === null; case "array": return Array.isArray(value); case "object": return typeof value === "object" && value !== null && !Array.isArray(value); case "integer": return typeof value === "number" && Number.isInteger(value); default: return typeof value === type; } } /** * Repair common model mistakes against the schema before the check: `"5"` * for 5, `"true"` for true, `null` for an optional field. It walks unions, * `$ref`, `if`/`then`/`else`, tuples, and pattern and additional properties. * A value that cannot be repaired stays as it is, so the check reports it. */ function coerceArguments(value, root) { const { draft, lookup } = root; function check(schema, candidate) { if (typeof schema === "boolean") return schema; return validate(candidate, schema, draft, lookup).valid; } function refTarget(schema) { const uri = schema.__absolute_ref__; const target = uri === void 0 ? void 0 : lookup[uri]; return typeof target === "object" ? target : void 0; } function coerceUnion(candidate, schemas, seen, required) { if (schemas.some((schema) => check(schema, candidate))) return candidate; for (const schema of schemas) { const converted = coerce(structuredClone(candidate), schema, seen, required); if (check(schema, converted)) return converted; } return candidate; } function requiredKeys(schema, candidate, seen = /* @__PURE__ */ new Set()) { if (typeof schema === "boolean" || seen.has(schema)) return /* @__PURE__ */ new Set(); const nextSeen = new Set(seen).add(schema); const required = new Set(Array.isArray(schema.required) ? schema.required : []); const children = [...schema.allOf ?? []]; const target = refTarget(schema); if (target) children.push(target); if (schema.if !== void 0) { const branch = check(schema.if, candidate) ? schema.then : schema.else; if (branch) children.push(branch); } for (const child of children) for (const key of requiredKeys(child, candidate, nextSeen)) required.add(key); return required; } function coerce(candidate, schema, seen = /* @__PURE__ */ new Set(), inheritedRequired = /* @__PURE__ */ new Set()) { if (typeof schema === "boolean") return candidate; if (seen.has(schema)) return candidate; const nextSeen = new Set(seen).add(schema); const required = requiredKeys(schema, candidate); for (const key of inheritedRequired) required.add(key); let next = candidate; const target = refTarget(schema); if (target) next = coerce(next, target, nextSeen, required); for (const nested of schema.allOf ?? []) next = coerce(next, nested, nextSeen, required); if (schema.anyOf) next = coerceUnion(next, schema.anyOf, nextSeen, required); if (schema.oneOf) next = coerceUnion(next, schema.oneOf, nextSeen, required); const types = typeof schema.type === "string" ? [schema.type] : schema.type ?? []; if (!types.some((type) => matchesType(next, type))) { const order = types.filter((type) => type !== "null"); if (types.includes("null")) order.push("null"); for (const type of order) { const converted = convertPrimitive(next, type); if (converted !== next) { next = converted; break; } } } if (typeof next === "object" && next !== null && !Array.isArray(next)) { const object = Object.fromEntries(Object.entries(next)); const properties = Object.entries(schema.properties ?? {}); const knownKeys = new Set(properties.map(([key]) => key)); for (const [key, property] of properties) { if (!Object.hasOwn(object, key)) continue; if (object[key] === null && !required.has(key) && !check(property, null)) delete object[key]; else object[key] = coerce(object[key], property); } for (const [pattern, property] of Object.entries(schema.patternProperties ?? {})) { const expression = new RegExp(pattern, "u"); for (const key of Object.keys(object)) { if (!expression.test(key)) continue; knownKeys.add(key); object[key] = coerce(object[key], property); } } const additional = schema.additionalProperties; if (typeof additional === "object") { for (const [key, entry] of Object.entries(object)) if (!knownKeys.has(key)) object[key] = coerce(entry, additional); } next = object; } if (Array.isArray(next)) { const tuple = schema.prefixItems ?? (Array.isArray(schema.items) ? schema.items : void 0); const rest = Array.isArray(schema.items) ? void 0 : schema.items; next = next.map((item, index) => { const itemSchema = tuple?.[index] ?? rest; return itemSchema === void 0 ? item : coerce(item, itemSchema); }); } if (schema.if !== void 0) { const branch = check(schema.if, next) ? schema.then : schema.else; if (branch) next = coerce(next, branch, nextSeen, required); } return next; } return coerce(value, root.schema); } /** Errors that only say that a nested check failed. The nested error says why. */ var WRAPPER_KEYWORDS = /* @__PURE__ */ new Set([ "properties", "patternProperties", "items", "prefixItems", "additionalItems", "unevaluatedItems", "$ref", "$recursiveRef", "allOf", "if" ]); /** More errors than this are counted, not listed. */ var MAX_ERRORS = 10; function validateJsonInput(schema, received, toolName, repair) { let errors; try { const json = JSON.stringify(received) ?? "null"; const value = JSON.parse(json); const root = compile(schema); const check = (candidate) => validate(candidate, root.schema, root.draft, root.lookup, false); const result = check(value); if (result.valid) return received; if (repair) { let converted = value; try { converted = coerceArguments(value, root); } catch {} if (JSON.stringify(converted) !== json && check(converted).valid) return converted; } const leaves = result.errors.filter((error) => !WRAPPER_KEYWORDS.has(error.keyword)); const shown = leaves.length > 0 ? leaves : result.errors; errors = shown.slice(0, MAX_ERRORS).map((error) => `${error.instanceLocation}: ${error.error}`).join(", "); if (shown.length > MAX_ERRORS) errors += `, and ${shown.length - MAX_ERRORS} more`; } catch (error) { errors = (error instanceof Error ? error.message : String(error)).split("\n", 1).join("").replaceAll(initialBaseURI.href, "").replace(/ +Absolute URI "[^"]*"\.?/, "").trim(); } throw new Error(`Input validation failed for tool ${toolName}: ${errors}`); } function standardInputError(issues, toolName) { const error = new StandardSchemaValidationError(issues); error.message = `Input validation failed for tool ${toolName}: ${error.message}`; return error; } /** * The final check of a tool input, just before the tool runs or the client * gets the call. A Standard Schema runs here (an async validator too), and * its output is the input, so transforms apply once. A JSON Schema (an MCP * tool, a plain JSON Schema tool) is checked with `@cfworker/json-schema`. * A failed input is repaired as `repair` says, then checked again. The error * of the first check goes back to the model as the tool result. */ async function validateToolInput(schema, received, toolName, repair) { try { return await checkToolInput(schema, received, toolName, repair === void 0); } catch (error) { if (typeof repair !== "function") throw error; const repaired = await repair(received, error instanceof Error ? error : new Error(String(error))); if (repaired === void 0) throw error; try { return await checkToolInput(schema, repaired, toolName, false); } catch { throw error; } } } async function checkToolInput(schema, received, toolName, repair) { if (schema === void 0) return received; if (typeof schema === "boolean") return validateJsonInput(schema, received, toolName, repair); if (!isStandardSchema(schema)) { const jsonSchema = convertSchemaToJsonSchema(schema, { io: "input" }); return jsonSchema ? validateJsonInput(jsonSchema, received, toolName, repair) : received; } const first = await validateWithStandardSchema(schema, received); if (first.success) return first.data; const original = standardInputError(first.issues, toolName); if (!repair || !isStandardJSONSchema(schema)) throw original; let converted; try { const jsonSchema = convertSchemaToJsonSchema(schema, { io: "input" }); if (!jsonSchema) throw original; converted = validateJsonInput(jsonSchema, received, toolName, true); } catch { throw original; } if (converted === received) throw original; const retried = await validateWithStandardSchema(schema, converted); if (!retried.success) throw original; return retried.data; } //#endregion export { validateToolInput }; //# sourceMappingURL=input-validation.js.map