Cost and pricing
The platform already writes a usage row for every model call it makes. Give it a pricer and those rows carry the money too — so you read spend from the store the engine already fills, and never keep a second table in step with it.
import { pricing, setupAgentCore } from 'agentenkit';
const runtime = await setupAgentCore({
storage, queue, bus, kv, resolveModel,
pricer: pricing.table({
'gpt-4o': { inputPerMillion: 2.5, cacheReadPerMillion: 1.25, outputPerMillion: 10 },
}),
});rt, err := agentenkit.SetupAgentCore(ctx, agentenkit.RuntimeOptions{
Storage: storage, Queue: queue, Bus: bus, Kv: kv, ResolveModel: resolveModel,
Pricer: pricing.Table{
"gpt-4o": {InputPerMillion: 2.5, CacheReadPerMillion: 1.25, OutputPerMillion: 10},
},
})That is the whole setup. From here every read that reports tokens also reports money.
One row per model call
A usage row is one call to a model: a step of the main run, a step of a nested run, a compaction pass, streamed or not, finished or cut short. The row carries everything pricing needs, so a pricer never has to reach back into the run to find out what happened.
| Field | What it holds |
|---|---|
runId | The dispatched run this call is billed to. A nested run's calls carry their parent's id, so one run's whole bill is one query. |
agentId / agentName | Which stream made the call — empty agentId is the main run — and the name a bill line should say. |
kind | step, or compaction for the platform's own housekeeping call. |
step | The 1-based iteration inside its loop. 0 for a compaction. |
model / modelId | The registry key the call asked for, and the wire id it resolved to. |
inputTokens, cacheReadInputTokens, cacheWriteInputTokens, outputTokens, reasoningTokens | The counters. |
outcome | finished, aborted (a stop cut it mid-stream) or error. |
estimated | The tokens were estimated, because no finish ever arrived. |
providerMetadata | What the provider attached to the finish, plus responseId and responseHeaders. |
cost | { micros, currency, source }, filled by the pricer. Absent means unpriced. |
totalTokens is input + cache reads + output — the counter the token budget is measured against. Cache writes and reasoning tokens sit outside it on purpose: a cache write is a separate line on the provider's bill, and reasoning tokens are usually already inside the output count. Both are on the row so a pricer can charge them properly.
Money in micros
Cost is an integer number of micros: millionths of one currency unit. 1_000_000 micros is one dollar when the currency is USD. Money summed as a float drifts; money summed as an integer does not.
pricing.micros(0.25); // 250_000
pricing.amount(250_000) // 0.25
pricing.format(250_000) // '0.2500 USD'Price in one currency per deployment. Money is summed, never converted:
- One run is one currency. A call priced in a different currency from the run's earlier calls is stored unpriced, with an error in the log, so a run's bill always adds up.
- A total keeps each currency apart.
costMicrosandcurrencyare the first currency seen; calls in any other currency are left out ofcostMicrosand counted inunpriced, so that figure stays a floor in one unit.costshas one entry per currency, and each line says itscurrency. A thread only sees two currencies when the pricer changed between runs.
The three pricers that ship
table — a price list
The common case. Prices are per million tokens, in the currency you are working in, so a list can be typed straight off a provider's pricing page.
const prices = pricing.table({
'claude-sonnet-4': {
inputPerMillion: 3,
cacheReadPerMillion: 0.3,
cacheWritePerMillion: 3.75,
outputPerMillion: 15,
},
});A lookup tries the registry key, then the wire id from resolveModel, then the key with any @variant suffix removed — so claude-sonnet-4@high finds claude-sonnet-4.
A model the table does not know is stored unpriced, not priced at zero. A missing price shows up as a gap in the bill (unpriced above zero) rather than as free work.
A missing cache rate is priced as input. Leave out cacheReadPerMillion or cacheWritePerMillion (or set it to 0) and those tokens are priced at inputPerMillion, with a warning logged once per model. Cache tokens are never free, and a list that forgot them must not bill them at 0. Set the real rate to bill them right.
reasoningPerMillion defaults to zero deliberately. Most providers already count reasoning tokens inside the output count, so charging them again bills the same tokens twice. Set it only when your provider reports them separately.
receipt — the number the provider already computed
The most accurate pricer there is: no price list to keep in step with a provider's changes, and discounts and gateway markups are already inside the figure. Gateways usually return it in a response header, which the platform puts on the row under responseHeaders.
const fromGateway = pricing.receipt((meta) => {
const headers = meta.responseHeaders as Record<string, string> | undefined;
const cents = headers?.['x-gateway-cost-cents'];
return cents ? Math.round(Number(cents) * 10_000) : null; // null: no receipt here
});A receipt is only believed when it makes sense: a finite number, 0 or more. Anything else (a negative number, NaN) is treated as no receipt, and the next pricer in a chain gets its turn. A fraction of a micro is rounded.
fromGateway := pricing.Receipt(func(meta map[string]any) (int64, bool) {
headers, ok := meta["responseHeaders"].(map[string]string)
if !ok {
return 0, false // no receipt on this call
}
cents, err := strconv.ParseFloat(headers["x-gateway-cost-cents"], 64)
if err != nil {
return 0, false
}
return int64(cents * 10_000), true
})chain — the accurate one first, the fallback last
pricer: pricing.chain(
fromGateway, // the real figure, when the gateway sent one
pricing.table(prices), // otherwise the daily price list
);The first pricer that answers wins. A pricer that fails is skipped rather than believed, and if none answers the call is stored unpriced.
tools — searches and other tool services
The built-in web tools write a usage row for each search and page read (kind: 'tool', model: 'tool:web_search', modelId the adapter). The sandbox tools add the seconds their commands ran. When your own bill walks usage.lines, isToolUse(line.model) (Go: agentenkit.IsToolUse) tells such a line from a model call, so it is never looked up as a model. A tool's own model call, like reading a page with a question, carries the model's key and is a model call. pricing.tools prices them per use, per second or both, keyed by adapter; chain it with the model table, and tool spend counts against a run's money cap:
pricer: pricing.chain(pricing.table(prices), pricing.tools({ brave: { perUse: 0.005 }, e2b: { perSecond: 0.000028 } })),Pricer: pricing.Chain(prices, pricing.Tools{"brave": {PerUse: 0.005}}),Writing your own
A pricer is one method. In Go it is a ports.Pricer, or ports.PricerFunc around a plain function.
const perCall: Pricer = { price: (u) => ({ micros: 500, currency: 'USD', source: 'estimate' }) };It runs on the run's own path, so keep it fast and side-effect free. A price list lookup is the intended shape; a network call is not.
Reading spend back
One method, one filter. An empty filter reads the whole thread; a run id reads one dispatched run, nested runs included.
const thread = await storage.usage.total(threadId, {});
const run = await storage.usage.total(threadId, { runId });Both return the counters, the money, and lines — the same spend grouped by agent and model, which is the shape a bill wants:
{
inputTokens: 1_240, cachedInputTokens: 8_000, outputTokens: 310, totalTokens: 9_550,
costMicros: 12_500, currency: 'USD',
unpriced: 0, // calls with no cost: above zero, costMicros is a floor
costs: [{ currency: 'USD', costMicros: 12_500, calls: 5 }], // one per currency
lines: [
{ agentId: null, agentName: 'chat', model: 'gpt-4o', modelId: 'gpt-4o-2024-11-20', currency: 'USD',
inputTokens: 1_000, cacheReadInputTokens: 8_000, cacheWriteInputTokens: 0,
outputTokens: 250, reasoningTokens: 0,
calls: 4, estimated: 0, costMicros: 10_000 },
{ agentId: 'sub_1', agentName: 'researcher', /* … */ calls: 1, costMicros: 2_500 },
],
}unpriced is what tells "this thread spent nothing" apart from "nobody priced this thread". Do not read a zero cost as free work without checking it.
A hook that bills from RunFinishInfo.usage has one more thing to check: usageError (Go: UsageErr) is set when the platform could not read the run's rows back, and usage is then zeroed. Refuse to settle in that case (throw from onSettle, or return the error from OnSettle) rather than charging nothing; the run stays unsettled and is settled again later instead of going free.
The same totals reach you four other ways:
getThreadUsage(threadId)→usage.tokens, for a thread header.admin.getRun(runId)→detail.usage, for spend per run.admin.getThread(threadId)→detail.thread.tokens, for spend per thread. (A thread list carries tokens only, summed from the run records; its money would need a read per thread.)- a spec's
onSettleandonFinish→info.usage, for billing.
Billing at settle time
onSettle runs once per run, whatever way the run ends, with the whole run's bill already read back and grouped. See Settling a run for when it runs. A credit system charges straight off the lines:
const chat = runtime.createStreamTextAgent({
name: 'chat',
model: 'gpt-4o',
onSettle: async (info) => {
await credits.recordAndBill({
idempotencyKey: info.runId!, // at-least-once dispatch: be idempotent
entries: info.usage.lines.map((line) => ({
agent: line.agentName,
model: line.modelId,
inputTokens: line.inputTokens,
outputTokens: line.outputTokens,
cacheReadInputTokens: line.cacheReadInputTokens,
cacheWriteInputTokens: line.cacheWriteInputTokens,
costMicros: line.costMicros,
})),
});
},
});OnSettle: func(ctx context.Context, info agentenkit.RunFinishInfo) error {
if info.UsageErr != nil {
return info.UsageErr // settle later rather than charge nothing
}
entries := make([]port.AIUsageEntry, 0, len(info.Usage.Lines))
for _, line := range info.Usage.Lines {
cost := line.CostMicros
entries = append(entries, port.AIUsageEntry{
Agent: line.AgentName, Model: line.ModelID,
InputTokens: line.InputTokens, OutputTokens: line.OutputTokens,
CacheReadInputTokens: line.CacheReadInputTokens,
CacheWriteInputTokens: line.CacheWriteInputTokens,
CostMicros: &cost,
})
}
return credits.RecordAndBill(ctx, port.AIUsageRecord{IdempotencyKey: info.RunID, Entries: entries})
},A run that parked and resumed finishes once, and info.usage covers every segment, so a settle hook never has to add up parts itself.
A budget in money
Once every call is priced, a run can be capped by spend the same way it is capped by tokens:
await chat.run({ prompt: 'hi', costBudgetMicros: 250_000 }); // stop after about $0.25chat.Run(ctx, agentenkit.RunInput{Prompt: "hi", CostBudgetMicros: 250_000})Order: run input → agent spec → config.costBudgetMicros. It is checked between steps, so the step that crossed the line is kept in full and the next one never starts. The platform then publishes COST_BUDGET_EXHAUSTED (costMicros, costBudgetMicros, currency, agentId) and finalizes with stopReason: 'cost_budget' — the same shape as the token cap. A money break is not a stop: the run completes, and says why.
Both caps count the whole run. Each segment starts its count from the run's usage rows — the steps before a park, before a retry, and the platform's own compaction calls — and then keeps it in memory, shared with nested runs. So a run that parks and resumes, in this process or another, never gets a fresh budget, and a nested run's calls count against the same cap.
It needs a pricer. With none configured nothing is ever priced, so nothing is ever spent and the cap can never fire.
Calls that were cut off
A run stopped mid-stream was still billed by the provider for what it had already produced. That call is recorded rather than dropped:
outcomeisaborted(a user stop) orerror(the provider failed).- The input count is the previous call's on this run — the prompt was certainly sent — or an estimate of the prompt when nothing has finished yet.
- The output count is an estimate over the text that actually streamed.
estimatedistrue, and each line counts how many of its calls are guesses.
The estimate is the same one compaction measures context fill with, so the context budget and the cost of a cut-off step can never disagree.
What it means for storage
UsageStore keeps two methods, but total now takes a filter and the row is wider. If you have written your own Storage:
usage: {
record(threadId, usage, ctx): Promise<void>;
total(threadId, filter, ctx): Promise<UsageTotals>; // filter is new
}- Store the row as given. An implementation never prices anything itself.
totalmust honourfilter.runId, and must returnlinesgrouped by agent and model. If you cannot group in the database,sumUsage(rows)(TS) orports.UsageAggregator(Go) does it for you and returns exactly the right shape.- A NULL cost means unpriced. Count those into
unpriced; do not store a zero.
The shipped adapters take care of themselves. Their tables gain the new columns and a (runId, createdAt) index; SQLite and Postgres migrate an existing store in place on construction. Prisma users must migrate: the TokenUsage model in the example schema has the new columns, and prisma migrate dev plus prisma generate picks them up. cachedInputTokens keeps its name and its meaning — cache reads — so existing rows still say what they always said.
In the UI
getThreadUsage returns cost alongside tokens, so the React hook's usage object carries it with no change of its own:
import { formatCost } from 'use-agentenkit';
const { usage } = useAgentThread();
const cost = formatCost(usage?.tokens); // '$0.0125', '≥ $0.0125', or nullformatCost returns null when there is nothing to show, so a header leaves the slot empty rather than printing $0.00 for a server with no pricer configured. It renders ≥ when some calls went unpriced and the figure is a floor.