Agora
Governance

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:

config/agent.ts
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 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.
  • purpose — chat 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 is 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_source says where cost_usd came 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:

  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's (providerMetadata.openrouter.usage.cost, from the official @openrouter/ai-sdk-provider), and persists it verbatim as cost_usd — on the usage row, the assistant message and the run. The quota's usd windows 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 / aiSdkModels add providerOptions.openrouter.usage = { include: true } to every OpenRouter call (an openrouter.usage of 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/openai with a custom baseURL reports 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).

  1. 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.

    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 with seedModelPrices(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_source is a nullable column that the boot schema heal adds to an existing agent_token_usage, with no app migration. Rows written before it existed keep cost_source NULL. 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_usd is 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, set quota: { limits, countEstimatedCost: false }. usageBetween returns { usedTokens, costUsd, estimatedCostUsd }, where costUsd includes 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:

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 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.

On this page

Partner

Your product as the official integration for Agent, right here.

Become a partner →