UNPKG

@adonis-agora/agent

Version:

Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.

542 lines • 24.1 kB
import { jsonSchema, Output, streamText, tool, } from 'ai'; import { staticModelCatalog, } from '../spi/model-catalog.js'; /** * Adapt a Vercel AI SDK v7 `LanguageModel` to the core `ModelProvider` SPI so a host app writes * zero provider code. Streams text deltas to `args.sink`, returns the assembled text, requested * tool calls, usage, and (when a gateway reports it) the real USD cost. It never executes tools — * tools are handed to the SDK WITHOUT an `execute` fn, so the SDK returns tool-calls for the agent * loop to run as its own (replay-safe) steps. */ function idOf(model) { if (typeof model === 'string') return model; const id = model.modelId; return typeof id === 'string' ? id : undefined; } /** * The provider family of an AI SDK model, as models.dev names it: `openrouter.chat` → `openrouter`, * `anthropic.messages` → `anthropic`. A bare string id is routed by the Vercel AI Gateway (`vercel`). */ function providerOf(model) { if (typeof model === 'string') return 'vercel'; const provider = model.provider; if (typeof provider !== 'string' || provider.length === 0) return undefined; const family = provider.split('.')[0] ?? provider; return family === 'gateway' ? 'vercel' : family; } /** OpenRouter (`@openrouter/ai-sdk-provider`): reports the real, routed cost per call. */ function isOpenRouter(model) { return providerOf(model) === 'openrouter'; } /** * Whether a model's provider reports the call's real USD cost in its metadata — the Vercel AI * Gateway (`providerMetadata.gateway.cost`) and OpenRouter (`providerMetadata.openrouter.usage.cost`). * A direct provider reports only tokens; its cost is an estimate off the pricing table. */ function reportsCost(model) { const provider = providerOf(model); return provider === 'vercel' || provider === 'openrouter'; } function describeModel(model, id = idOf(model)) { if (id === undefined) return []; const provider = providerOf(model); return [ { modelId: id, ...(provider !== undefined ? { provider } : {}), reportsCost: reportsCost(model), }, ]; } export function aiSdkModel(model, opts) { const own = idOf(model); return { describeModels: () => describeModel(model, own), async runTurn(args) { // One model, so a pick naming another cannot be honoured — and running this one instead would // answer on a model nobody chose. `aiSdkModels` is the provider for a picker. if (args.model !== undefined && args.model !== own) { throw new Error(`aiSdkModel: this turn picked model "${args.model}", but the provider serves only ` + `"${own ?? 'its one model'}". Offer several with aiSdkModels({ … }).`); } return runTurnOn(model, opts ?? {}, args); }, }; } function groupOf(id, entry) { if (entry.provider !== undefined) return entry.provider; const slash = id.indexOf('/'); if (slash > 0) return id.slice(0, slash); const provider = typeof entry.model === 'string' ? undefined : entry.model.provider; return typeof provider === 'string' ? (provider.split('.')[0] ?? provider) : 'models'; } /** * Several AI SDK models behind one provider — what a model picker needs, and all it needs: the * provider carries its catalog (`.catalog`), which `GET <path>/models` serves when `config/agent.ts` * sets no `models`, and a turn runs on the picked id (else `default`). * * ```ts * model: () => aiSdkModels({ 'openai/gpt-4o-mini': 'openai/gpt-4o-mini', 'openai/gpt-4o': { model: openai('gpt-4o'), badges: ['smart'] } }), * ``` */ export function aiSdkModels(models, options = {}) { const { default: defaultId, providerLabels, ...settings } = options; const entries = Object.entries(models).map(([id, value]) => { const entry = typeof value === 'object' && value !== null && 'model' in value ? value : { model: value }; return [id, entry]; }); if (entries.length === 0) throw new Error('aiSdkModels: offer at least one model'); const byId = new Map(entries); const fallback = defaultId ?? entries[0]?.[0]; if (fallback === undefined || !byId.has(fallback)) { throw new Error(`aiSdkModels: default "${defaultId}" is not one of the offered models`); } const groups = new Map(); for (const [id, entry] of entries) { const group = groupOf(id, entry); const list = groups.get(group) ?? []; list.push({ id, label: entry.label ?? id, available: true, ...(entry.description !== undefined ? { description: entry.description } : {}), ...(entry.badges !== undefined ? { badges: entry.badges } : {}), ...(entry.contextWindow !== undefined ? { contextWindow: entry.contextWindow } : {}), }); groups.set(group, list); } const view = { default: fallback, providers: [...groups].map(([id, list]) => ({ id, label: providerLabels?.[id] ?? id.charAt(0).toUpperCase() + id.slice(1), models: list, })), }; return { catalog: staticModelCatalog(view), describeModels: () => entries.flatMap(([, entry]) => describeModel(entry.model)), async runTurn(args) { const id = args.model ?? fallback; const entry = byId.get(id); if (entry === undefined) { throw new Error(`aiSdkModels: this turn picked model "${id}", which is not offered (${[...byId.keys()].join(', ')}).`); } return runTurnOn(entry.model, settings, args); }, }; } async function runTurnOn(model, settings, args) { // `output` makes the provider constrain generation to the schema. It is only offered for a // schema the SDK can convert; for anything else the call goes out unconstrained and the loop // reads the JSON out of the reply text, which is what `toSdkOutput` returning undefined means. const output = args.outputSchema !== undefined ? toSdkOutput(args.outputSchema) : undefined; const prompt = hoistSystemMessages(args.system, args.messages); const result = streamText({ ...settings, ...withOpenRouterUsageAccounting(model, settings.providerOptions), model, ...prompt, tools: mapTools(args.tools), ...(output !== undefined ? { output } : {}), ...(args.abortSignal ? { abortSignal: args.abortSignal } : {}), }); // Text stays a `text` frame (the one kind the output gate reads); everything else the model // streams goes out in the agent protocol's vocabulary, so a client draws the thinking and the // tool cards live. Tool RESULTS and step brackets are the loop's to write. let text = ''; for await (const part of result.stream) { switch (part.type) { case 'text-delta': text += part.text; await args.sink.write({ t: 'text', v: part.text }); break; case 'reasoning-delta': await args.sink.write({ t: 'event', event: { kind: 'reasoning', text: part.text } }); break; case 'tool-input-start': await args.sink.write({ t: 'event', event: { kind: 'tool-input-start', id: part.id, name: part.toolName, toolKind: streamToolKind(part.toolName, args.tools), }, }); break; case 'tool-input-delta': await args.sink.write({ t: 'event', event: { kind: 'tool-input-delta', id: part.id, delta: part.delta }, }); break; case 'tool-call': await args.sink.write({ t: 'event', event: { kind: 'tool-input-available', id: part.toolCallId, name: part.toolName, input: part.input, toolKind: streamToolKind(part.toolName, args.tools), }, }); break; case 'error': // The provider's own failure. Thrown as it is: left alone, the stream ends empty and the // accessors below reject with the SDK's "No output generated. Check the stream for errors." // — which names no cause, and is all anyone would ever read. throw part.error instanceof Error ? part.error : new Error(typeof part.error === 'string' ? part.error : JSON.stringify(part.error)); default: break; } } // The promise accessors resolve once the stream is fully consumed above. `modelId` and the // reported cost live on the final step (the top-level aliases are deprecated in AI SDK v7). const [toolCalls, usage, finalStep] = await Promise.all([ result.toolCalls, result.usage, result.finalStep, ]); // What the provider itself parsed. Handed back for the loop to VALIDATE — never as the // finished answer: "the provider says it matched" is a different claim from "it matches". const object = output === undefined ? undefined : await result.output; const modelId = finalStep.response.modelId; const costUsd = extractCostUsd(finalStep.providerMetadata); if (costUsd === undefined && isOpenRouter(model)) { warnOpenRouterCostMissing(idOf(model) ?? modelId ?? 'unknown'); } return { text, toolCalls: toolCalls.map(mapToolCall), usage: mapUsage(usage), ...(typeof modelId === 'string' && modelId.length > 0 ? { modelId } : {}), ...(costUsd !== undefined ? { costUsd } : {}), ...(object !== undefined ? { object } : {}), }; } /** The stream's two-valued tool kind: only an `action` waits for a person before it runs. */ function streamToolKind(toolName, tools) { return tools.find((definition) => definition.name === toolName)?.kind === 'action' ? 'action' : 'read'; } /** * The turn's `instructions` and `messages`, with every `system` message the conversation carries * moved into `instructions` — in order, after the turn's own system prompt. * * AI SDK 7 refuses a `system` message in `messages` (`AI_InvalidPromptError: System messages are not * allowed in the prompt or messages fields. Use the instructions option instead.`), and even with * `allowSystemInMessages` some providers refuse one that follows a user or assistant message. A * `ModelMessage` may still be `system` (a stored one, or a host's own provider wrapper feeding this * one), so the adapter hoists rather than trusting every caller to have kept them out. */ function hoistSystemMessages(system, messages) { const parts = system === '' ? [] : [system]; const conversation = []; for (const message of messages) { if (message.role === 'system') { if (message.content.trim() !== '') parts.push(message.content); } else { conversation.push(message); } } return { instructions: parts.join('\n\n'), messages: mapMessages(conversation) }; } /** Maps the conversation's user and assistant messages; `system` ones are {@link hoistSystemMessages}'s. */ function mapMessages(messages) { const out = []; for (const message of messages) { if (message.role === 'system') { continue; } if (message.role === 'user') { const toolResults = message.toolResults ?? []; // A pure tool-result carrier (`{ content: '', toolResults }`, pushed by the agent loop to // feed results back to the model) must NOT also emit an empty user turn — the SDK requires // the `tool` message to immediately follow the assistant `tool-call` message, and a wedged // empty user message in between breaks that adjacency (`AI_MissingToolResultsError`). if (message.content.length > 0 || toolResults.length === 0) { const attachments = message.attachments ?? []; out.push(attachments.length === 0 ? { role: 'user', content: message.content } : { role: 'user', content: userContentWithAttachments(message.content, attachments) }); } pushToolResults(out, toolResults); continue; } const toolCalls = message.toolCalls ?? []; // Anthropic and Bedrock refuse a whole request carrying an assistant message with empty content // or a whitespace-only text block. Blank text is therefore never sent as a part, and a blank // assistant message with no tool calls is not sent at all: it says nothing the model needs. const hasText = message.content.trim().length > 0; if (toolCalls.length > 0) { const content = []; if (hasText) { content.push({ type: 'text', text: message.content }); } for (const call of toolCalls) { content.push({ type: 'tool-call', toolCallId: call.id, toolName: call.name, input: call.input, }); } out.push({ role: 'assistant', content: assistantContent(content) }); } else if (hasText) { out.push({ role: 'assistant', content: message.content }); } pushToolResults(out, message.toolResults ?? []); } return out; } /** * Append a `tool` message mapping each core `ToolResult` to an SDK `ToolResultPart`, when there * are any. Shared by the assistant branch (tool results following its own tool-calls) and the * user branch (the agent loop's `{ role: 'user', content: '', toolResults }` carrier) so both * faithfully surface results to the SDK instead of silently dropping them. */ function pushToolResults(out, toolResults) { if (toolResults.length === 0) { return; } out.push({ role: 'tool', content: toolResults.map((result) => ({ type: 'tool-result', toolCallId: result.id, toolName: result.name, output: toModelOutput(result), })), }); } /** `Array<TextPart | ToolCallPart>` is a valid `AssistantContent`; name the widening explicitly. */ function assistantContent(parts) { return parts; } /** * Build a multimodal user content array: the text (when non-empty) followed by one part per * attachment — `image/*` → an image part, everything else → a file part (Bedrock Claude reads a PDF * this way). The attachment's `url` is passed straight through as the part's source; making it * reachable by the provider is the consumer's concern (the staging SPI's job), not the adapter's. */ function userContentWithAttachments(text, attachments) { const parts = []; if (text.length > 0) { parts.push({ type: 'text', text }); } for (const attachment of attachments) { parts.push(attachment.contentType.startsWith('image/') ? { type: 'image', image: new URL(attachment.url), mediaType: attachment.contentType } : { type: 'file', data: new URL(attachment.url), mediaType: attachment.contentType, filename: attachment.name, }); } return parts; } /** * NOTE: core tool `output` is `unknown`, but the SDK's structured `json` output demands a * `JSONValue`. Rather than an unsafe cast we serialise every result to text — the model reads * tool output as text regardless, and the loop already validated the tool INPUT via the schema. */ function toModelOutput(result) { if (result.error !== undefined) { return { type: 'text', value: result.error }; } const { output } = result; if (typeof output === 'string') { return { type: 'text', value: output }; } return { type: 'text', value: JSON.stringify(output ?? null) }; } /** * Map core `ToolDefinition[]` → an SDK `ToolSet`. Each tool is built WITHOUT an `execute` fn so the * SDK surfaces the tool-call for the agent loop to run, instead of executing it inline. */ function mapTools(tools) { const set = {}; for (const definition of tools) { set[definition.name] = tool({ description: definition.description, inputSchema: toSdkInputSchema(definition.inputSchema), }); } return set; } /** * Convert a core `StandardSchemaV1` into the schema the SDK feeds the model as tool parameters. * The SDK's own `asSchema` derives a precise JSON schema from exactly two kinds of Standard Schema, * so we hand those straight through and let it do the conversion: * * - **Zod** (`~standard.vendor === 'zod'`) — the SDK runs zod-to-json-schema natively. Zod 3 does * NOT expose the Standard JSON Schema extension, so this vendor tag is the only way to recognise * it, and it's the common case (`@AiTool({ input: z.object(...) })`). * - **Standard JSON Schema** (`~standard.jsonSchema`) — Valibot, ArkType, and Zod 4 implement the * extension; the SDK calls its `input()` converter to derive the schema. * * Anything else is a bare Standard Schema the SDK can't introspect (its `asSchema` throws on one), so * we degrade to a permissive object schema — the model loses the parameter shapes, but the agent loop * still validates the tool input against the real schema via `~standard.validate` before running it. */ function toSdkInputSchema(schema) { if (isZodSchema(schema) || hasStandardJsonSchema(schema)) { return schema; } return jsonSchema({ type: 'object', properties: {}, additionalProperties: true }); } /** * The SDK's structured-output spec for a schema it can convert, or `undefined` for a bare Standard * Schema it cannot introspect — the same narrowing {@link toSdkInputSchema} does, and the same * degradation: the provider stops constraining generation, and the loop validates the reply anyway. */ function toSdkOutput(schema) { if (isZodSchema(schema) || hasStandardJsonSchema(schema)) { return Output.object({ schema }); } return undefined; } /** * True for a Zod schema. Zod tags its Standard Schema props with `vendor: 'zod'`, and its own type * declares `~standard`, so this narrows to `ZodTypeAny` — a member of the SDK's `FlexibleSchema` — * without a cast, letting the SDK convert it natively. */ function isZodSchema(schema) { return schema['~standard'].vendor === 'zod'; } /** True when the schema carries the Standard JSON Schema converter (`~standard.jsonSchema.input`). */ function hasStandardJsonSchema(schema) { const standard = schema['~standard']; if (!('jsonSchema' in standard)) { return false; } const converter = standard.jsonSchema; return (typeof converter === 'object' && converter !== null && 'input' in converter && typeof converter.input === 'function'); } function mapToolCall(call) { return { id: call.toolCallId, name: call.toolName, input: call.input }; } /** * Map SDK usage → core `MessageUsage`. Cache/reasoning breakdowns are optional and only added when * the provider reports them (conditional spread, never an `undefined` assignment). AI SDK v7 carries * them on the `*Details` objects; the deprecated flat aliases (`cachedInputTokens`, `reasoningTokens`) * were removed. */ function mapUsage(usage) { const cacheReadTokens = usage.inputTokenDetails?.cacheReadTokens; const cacheWriteTokens = usage.inputTokenDetails?.cacheWriteTokens; const reasoningTokens = usage.outputTokenDetails?.reasoningTokens; return { inputTokens: usage.inputTokens ?? 0, outputTokens: usage.outputTokens ?? 0, ...(cacheWriteTokens !== undefined ? { cacheWriteTokens } : {}), ...(cacheReadTokens !== undefined ? { cacheReadTokens } : {}), ...(reasoningTokens !== undefined ? { reasoningTokens } : {}), }; } /** * OpenRouter only bills what it reports, and reports cost in the response only when usage accounting * is asked for (`usage: { include: true }`). The OpenRouter provider forwards * `providerOptions.openrouter` into the request body, so the adapter asks on every OpenRouter call — * unless the app set its own `usage`, which wins. Any other model gets the options untouched. */ function withOpenRouterUsageAccounting(model, providerOptions) { if (!isOpenRouter(model)) { return providerOptions !== undefined ? { providerOptions } : {}; } const own = providerOptions?.openrouter ?? {}; return { providerOptions: { ...providerOptions, openrouter: { usage: { include: true }, ...own }, }, }; } const openRouterCostWarned = new Set(); /** * Said once per model per process: an OpenRouter call came back without `usage.cost`. That should not * happen with the official provider (the adapter asks for usage accounting); it does when the model * is OpenRouter reached through another SDK, or an old provider version that drops the field. */ function warnOpenRouterCostMissing(modelId) { if (openRouterCostWarned.has(modelId)) return; openRouterCostWarned.add(modelId); console.warn(`[@adonis-agora/agent] OpenRouter model "${modelId}" returned no cost ` + '(`providerMetadata.openrouter.usage.cost`). Cost falls back to the pricing table, which may ' + 'have no row for it. Use `@openrouter/ai-sdk-provider` >= 1 (`createOpenRouter(...).chat(id)`); ' + 'do not set `usage: { include: false }`.'); } /** Test seam: forget which models already warned. */ export function resetOpenRouterCostWarnings() { openRouterCostWarned.clear(); } /** * The provider-reported USD cost of the call, when a gateway reports one: * - Vercel AI Gateway: `providerMetadata.gateway.cost` (a decimal string). * - OpenRouter (`@openrouter/ai-sdk-provider`): `providerMetadata.openrouter.usage.cost` — what the * account was actually charged for the routed provider, which varies per call. `total_cost` (top * level or under `usage`) is the shape older integrations used, kept as a fallback. */ function extractCostUsd(metadata) { if (!metadata) { return undefined; } const gateway = metadata.gateway; const gatewayCost = gateway ? toFiniteNumber(gateway.cost) : undefined; if (gatewayCost !== undefined) { return gatewayCost; } const openrouter = metadata.openrouter; if (openrouter) { const direct = toFiniteNumber(openrouter.total_cost); if (direct !== undefined) { return direct; } const usage = asJsonObject(openrouter.usage); if (usage) { const nested = toFiniteNumber(usage.cost) ?? toFiniteNumber(usage.total_cost); if (nested !== undefined) { return nested; } } } return undefined; } function toFiniteNumber(value) { if (typeof value === 'number') { return Number.isFinite(value) ? value : undefined; } if (typeof value === 'string') { const parsed = Number.parseFloat(value); return Number.isFinite(parsed) ? parsed : undefined; } return undefined; } function asJsonObject(value) { // AI SDK JSON arrays may be readonly; Array.isArray only narrows mutable arrays in TS. if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined; return value; } //# sourceMappingURL=ai-sdk-model.js.map