@tanstack/ai
Version:
Type-safe TypeScript AI SDK for streaming chat, tool calling, agents, structured outputs, and multimodal generation.
206 lines (190 loc) • 5.57 kB
text/typescript
import {
canonicalInterruptJson,
digestInterruptJson,
} from '../../../interrupt-serialization'
import { isStandardJSONSchema, isStandardSchema } from './schema-converter'
import type { StandardSchemaV1 } from '@standard-schema/spec'
import type { JSONSchema, SchemaInput } from '../../../types'
import type { ApprovalSchemaConfig } from './tool-definition'
export interface NormalizedSchemaInput {
source: SchemaInput
validator?: StandardSchemaV1
jsonSchema?: JSONSchema
}
export interface NormalizedApprovalSchema {
branches: {
approve: NormalizedSchemaInput | null
reject: NormalizedSchemaInput | null
}
responseSchema: JSONSchema
responseSchemaHash: string
approvalSchemaHash: string
}
const jsonSchemaKeywords = new Set([
'$schema',
'$id',
'$ref',
'$defs',
'type',
'properties',
'required',
'additionalProperties',
'items',
'oneOf',
'anyOf',
'allOf',
'enum',
'const',
'format',
'minimum',
'maximum',
'minLength',
'maxLength',
'pattern',
])
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return (
value !== null &&
typeof value === 'object' &&
!Array.isArray(value) &&
(Object.getPrototypeOf(value) === Object.prototype ||
Object.getPrototypeOf(value) === null)
)
}
function isRawJsonSchema(value: unknown): value is JSONSchema {
return (
isPlainRecord(value) &&
Object.keys(value).some((key) => jsonSchemaKeywords.has(key))
)
}
function isSchemaInput(value: unknown): value is SchemaInput {
return (
isStandardSchema(value) ||
isStandardJSONSchema(value) ||
isRawJsonSchema(value)
)
}
function isApprovalBranchMap(
value: unknown,
): value is { approve?: SchemaInput; reject?: SchemaInput } {
if (!isPlainRecord(value)) return false
const keys = Object.keys(value)
return (
keys.length > 0 &&
keys.every((key) => key === 'approve' || key === 'reject') &&
keys.every((key) => isSchemaInput(value[key]))
)
}
function toJsonSchema(value: Record<string, unknown>): JSONSchema {
const result: JSONSchema = {}
for (const [key, item] of Object.entries(value)) {
result[key] = item
}
return result
}
function schemaToWire(schema: SchemaInput): NormalizedSchemaInput {
if (isStandardSchema(schema)) {
const jsonSchema = isStandardJSONSchema(schema)
? toJsonSchema(
schema['~standard'].jsonSchema.input({
target: 'draft-2020-12',
}),
)
: undefined
return {
source: schema,
validator: schema,
...(jsonSchema !== undefined && { jsonSchema }),
}
}
if (isRawJsonSchema(schema)) {
// The library does not compile or validate raw JSON Schema; it is carried
// on the wire and hashed as-is. Validation is the application's job.
return { source: schema, jsonSchema: schema }
}
throw new TypeError('Expected a supported SchemaInput.')
}
function decisionEnvelope(input: {
approved: boolean
payload: NormalizedSchemaInput | null
inputSchema: NormalizedSchemaInput | null
}): JSONSchema {
const properties: Record<string, JSONSchema> = {
approved: { const: input.approved },
}
const required = ['approved']
if (input.approved && input.inputSchema) {
properties['editedArgs'] = input.inputSchema.jsonSchema ?? {}
}
if (input.payload) {
properties['payload'] = input.payload.jsonSchema ?? {}
required.push('payload')
}
return {
type: 'object',
properties,
required,
additionalProperties: false,
}
}
export function normalizeApprovalSchema(
approvalSchema: ApprovalSchemaConfig | undefined,
inputSchema?: SchemaInput,
): NormalizedApprovalSchema {
const normalizedInput =
inputSchema === undefined ? null : schemaToWire(inputSchema)
let approve: NormalizedSchemaInput | null = null
let reject: NormalizedSchemaInput | null = null
if (approvalSchema !== undefined) {
if (isStandardSchema(approvalSchema) || isRawJsonSchema(approvalSchema)) {
approve = schemaToWire(approvalSchema)
reject = approve
} else if (isApprovalBranchMap(approvalSchema)) {
approve =
approvalSchema.approve === undefined
? null
: schemaToWire(approvalSchema.approve)
reject =
approvalSchema.reject === undefined
? null
: schemaToWire(approvalSchema.reject)
} else {
throw new TypeError(
'approvalSchema must be a SchemaInput or a nonempty map containing approve or reject.',
)
}
}
const responseSchema: JSONSchema = {
oneOf: [
decisionEnvelope({
approved: true,
payload: approve,
inputSchema: normalizedInput,
}),
decisionEnvelope({
approved: false,
payload: reject,
inputSchema: null,
}),
],
}
const responseCanonical = canonicalInterruptJson(responseSchema)
const approvalCanonical = canonicalInterruptJson({
approve: approve?.jsonSchema ?? null,
reject: reject?.jsonSchema ?? null,
})
return {
branches: { approve, reject },
responseSchema,
responseSchemaHash: digestInterruptJson(responseCanonical),
approvalSchemaHash: digestInterruptJson(approvalCanonical),
}
}
export function hashSchemaInput(schema: SchemaInput | undefined): string {
if (schema === undefined) return digestInterruptJson('undefined')
const normalized = schemaToWire(schema)
const identity = normalized.jsonSchema ?? {
standardValidator: 'unserialized',
}
return digestInterruptJson(canonicalInterruptJson(identity))
}