Quota & cost
Meter agent spend — a fail-closed daily token quota checked before the model runs, 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 a daily token quota checked before each turn and a usage ledger that records every turn's tokens and cost.
The daily quota
A QuotaStore is a two-method seam:
interface QuotaStore {
check(actorRef: string, day: string): Promise<QuotaState> // { usedTokens, limitTokens, withinLimit }
bump(actorRef: string, day: string, tokens: number): Promise<void>
}The loop uses it in two places:
checkruns before the first model call. If the actor is over budget for the day, the turn throwsQuotaExceededErrorand no tokens are spent — the model is never called. This is the fail-closed, pre-model gate.bumpruns after each model turn, handing the store that turn'sinputTokens + outputTokens.
The day (YYYY-MM-DD, UTC) is stamped once by the runner and threaded through the loop, so quota-by-day stays deterministic under replay.
`bump` is a notification, not necessarily an increment
The loop calls bump after every turn, but what the store does with it is the store's business — and
the recommended quotas.ledger implements it as a deliberate no-op:
async bump(): Promise<void> {
// No-op: the ledger is the source of truth — recordUsage already persisted this turn's tokens.
}That is not an oversight. store.recordUsage(...) has already written the turn's tokens to
agent_token_usage, which is what check reads; incrementing a second counter would double-count
every turn. So if you write your own QuotaStore over the persisted ledger, bump should stay empty
too. Only a store that keeps its own tally — quotas.memory, a bespoke Redis counter — should
actually add anything in bump.
Quota is opt-in, and off means open
Omit quota in the config and quotas are disabled — the loop skips the check and bump entirely (fail-open on budget). When you do configure a quota store, the budget gate is fail-closed. Wire one with the quotas factory to enforce spend limits:
import { defineConfig, quotas } from '@adonis-agora/agent'
export default defineConfig({
// ...
quota: quotas.ledger({ limitTokens: 1_000_000 }), // enforce off the persisted usage ledger
})quotas.ledger enforces the budget off the persisted token-usage ledger (one source of truth across replicas); quotas.memory({ limitTokens }) is a single-process budget for tests and small apps. Any custom QuotaStore (e.g. a bespoke Redis counter) satisfies the same two methods.
GET /agent/quota/today reports what the calling actor has spent today: the response is exactly
{ usedTokens }, summed from the usage ledger over the UTC day. It does not carry the limit or the
remainder — the route reads the store directly and never consults the configured QuotaStore, so it
answers the same way whether or not a budget is enforced. A UI that wants to show "820k of 1M used"
needs the limit from its own config.
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_usd— the provider's actual cost when a gateway reports it (see below), else null.
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 (total_cost), and persists it verbatim ascost_usd. The governance read-model uses this figure as-is. -
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.Seed prices once with
seedPricesFromModelsDev(store, [...]), or by hand withseedModelPrices(store, [...]).
`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.
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
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. 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.
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.