Quota & cost
Meter agent spend — budget windows per day and month (tokens and spend) checked before a turn starts, a per-turn usage ledger with real or estimated cost, and the model pricing table.
Once an agent can spend tokens on a user's behalf, you want a budget and a bill. @adonis-agora/agent gives you budget windows checked before each turn and a usage ledger that records every turn's tokens and cost.
Budgets
One option, quota, sets ceilings per UTC day and per calendar month, in tokens and/or recorded spend:
export default defineConfig({
// …
quota: { limits: { day: { tokens: 200_000 }, month: { usd: 20 } } },
})GET /agent/quota reports every window — { windows: [{ period, usedTokens?, limitTokens?, usedUsd, limitUsd?, resetsAt, warnAt? }], blocked?: { period, reason }, warning?: { period, ratio, reason? } } — and while one is exhausted POST /agent/chat answers 429 { code: 'quota_exceeded', period, message } before the turn starts, so no tokens are spent. That is the shape @adonis-agora/agent/react's useQuota renders, and GET /agent/config says quota.enforced: true.
Without quota, GET /agent/quota still reports the day and month windows off the ledger — no ceilings, never blocked. For a public (anonymous) deployment that means unbounded model spend, so set limits there; they apply per actor, which is per browser in anonymous mode.
Soft limit. quota: { limits, warnAt: 0.8 } stamps warnAt on every ledger window that has a ceiling; past that share of it the report carries warning: { period, ratio } — the fullest such window, never while one is blocked — so a client can say "80% of today's budget used" before the send that gets refused. quotaWarning(windows) derives the same value from any report.
A budget of your own — an AI-gateway spend cap, a plan's allowance — is a QuotaProvider (report({ actor, now? })) passed as quota; its blocked gates sends the same way. A USD-only budget leaves usedTokens (and limitTokens) out of its windows rather than sending 0; exhaustedWindow reads a missing usedTokens as 0.
Spend is what the usage rows recorded: the provider-reported cost of a gateway, plus the loop's estimate for a provider that reports none (Bedrock, or OpenAI and Anthropic called directly, priced off the pricing table). So a usd ceiling binds on those too. Estimates are counted by default because otherwise a USD ceiling on a provider that reports no cost would read $0 and never block: the budget would exist but never enforce. To budget on provider-reported cost only, set quota: { limits, countEstimatedCost: false }. The month window needs a store with usageBetween (Lucid and in-memory have it). See Estimated cost is persisted.
Migrating from quotas.ledger / quotas.memory
The quotas.* factories, LedgerQuotaStore and GET /agent/quota/today are gone (0.46). quota: quotas.ledger({ limitTokens: N }) becomes quota: { limits: { day: { tokens: N } } }. The check moved from inside the turn to the send: a request over budget is refused with 429 instead of a run that fails with quota_exceeded. Drain in-flight durable runs started under the old option before upgrading — their journals hold the loop's quota checkpoints.
The usage ledger
Every model turn is recorded to the agent_token_usage table via store.recordUsage(...), independently of whether a quota is enforced. Each row carries:
input_tokens/output_tokens— the turn's token counts.cache_write_tokens/cache_read_tokens— prompt-cache breakdown when the provider reports it. These are subsets ofinput_tokens, never added on top — so token totals and quota don't change when a breakdown is present.model_id— the model actually used. A provider-reported model id wins over the configured fallback, so cost can't misattribute to the wrong model.purpose—chatfor a turn's model steps,embeddingfor inject-mode retrieval,summaryfor a history window's fold (the SPI also allowstitle/follow_ups).cost_usdis the turn's cost: the provider's actual cost when a gateway reports it, else the estimate from the pricing table, else null (see below).cost_sourcesays wherecost_usdcame from:'provider'or'estimate'. It is NULL on an unpriced row, and on rows from before the column existed, which only ever held a reported cost.
Reasoning tokens, when reported, are a subset of output_tokens — observability only, billed at the output rate.
Cost — real when known, estimated otherwise
There are two ways a turn gets a dollar figure:
-
The provider reports it. A gateway knows the real spend. The AI SDK adapter reads the Vercel AI Gateway shape (
providerMetadata.gateway.cost) and OpenRouter's (providerMetadata.openrouter.usage.cost, from the official@openrouter/ai-sdk-provider), and persists it verbatim ascost_usd— on the usage row, the assistant message and the run. The quota'susdwindows and the governance read-model use this figure as-is. For OpenRouter it is what the account was actually charged for the provider the call was routed to, which can differ several-fold between identical calls; no list price gets that right.OpenRouter only reports cost when usage accounting is requested, so
aiSdkModel/aiSdkModelsaddproviderOptions.openrouter.usage = { include: true }to every OpenRouter call (anopenrouter.usageof your own wins). Nothing to configure:config/agent.ts import { createOpenRouter } from '@openrouter/ai-sdk-provider' const openrouter = createOpenRouter({ apiKey: env.get('OPENROUTER_API_KEY') }) model: () => aiSdkModel(openrouter.chat('deepseek/deepseek-v4.1-flash')),OpenRouter reached through
@ai-sdk/openaiwith a custombaseURLreports no cost — that SDK drops the field. If an OpenRouter model returns no cost the adapter warns once per model, and the turn falls back to the pricing table.
Before 0.68: OpenRouter cost was never read
Up to 0.67 the adapter read total_cost, a key the OpenRouter provider does not emit, so every OpenRouter turn recorded cost_usd = NULL even with usage accounting on. Rows recorded then stay NULL in the ledger; the read-model still estimates them from the pricing table, which boot now seeds (below).
-
Estimated from the pricing table. A direct provider (Anthropic / OpenAI / Bedrock) reports only tokens. When you configure a
pricingStore(pricingStores.lucid()), cost is estimated from theagent_model_pricingtable (the current price rows are fetched once per run and folded intousage.costUsd):uncachedInput = inputTokens - cacheWriteTokens - cacheReadTokens cost = uncachedInput/1e6 * inputPricePer1m + cacheWriteTokens/1e6 * (cacheWritePricePer1m ?? inputPricePer1m) + cacheReadTokens/1e6 * (cacheReadPricePer1m ?? inputPricePer1m) + outputTokens/1e6 * outputPricePer1magainst the current pricing row for the model. Cache tokens are subsets of
inputTokens, so they are subtracted out before the input rate applies and then billed at their own rates — which is the whole reason the pricing table carries those two extra columns. A cache rate left unset falls back to the input rate, so a partially-filled row degrades to the naive formula rather than pricing cached tokens at zero. Reasoning tokens are a subset ofoutputTokensand are already billed at the output rate, so they never change the estimate.At boot, a configured model with no price row gets the models.dev list price written as its row (see Prices from models.dev); seed your own with
seedPricesFromModelsDev(store, [...]), or by hand withseedModelPrices(store, [...]). A store price is an estimate: it is what a direct provider's tokens cost at list price, and a provider-reported cost always replaces it.
When neither exists — no gateway cost and no price row — cost_usd stays null. The provider says
so at boot, in one line naming the model:
[@adonis-agora/agent] No cost will be recorded for gpt-4o-mini: the provider reports no cost and the pricing table has no row for it. …`null` in the ledger, `0` in the rollups
An unpriced turn — no pricingStore bound, or no price row for that model — leaves cost_usd
null in the ledger. That is deliberate: a genuinely free turn and "we have no idea what this
cost" are different facts, and null keeps them apart.
The rollups cannot preserve that distinction, because they are sums. spendByModel,
spendByActor and usageTrend all declare a non-nullable costUsd, and an unpriced row contributes
0 to it — so a model you never priced shows real token counts next to $0.00 of spend, which reads
exactly like a free model. If a rollup total looks implausibly low, check the pricing table before you
believe it. The per-run and per-usage rows (GET /agent/governance/runs, a run's usage ledger) keep
their nulls and are where you can tell the two apart.
Estimated cost is persisted, and the quota counts it
A provider that reports no cost, such as Bedrock or OpenAI and Anthropic called directly, still gets
a cost for every turn: its tokens times the model's price row. That estimate is written to the usage
ledger: agent_token_usage.cost_usd, with cost_source = 'estimate'. Before this, the estimate
reached only the message and the run, so the ledger held NULL and the quota's USD windows
read $0. A cost the provider reports (OpenRouter, the Vercel AI Gateway) is written as before, with
cost_source = 'provider'. That reported figure always wins over an estimate. A turn with neither
keeps cost_usd NULL and cost_source NULL.
- Schema.
cost_sourceis a nullable column that the boot schema heal adds to an existingagent_token_usage, with no app migration. Rows written before it existed keepcost_sourceNULL. They only ever held a reported cost. - Frozen at write time. An estimate uses the price row in effect for that turn. Changing a price
later does not rewrite history. A row with NULL
cost_usdis still estimated at read time by the governance read-model, as before. - Quota. The USD windows (
limits.day.usd,limits.month.usd) sum every priced row, estimates included. That is the default because otherwise a USD ceiling on a provider that reports no cost would read $0 and never block: the budget would exist but never enforce. To budget on provider-reported (gateway) cost only, setquota: { limits, countEstimatedCost: false }.usageBetweenreturns{ usedTokens, costUsd, estimatedCostUsd }, wherecostUsdincludes the estimated share. - History-summary and embedding usage rows are written before the run reads its price list, so they stay unpriced in the ledger. The governance read-model still estimates them.
The pricing table
agent_model_pricing is one row per model per effective period:
| Column | Meaning |
|---|---|
model_id | the model the row prices |
input_price_per_1m / output_price_per_1m | USD per 1M input / output tokens |
cache_write_price_per_1m / cache_read_price_per_1m | optional cache rates (write ≈ 1.25× input, read ≈ 0.1× input) |
effective_from | epoch-ms the price took effect |
is_current | whether this is the active row for the model |
Seed it with the current prices for the models you use; leave it empty and you still get token accounting, just no cost estimate.
Which id to price
Price the model under the id the provider publishes — gpt-4o-mini, claude-sonnet-5 — which is
also the id you asked for. You do not need to know, or track, the dated snapshot the provider answers
with.
That distinction is real and it used to cost you the whole dashboard. The ledger records what the
provider reports (finalStep.response.modelId), and OpenAI answers a request for gpt-4o-mini
with gpt-4o-mini-2024-07-18. Matched by raw string equality, the alias you priced and the snapshot
in the ledger never met: every rollup rendered $0.00 next to a real token count. The fold now
resolves the reported id down to its alias — exact id first, then the route prefix dropped, then a
trailing date suffix (-2024-07-18, -20241022) stripped.
Only date-shaped suffixes are stripped. A trailing -002 or -v2 can be a genuinely different
model with a genuinely different price, so those stay unpriced and stay visible — mispricing in
silence is worse than not pricing. And the exact id always wins, so pricing one snapshot differently
from its alias still works.
Prices from models.dev
Automatically, at boot. Once the tables exist, the provider asks the model provider which
models it runs (aiSdkModel / aiSdkModels describe theirs) and, for each one the pricing table
does not price, writes the current models.dev list price — looked up as <provider>/<model> (the AI
SDK provider: openrouter.chat → openrouter, openai.chat → openai), and for an
OpenRouter-style id (deepseek/deepseek-v4.1-flash) also as OpenRouter's list price. It runs in the
background, never blocks the boot, never overwrites a row that exists (so your own prices win), and
logs one line saying what it seeded. It is skipped under NODE_ENV=test and for ace commands.
priceCatalog in config/agent.ts adds your own prices (prices, see below), points it at a mirror
({ url, fetch }) or turns the fetch off (modelsDev: false, or false); the unpriced-model warning
runs either way and names why each model is unpriced.
By hand. seedPricesFromModelsDev fills the table from the open models.dev catalog
instead of numbers hand-copied off a rate card:
import { pricingStores, seedPricesFromModelsDev } from '@adonis-agora/agent'
const store = await pricingStores.lucid({ connection: 'primary' })({ app })
await seedPricesFromModelsDev(store, ['openai/gpt-4o-mini', 'anthropic/claude-sonnet-5'])The <provider>/<model> prefix is required: the same model name exists under several providers at
different prices, and guessing there would be guessing at a bill. A model missing from the catalog,
or listed without a price, throws — a silently skipped row would come back later as $0.00 in a
panel, far from its cause. Nothing is written unless every requested model resolved.
Run it from a command or a deploy step, not on a request path — boot seeding is the same: it runs once at start, never while a turn is being charged. Prices change monthly, not per second, and fetching one at charge time would trade a table you control for a third party in the middle of your accounting.
Your own prices, and AWS Bedrock (GovCloud, China)
Bedrock reports no cost, so a Bedrock turn is priced off the row for the exact model id the turn
reports — often an inference-profile ARN. At boot each Bedrock id is normalized: an ARN
(arn:aws[-us-gov|-cn]:bedrock:<region>:<account>:(inference-profile|foundation-model|application-inference-profile)/<id>)
or a geo-prefixed profile (us., eu., apac., us-gov., global., …) is looked up as the full
id, then the profile id, then the base foundation-model id, and the row is written under the id the
model is configured with (an ARN stays an ARN). An ARN is never looked up as an OpenRouter id.
models.dev lists commercial Bedrock prices. For a model in GovCloud (aws-us-gov) or
China (aws-cn) they are never seeded. The partition comes from the id (the ARN, or a us-gov.
prefix), else priceCatalog.region, else AWS_REGION / AWS_DEFAULT_REGION. GovCloud models get
a built-in table instead (BEDROCK_BUILTIN_PRICES, from the AWS Price List for us-gov-west-1:
the Anthropic models, Amazon Nova Micro/Lite/Pro and Titan Text Embeddings V2, Meta Llama 3 8B/70B,
OpenAI gpt-oss-20b/120b, NVIDIA Nemotron, and xAI Grok 4.6 — with cache-read and cache-write prices
where AWS publishes them). Any other model in those partitions is left unpriced, and the boot warning names
the partition and the fix.
priceCatalog.prices is your own rate card. Each entry seeds a row for its model, and for any
configured model it matches by normalized id, when that model has no row yet (an existing row is
never overwritten). Entries are checked before the built-in table and models.dev, so they win over
both. An entry looks like { model, input, output, cacheRead?, cacheWrite?, currency?: 'USD', unit?: '1M tokens' | '1K tokens' }. Only USD is accepted, because the ledger records cost_usd.
A GovCloud app on Claude Sonnet 4.5 (us-gov-west-1 list price, USD per 1M tokens):
priceCatalog: {
region: 'us-gov-west-1',
prices: [
{
// Also prices the ARN a turn reports:
// arn:aws-us-gov:bedrock:us-gov-west-1:<account>:inference-profile/us-gov.anthropic.claude-sonnet-4-5-20250929-v1:0
model: 'us-gov.anthropic.claude-sonnet-4-5-20250929-v1:0',
input: 3.6,
output: 18,
cacheRead: 0.36,
cacheWrite: 4.5,
},
],
},Set modelsDev: false to keep the app prices and never fetch models.dev.
A row seeded before this existed (an earlier release priced a bare base id at the commercial price)
is not replaced. Correct it with upsertModelPrice if you run in GovCloud.
Embeddings (RAG)
Retrieval in inject mode embeds the user's question on every turn, and that embedding is spend
like any other. It used to be invisible: EmbeddingProvider.embed returned vectors and nothing
else, so nothing from RAG reached the ledger — or the quota, which sums the ledger without
filtering by purpose. An agent with retrieval on burned tokens on every question while the panel
swore it did not, which is worse than not measuring: it is measuring low, on the very number that
decides when to cut someone off.
The loop now records a purpose: 'embedding' row for the query embedding, so it shows up in the
dashboard and counts against the daily cap.
Your daily quota now includes RAG
If your agent uses inject-mode retrieval, actors will reach the cap sooner than before — not because anything got more expensive, but because the embedding spend was always there and is now counted. If that pushes real users over, raise the limit deliberately rather than treating the older, lower number as the true one.
This needs a provider that can report token counts. The SPI keeps embed as-is and adds an
optional embedWithUsage, so a provider written before this capability keeps working and
simply produces no embedding rows — the runtime never fabricates a count it was not given.
export interface EmbeddingProvider {
embed(texts: string[]): Promise<number[][]>
embedWithUsage?(texts: string[]): Promise<EmbeddingResult> // { vectors, usage? }
}The same shape exists one level up: Retriever gains an optional retrieveWithUsage, which the
loop prefers when present. A third-party retriever that only implements retrieve is unaffected.
Ingestion is different. Bulk indexing does not happen inside a conversation, and
agent_token_usage.thread_id is NOT NULL with a foreign key to threads — so an ingestion row has
nowhere to go without loosening that constraint, which is a schema decision and not something an
ingestion callback should make. ingestChunks therefore exposes the batch's usage through an
onUsage callback for the host to account for as it sees fit:
await ingestChunks(chunks, {
embedder,
store,
onUsage: (usage) => metrics.increment('rag.ingest.tokens', usage.inputTokens),
})Observable rather than invisible, which is the honest half-step. Retrieval — the recurring cost — is fully accounted.
The governance read-model
Cost/usage aggregation lives behind one interface, AgentGovernanceQueries — spendByModel, spendByActor, usageTrend, run lifecycle, tool stats, reliability, and the approvals inbox — so a dashboard and a Telescope tab can consume the same rolled-up numbers. Wire governanceQueries.lucid() and a governanceAuthorize gate to mount the /agent/governance/* routes (priced against the same pricingStore) — without the gate they are not mounted at all — and consume them from the dashboard SPA or the Telescope tab. The in-memory implementation ships in the testing kit. See Governance read-model.
Authz (Bouncer) adapter
Swap the ADMIN-only role gate for ability-based authorization — the @adonis-agora/agent/authz adapter checks each tool's declared ability through @adonis-agora/authz (Bouncer), tenant-scoped and fail-closed.
Governance read-model
The read/analytics side of the agent — spend, usage trend, run lifecycle, tool stats, reliability, and a cross-thread approvals inbox over the persisted tables, exposed as /agent/governance/* routes.