Agora
Governance

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:

  • check runs before the first model call. If the actor is over budget for the day, the turn throws QuotaExceededError and no tokens are spent — the model is never called. This is the fail-closed, pre-model gate.
  • bump runs after each model turn, handing the store that turn's inputTokens + 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:

config/agent.ts
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 of input_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.
  • purposechat for a turn's model steps, embedding for inject-mode retrieval, summary for a history window's fold (the SPI also allows title / 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:

  1. 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 as cost_usd. The governance read-model uses this figure as-is.

  2. 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 the agent_model_pricing table (the current price rows are fetched once per run and folded into usage.costUsd):

    uncachedInput = inputTokens - cacheWriteTokens - cacheReadTokens
    
    cost = uncachedInput/1e6    * inputPricePer1m
         + cacheWriteTokens/1e6 * (cacheWritePricePer1m ?? inputPricePer1m)
         + cacheReadTokens/1e6  * (cacheReadPricePer1m  ?? inputPricePer1m)
         + outputTokens/1e6     * outputPricePer1m

    against 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 of outputTokens and are already billed at the output rate, so they never change the estimate.

    Seed prices once with seedPricesFromModelsDev(store, [...]), or by hand with seedModelPrices(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:

ColumnMeaning
model_idthe model the row prices
input_price_per_1m / output_price_per_1mUSD per 1M input / output tokens
cache_write_price_per_1m / cache_read_price_per_1moptional cache rates (write ≈ 1.25× input, read ≈ 0.1× input)
effective_fromepoch-ms the price took effect
is_currentwhether 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 publishesgpt-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, AgentGovernanceQueriesspendByModel, 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.

On this page