@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
JavaScript
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