@tanstack/ai
Version:
Type-safe TypeScript AI SDK for streaming chat, tool calling, agents, structured outputs, and multimodal generation.
558 lines (523 loc) • 19 kB
text/typescript
import { dereference, initialBaseURI, validate } from '@cfworker/json-schema'
import {
StandardSchemaValidationError,
convertSchemaToJsonSchema,
isStandardJSONSchema,
isStandardSchema,
validateWithStandardSchema,
} from './schema-converter'
import type { StandardSchemaV1 } from '@standard-schema/spec'
import type { Schema, SchemaDraft } from '@cfworker/json-schema'
import type { JSONSchema, SchemaInput } from '../../../types'
/** A JSON Schema ready for `validate`: the copy, its draft, its `$ref` lookup. */
interface CompiledSchema {
schema: Schema | boolean
draft: SchemaDraft
lookup: Record<string, Schema | boolean>
}
const compiled = new WeakMap<JSONSchema, CompiledSchema>()
/** Keywords whose value is data, not a schema. */
const DATA_KEYWORDS = new Set([
'const',
'enum',
'default',
'examples',
'example',
])
/** Keywords whose value maps names to schemas. */
const SCHEMA_MAP_KEYWORDS = new Set([
'properties',
'patternProperties',
'$defs',
'definitions',
'dependentSchemas',
'dependencies',
])
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
/** Draft-04 and OpenAPI 3.0 write these bounds as booleans. */
const BOOLEAN_BOUNDS = [
['exclusiveMinimum', 'minimum'],
['exclusiveMaximum', 'maximum'],
] as const
/** Keywords that can reject `null` even when `type` allows it. */
const NULL_REJECTING_KEYWORDS = ['$ref', 'allOf', 'anyOf', 'oneOf', 'not']
/** Keywords that name a schema. They stay on a wrapped schema. */
const NAMING_KEYWORDS = new Set(['$id', 'id', '$schema', '$anchor'])
/** A key as a JSON Pointer token, URI-encoded as cfworker stores paths. */
function pointerToken(key: string) {
return encodeURI(key.replaceAll('~', '~0').replaceAll('/', '~1'))
}
/** Calls `visit` on each schema in `node`, inner schemas first. */
function eachSchema(
node: unknown,
path: string,
visit: (schema: Record<string, unknown>, path: string) => void,
): void {
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: Record<string, unknown>,
path: string,
wrapped: Array<string>,
) {
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 !== undefined) {
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: Record<string, unknown> = {}
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. */
const REF_BASE = 'https://ref.invalid/'
/** A `$ref` into a wrapped schema, moved to where that schema went. */
function movedRef(ref: string, wrappedPath: string) {
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(REF_BASE.length)
: ref
}
/** The draft named by `$schema`, else 2020-12. */
function draftOf(schema: JSONSchema): SchemaDraft {
const uri = typeof schema.$schema === 'string' ? schema.$schema : ''
// Boolean bounds are numbers by now, and cfworker reads numeric bounds as
// booleans in its draft-04 mode. Draft 7 checks the rest the same way.
if (/draft-0[467]/.test(uri)) return '7'
if (uri.includes('2019-09')) return '2019-09'
return '2020-12'
}
function compile(schema: JSONSchema | boolean): CompiledSchema {
if (typeof schema === 'boolean') {
return { schema, draft: '2020-12', lookup: dereference(schema) }
}
const cached = compiled.get(schema)
if (cached) return cached
// `dereference` writes lookup data onto the schema it gets. A JSON copy
// keeps the tool's own schema, which also goes to the provider, unchanged.
const copy: Schema = JSON.parse(JSON.stringify(schema))
const wrapped: Array<string> = []
eachSchema(copy, '#', (node, path) => prepareSchema(node, path, wrapped))
// Inner schemas were wrapped first, so their moves apply first.
// ponytail: refs are read as paths from the root. An absolute ref, and a
// ref inside a nullable schema with its own `$id`, are not moved.
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
}
const DECIMAL = /^-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?$/
function convertPrimitive(value: unknown, type: string) {
switch (type) {
case 'number':
case 'integer': {
if (value === null) return 0
if (typeof value === 'boolean') return value ? 1 : 0
// Decimal text only: no hex, and no integer past the safe range.
if (typeof value !== 'string' || !DECIMAL.test(value)) return value
const number = Number(value)
const valid =
type === 'integer'
? Number.isSafeInteger(number)
: Number.isFinite(number)
return valid ? 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: unknown, type: string) {
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: unknown, root: CompiledSchema) {
const { draft, lookup } = root
function check(schema: Schema | boolean, candidate: unknown) {
if (typeof schema === 'boolean') return schema
return validate(candidate, schema, draft, lookup).valid
}
function refTarget(schema: Schema) {
const uri = schema.__absolute_ref__
const target = uri === undefined ? undefined : lookup[uri]
return typeof target === 'object' ? target : undefined
}
function coerceUnion(
candidate: unknown,
schemas: Array<Schema>,
seen: Set<Schema>,
required: Set<string>,
) {
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: Schema | boolean,
candidate: unknown,
seen = new Set<Schema>(),
): Set<string> {
if (typeof schema === 'boolean' || seen.has(schema)) return new Set()
const nextSeen = new Set(seen).add(schema)
const required = new Set(
Array.isArray(schema.required) ? schema.required : [],
)
const children: Array<Schema | boolean> = [...(schema.allOf ?? [])]
const target = refTarget(schema)
if (target) children.push(target)
if (schema.if !== undefined) {
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: unknown,
schema: Schema | boolean,
seen = new Set<Schema>(),
inheritedRequired = new Set<string>(),
): unknown {
if (typeof schema === 'boolean') return candidate
// A reference cycle at the same value cannot add another conversion.
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))) {
// `null` last: `0` for `['null', 'string']` is `"0"`, not `null`.
const order: Array<string> = 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)) {
// Own keys only, `__proto__` included.
const object: Record<string, unknown> = 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)
) {
// A strict-mode provider sends `null` for an optional field.
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 : undefined)
const rest = Array.isArray(schema.items) ? undefined : schema.items
next = next.map((item: unknown, index) => {
const itemSchema = tuple?.[index] ?? rest
return itemSchema === undefined ? item : coerce(item, itemSchema)
})
}
if (schema.if !== undefined) {
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. */
const WRAPPER_KEYWORDS = new Set([
'properties',
'patternProperties',
'items',
'prefixItems',
'additionalItems',
'unevaluatedItems',
'$ref',
'$recursiveRef',
'allOf',
// "Instance does not match "then" schema." (or "else")
'if',
])
/** More errors than this are counted, not listed. */
const MAX_ERRORS = 10
function validateJsonInput(
schema: JSONSchema | boolean,
received: unknown,
toolName: string,
repair: boolean,
) {
let errors: string
try {
// Check the JSON form: a key set to `undefined` is the same as no key,
// and an input with no JSON form (`undefined`) is `null`.
const json = JSON.stringify(received) ?? 'null'
const value: unknown = JSON.parse(json)
const root = compile(schema)
const check = (candidate: unknown) =>
validate(candidate, root.schema, root.draft, root.lookup, false)
const result = check(value)
if (result.valid) return received
// Repair only an input that fails. The error reports the input as sent.
if (repair) {
let converted = value
try {
converted = coerceArguments(value, root)
} catch {
// A schema that cannot be used: the first check reports it.
}
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) {
// A schema the validator cannot use, such as an unresolved `$ref`, or a
// value with no JSON form. The first line is enough for the model.
// Drop the validator base (in a browser, the page URL and its query).
const message = error instanceof Error ? error.message : String(error)
errors = message
.split('\n', 1)
.join('')
.replaceAll(initialBaseURI.href, '')
.replace(/ +Absolute URI "[^"]*"\.?/, '')
.trim()
}
throw new Error(`Input validation failed for tool ${toolName}: ${errors}`)
}
function standardInputError(
issues: ReadonlyArray<StandardSchemaV1.Issue>,
toolName: string,
) {
const error = new StandardSchemaValidationError(issues)
error.message = `Input validation failed for tool ${toolName}: ${error.message}`
return error
}
/**
* How a failed tool input is repaired. `undefined`: the built-in repair.
* `false`: no repair. A function: called instead of the built-in repair. It
* returns the input to check again, or `undefined` to keep the error.
*/
export type ToolInputRepair =
| false
| ((input: unknown, error: Error) => unknown | Promise<unknown>)
/**
* 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.
*/
export async function validateToolInput(
schema: SchemaInput | boolean | undefined,
received: unknown,
toolName: string,
repair?: ToolInputRepair,
): Promise<unknown> {
try {
return await checkToolInput(
schema,
received,
toolName,
repair === undefined,
)
} catch (error) {
if (typeof repair !== 'function') throw error
const repaired = await repair(
received,
error instanceof Error ? error : new Error(String(error)),
)
if (repaired === undefined) throw error
try {
return await checkToolInput(schema, repaired, toolName, false)
} catch {
throw error
}
}
}
async function checkToolInput(
schema: SchemaInput | boolean | undefined,
received: unknown,
toolName: string,
repair: boolean,
): Promise<unknown> {
if (schema === undefined) 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<unknown>(schema, received)
if (first.success) return first.data
// The authored schema runs first. When it fails and it can export JSON
// Schema, repair the input and run the authored schema again. A failure
// reports the issues of the first run.
const original = standardInputError(first.issues, toolName)
if (!repair || !isStandardJSONSchema(schema)) throw original
let converted: unknown
try {
const jsonSchema = convertSchemaToJsonSchema(schema, { io: 'input' })
if (!jsonSchema) throw original
converted = validateJsonInput(jsonSchema, received, toolName, true)
} catch {
throw original
}
// Nothing was repaired: a second run gives the same issues.
if (converted === received) throw original
const retried = await validateWithStandardSchema<unknown>(schema, converted)
if (!retried.success) throw original
return retried.data
}