Cost & Governance
The usage ledger and read-model — spend per model and actor, a gateway's reported cost preferred over a cache-aware estimate, daily quota, and the console that surfaces it all.
Every turn writes a row to a usage ledger. A read-model rolls those rows up into spend per model, spend per actor, a usage trend over time, and recent tool-call / thread activity — the numbers behind the governance console. Cost is resolved per row, and the model is honest about what it knows: a gateway's real reported cost wins; otherwise it estimates, and the estimate accounts for prompt caching.
The ledger
Each model turn appends an agent_token_usage row: the actor, thread, model id, purpose, the token
counts, and — when a gateway reported it — the real dollar cost. This is the raw material; you never
write to it directly, the loop does. Aggregation happens in the read-model, in-process, so the
day-bucketing stays engine-portable across MikroORM and Drizzle.
Cost per row: reported wins, else estimate
costUsd = COALESCE(reportedCostUsd, cache-aware token estimate)Cost is computed per usage row and then summed — never once on an aggregate — so a gateway-priced turn and a token-estimated turn can sit side by side in the same rollup and each be correct.
A gateway's reported cost wins
A gateway knows the real spend of a turn. When it reports one, the model provider surfaces it on the turn result and it is stored verbatim:
| Provider | Where the real cost comes from |
|---|---|
| Vercel AI Gateway | providerMetadata.gateway.cost |
| OpenRouter | total_cost |
| Direct (Anthropic / OpenAI / Bedrock) | only tokens reported → falls through to the estimate |
Your ModelProvider sets costUsd on the ModelTurnResult when it has it; the loop persists it to
the nullable cost_usd column, and the read-model uses it verbatim. Preferring the report per row
is why prompt-cache pricing skew never reaches numbers a gateway already priced.
// inside your ModelProvider.runTurn — pass the gateway's number straight through
return {
text,
toolCalls,
usage: { inputTokens, outputTokens },
costUsd: providerMetadata?.gateway?.cost, // undefined for direct providers — that's fine
};The fallback estimate is cache-aware
When no gateway priced the turn, the estimate runs against the current pricing row for the model
(AgentModelPricing where isCurrent). Prompt caching makes a naive inputTokens × inputPrice
wrong — cache writes cost a premium, cache reads a fraction — so the estimate splits the input side:
estimate =
uncachedInput × inputPricePer1m
+ cacheWrite × cacheWritePricePer1m (falls back to inputPricePer1m)
+ cacheRead × cacheReadPricePer1m (falls back to inputPricePer1m)
+ output × outputPricePer1mMessageUsage carries the split, and the counts are subsets of inputTokens
(uncachedInput = inputTokens − cacheWrite − cacheRead):
interface MessageUsage {
inputTokens: number; // the whole input side, cached and uncached alike
outputTokens: number;
cacheWriteTokens?: number; // subset of inputTokens — billed at a premium
cacheReadTokens?: number; // subset of inputTokens — billed at a discount
reasoningTokens?: number; // subset of outputTokens — observability only (output-rate)
}Backward compatible by construction
Because the cache counts are subsets of inputTokens, token totals and quota never change when a
breakdown is present. And because the cache rates are nullable, a pricing table with no cache data
reduces exactly to the old input×inputPrice + output×outputPrice. Reasoning tokens ride along
for observability but are billed at the output rate, so they don't move the estimate.
The pricing table
Prices are versioned rows keyed by model, with the live one flagged isCurrent. Add the nullable
cache rates to make the fallback precise for prompt-cached models:
// one current row per model
{
modelId: 'claude-sonnet-4-6',
inputPricePer1m: 3,
outputPricePer1m: 15,
cacheWritePricePer1m: 3.75, // ~1.25× input
cacheReadPricePer1m: 0.3, // ~0.1× input
effectiveFrom: new Date('2026-06-01T00:00:00Z'),
isCurrent: true,
}Supersede a price by inserting a new isCurrent: true row and clearing the flag on the old one; the
read-model always joins to the current row, and historical rows are ignored.
Seeding prices
Unpriced models cost $0
The pricing table starts empty. Until something writes a row for a model, that model's estimate has
no current price to join against, and every turn against it is costed at $0 — tokens still
accumulate correctly, only the dollar figure is missing. Seeding prices isn't optional for accurate
cost reporting; it's a required setup step, same as wiring a store.
The write side is a separate SPI, AgentPricingStore (from @dudousxd/nestjs-agent-core), bound to
the AGENT_PRICING_STORE token by the same store module that binds AGENT_STORE:
interface AgentPricingStore {
upsertModelPrice(input: ModelPriceInput): Promise<void>; // atomic supersede — retires the old
// current row, inserts a new one now
listCurrentPrices(): Promise<CurrentModelPrice[]>;
}Inject it and seed prices at boot, either one at a time with upsertModelPrice or in bulk with the
seedModelPrices(store, prices[]) helper:
import { Inject, Injectable, OnApplicationBootstrap } from '@nestjs/common';
import { AGENT_PRICING_STORE, seedModelPrices, type AgentPricingStore } from '@dudousxd/nestjs-agent';
@Injectable()
export class SeedAgentPricing implements OnApplicationBootstrap {
constructor(@Inject(AGENT_PRICING_STORE) private readonly pricing: AgentPricingStore) {}
async onApplicationBootstrap() {
await seedModelPrices(this.pricing, [
{ modelId: 'claude-sonnet-4-6', inputPricePer1m: 3, outputPricePer1m: 15, cacheWritePricePer1m: 3.75, cacheReadPricePer1m: 0.3 },
]);
}
}upsertModelPrice is the same atomic supersede described above, just exposed as a real write path
instead of a manual insert — it retires the model's current row and inserts the new one as current,
effective now. -store-mikro-orm and -store-drizzle both ship an implementation
(MikroOrmPricingStore / DrizzlePricingStore); -testing ships InMemoryPricingStore for tests.
The read-model
AgentGovernanceQueries is the read/analytics half of the store SPI (the write half is
AgentStore). The store adapters bind it to the AGENT_GOVERNANCE_QUERIES token, so the surfaces
just inject it:
| Method | Returns |
|---|---|
spendByModel(range) | requests / input+output tokens / cost, per model |
spendByActor(range) | requests / total tokens / cost, per actor |
usageTrend(range) | total tokens + cost bucketed by day |
recentToolCalls(limit) | latest tool calls with status + thread |
recentThreads(limit) | latest threads with message count + tokens |
runMetrics(range) | success/error rate, retries, p95 run duration |
runsByAgent(range) | run counts broken down by agent name |
runErrors(range) | failure counts broken down by error code |
runTrend(range) | run + failure counts bucketed by day |
recentRuns(limit) | latest runs with promptHash, duration, outcome |
pendingApprovals(limit) | oldest-first pending action-tool calls, joined to thread/actor |
toolStats(range) | per-tool calls / failed / rejected counts + p95 execution time |
toolCallsPage(query) / threadsPage(query) / runsPage(query) | the same tool-call/thread/run activity, paginated and filterable |
The run-reliability and tool-governance methods (runMetrics through toolStats, and runsPage)
are required members of the interface — an external adapter must implement them, but one whose
backing store never records runs (no recordRunStart, see Persistence)
is free to return zeros/empty rather than throw, and the dashboard renders an empty state instead of
erroring.
For unit tests and the offline demo, @dudousxd/nestjs-agent-testing ships an
InMemoryGovernanceQueries with an optional pricing map (default empty → zero cost, tokens still
counted) and in-memory implementations of every read above.
Run reliability
Every run's start and end are durably recorded (see Persistence),
so the read-model can answer "is the agent healthy" as a real signal instead of a diagnostics-tailing
guess: success/error rate, retry counts, run duration as a distribution (p50/p95), a run/failure trend
over time, a failure breakdown by error code, and a recent-runs table that carries each run's
promptHash — a stable fingerprint of the resolved system prompt (pre-RAG) that lets you correlate an
error-rate spike with a specific prompt change rather than guessing from timing alone.
Tool governance
toolStats(range) rolls the tool-call ledger up per tool: how many times it was called, how many
failed, how many were rejected at the approval gate, and the p95 execution time. It's the
per-tool complement to the per-model/per-actor cost views above — the number to reach for when one
tool is timing out or getting rejected disproportionately, not just costing the most.
The approvals inbox
Pending action tool calls (see Human-in-the-loop & Durability)
are a governance surface in their own right: pendingApprovals(limit) lists them oldest-first,
joined to their thread and actor, and both shipped surfaces render an Approvals section with
approve/reject affordances (and a nav badge for the pending count).
Deciding from the console — rather than from the chat UI watching the run — goes through the
AGENT_APPROVAL_PORT SPI, described in full in
Human-in-the-loop & Durability:
it routes through the same durable-signal / inline-resolution decision path as chat approvals,
without re-running chat's ownership check, and records who decided via executedByRef. When no
AGENT_APPROVAL_PORT is bound, the dashboard's approval endpoints answer 501 and the SPA renders
the inbox read-only rather than pretending decisions are being applied.
Paginated, filterable lists
toolCallsPage, threadsPage, and runsPage back the console's list views with real pagination
instead of a latest-N cap: a neutral GovernancePageQuery<TWhere> (page, limit, a typed where)
returns a GovernancePage with a real COUNT, offset paging, deterministic id tiebreaks, and
case-insensitive title search where relevant. The dashboard's HTTP surface speaks the same wire
grammar the rest of the ecosystem uses — page, limit, where[field]=value, with an unknown
field 400ing — so the SPA's tables get prev/next paging with per-table debounced filters. The
latest-N reads (recentToolCalls, recentThreads, recentRuns) remain for the Telescope bridge,
which doesn't need pagination.
Daily quota
Governance isn't only cost. A per-actor daily token quota blocks a turn before it runs when the
actor is over budget; the loop emits an aviary:agent:quota.exceeded diagnostics event and the
GET /agent/quota/today endpoint reports the actor's usage. Quota counts total tokens
(inputTokens + outputTokens), unaffected by the cache split.
Turn it on with quotaLimitTokens on AgentModule.forRoot({ quotaLimitTokens: 200_000 }) — that's
the whole setup. It's backed by the built-in LedgerQuotaStore, which reads the same usage ledger
this page describes, so it needs no extra store or shared state, even across replicas. Pass an
explicit quota: QuotaStore instead if you need a different backing store; it wins if both are set.
A run that goes over quota ends its stream with a structured run.failed error
(code: 'quota_exceeded') rather than a plain diagnostics event alone — see
Frontend for how that surfaces client-side.
Follow-up suggestions cost an extra call
AgentModule.forRoot({ followUps: true | { count } }) proposes short follow-up questions after the
final turn — but it does so with one additional model call, over and above the turn's own steps.
That call writes its own usage row (purpose: 'follow_ups'), so it shows up in spendByModel /
spendByActor like any other, and it counts against quota the same as a normal step. It's off by
default for exactly this reason — turn it on only where the extra spend is worth the UX. See
Configuration for the option.
Surfacing it
Both surfaces read the same AGENT_GOVERNANCE_QUERIES read-model and tail live tool-call / quota
signals off the aviary:agent:* diagnostics channel — pick either or both.
A bundled React SPA served by a NestJS module, mounted at its own route, no Telescope required:
import { AgentDashboardModule } from '@dudousxd/nestjs-agent-dashboard';
@Module({ imports: [AgentDashboardModule.forRoot({ basePath: '/ai-gateway' })] })
export class AppModule {}The in-process analog of a hosted AI-gateway dashboard — spend per model/actor, usage trend, and
live activity — at /ai-gateway.
Already running @dudousxd/nestjs-telescope? Register the extension on Telescope's extensions
array to get the same governance sections as an "Agent" tab (agent runs also show up in the
durable "Workflows" tab for free):
import { TelescopeModule } from '@dudousxd/nestjs-telescope';
import { agentTelescopeExtension } from '@dudousxd/nestjs-agent-telescope';
@Module({ imports: [TelescopeModule.forRoot({ extensions: [agentTelescopeExtension()] })] })
export class AppModule {}Any consumer can subscribe
The read-model and the diagnostics channel are public. Beyond the shipped surfaces, your app can
query AGENT_GOVERNANCE_QUERIES for its own billing export, or subscribe to aviary:agent:* for
alerts and metrics — the library instruments nothing in your code.
Related
- Persistence — the stores that bind
AGENT_STORE+AGENT_GOVERNANCE_QUERIES, and theagent_runreliability table - Identity & Authorization — the actor every cost row is attributed to
- Human-in-the-loop & Durability — the approvals
AGENT_APPROVAL_PORTandexecutedByRefbehind the inbox - Tools — every tool call is audited in the ledger
RAG
Ground the agent in your documents — agentic retrieval as a tool (default) or always-on prompt injection, with citations flowing through the tool-call mechanism.
Persistence
The AgentStore SPI — threads, messages, tool calls, and a token-usage ledger — behind one ORM-portable interface, wired on MikroORM or Drizzle.