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