Skip to content

Usage

What was spent, by whom, for which customer, on which model. Built.

The ledger

usage_ledger holds one row per model call. Not per turn: a turn that fell back to a second provider mid-way has two rows naming two endpoints, and a turn that failed after three calls has three rows, because those calls cost money whether or not the turn finished.

Every dimension a bill might be cut along is on the row:

Column Says
workspace_id Whose ledger. The partition key.
agent_id, session_id, user_id Which agent, conversation and person
account The workspace's own label for whose conversation this was
reply_id, job_id, round Which reply, which job, which call within the turn
traffic_type What class of work it was
endpoint, model Who answered, and with what. The model that actually served, which routing may have chosen
credential_owner Whose key paid: operator or workspace
fallback none, same_model or cross_model
five token columns As the provider reported them, normalized
provider_usage The provider's usage object verbatim, for dimensions the columns do not model
service_tier The price tier that served it, where a provider has them

Tokens, never prices. Rate cards change and disputes happen, and a ledger that stored a computed cost would have to be corrected where one that stores tokens is re-priced by whoever is billing.

What the token columns mean

One definition, written once on the Usage type in crates/core/src/llm.rs, and every route arrives at it:

  • prompt_tokens is the input billed at full rate: what the model evaluated afresh. It never includes a cached token.
  • cache_read_tokens and cache_write_tokens are the input served from a cache and the input written into one, beside the prompt count rather than inside it.
  • completion_tokens is everything the model produced, thinking included; reasoning_tokens is the thinking part of that, reported apart.

The providers do not agree on this. The OpenAI protocol and Gemini count cached tokens inside the prompt total and leave the breakdown to explain; Anthropic reports reads and writes apart from the input count. The gateway settles that where it speaks each protocol -- the OpenAI provider takes the cache out of the prompt count, the Anthropic and Gemini translations produce the canonical numbers directly -- and the runtime takes what the gateway sends without arithmetic of its own. For a while it did not: the runtime applied the OpenAI convention to every route, subtracting the cache from a count the gateway had already subtracted it from, so an Anthropic call with 80 fresh and 20 cached tokens was recorded as 60 and a Gemini call with 800 of 1,000 cached was recorded as 0. Two places each knowing a convention is how a number ends up meaning different things on different routes; the convention lives in one.

The five normalized columns are what every provider agrees on and every rate card needs. They are not a superset and never will be: cache writes priced by TTL, service tiers, long-context thresholds, server-side tools billed per call, audio and image tokens. So the provider's usage object is kept whole beside them. The columns build today's bill; the raw object lets yesterday's calls be re-priced under a dimension nobody thought to normalize, without a backfill. For the Anthropic protocol the gateway's translation is lossy by design, so the original rides through under an anthropic key.

Append-only. Nothing updates or deletes a row, so an export of a closed window returns the same rows every time it is run.

The account label

A session may carry an account: free text the workspace sets when opening the conversation, meaning whatever the workspace's business means by it -- a shipper, a customer number, a matter. The platform never interprets it. The ledger copies it onto every row the session produces -- the agent's own rounds, and the naming and compaction done on that session's behalf -- so a workspace can join its bill to its own records without the platform knowing what those records are. Those last two wrote a null for a while: spend a workspace could see on its bill and could not put against any of its customers.

The platform workspace

Work the platform does on its own initiative -- titles, summaries, anything that comes later -- bills to a reserved workspace, platform, with a fixed id and a trigger that refuses its deletion. A reserved row rather than a null keeps partitioning uniform and foreign keys real, and makes "what did the platform itself spend" the same export as everyone else's.

The export

GET /v1/usage, needing usage:read. Parameters:

  • from, to: RFC 3339, inclusive start, exclusive end. A closed month is from the first and to the first of the next.
  • after: the next of the previous page. Ids are UUIDv7, so id order is time order and the id doubles as the cursor.
  • limit: up to 5000, default 500.
  • workspace_id: system administrators only. Everyone else gets their own.

The response is { entries: [...], next: <id> | null }. A null next means the window is exhausted.

The export is the product. An operator runs it across workspaces and bills them. A workspace runs it on its own ledger, joins account to its records, applies its own rates, and bills its customers. Nobody needs a billing system inside outturn, which is good, because every customer's is different.

How rows get written

The runtime host emits a usage event on the turn's stream the moment each model call returns, carrying the endpoint and model the gateway named, whose credential paid (a header the gateway sets), and the provider's token counts. The API writes a ledger row as each arrives, before the turn ends. A failed ledger write is logged at error, because a wrong bill is an operator's problem and a quiet one is worse than a loud one.

usage_source says how much to trust the row. reported is the provider's own figure for a call that ran to its end. A call cut short -- a stop button, a hold, an upstream that hung up -- is reported_partial where the provider reports as it goes, which Anthropic and Gemini do, so the numbers are real but for part of an answer. The OpenAI protocol says what a call cost only on its last chunk, which a cut stream never delivers: the gateway asks for usage on every stream (stream_options.include_usage), and it still cannot arrive before the end. Such a call used to write no row at all, and a stop pressed mid-answer billed nothing. Now the runtime estimates, at four characters a token, from what it sent and what came back, and the row is estimated: tokens the provider will bill for, counted by us rather than by them, and never mistaken for a measurement. unknown is a call that finished and reported nothing, whose zeros mean "not measured" rather than "free".

The summary

GET /v1/usage/summary, needing usage:read. What the dashboard reads.

The export stays the product -- this is a reading of it, computed where the rows are. A browser folding the ledger itself would page every row of a window down the wire to show one number, which is tolerable on a dev machine and wrong on a real deployment.

Parameters are from and to as the export has them, defaulting to the last 30 days ending at the next midnight, so today is a whole bucket rather than a partial one that reads as a collapse in traffic. A window is always closed, and bounded at 370 days: the statement behind it scans every partition, and an unbounded one is a way to ask the database for everything by accident.

Scope works the way the export's does, with one addition. workspace_id names a workspace, and only a system administrator may name one that is not their own. scope=all sums every workspace, and only a system administrator may ask -- an absent scope means the caller's own workspace, so a page cannot widen itself by forgetting a parameter.

The response carries the window's totals, a bucket per day, and the window cut by workspace, model, agent, account and usage_source. Days with no rows are filled in with zeros rather than omitted: a chart drawn from a series that skips its empty days draws a quiet day as no day at all, which reads as a shorter window rather than an idle one. Each dimension is ordered by tokens and capped, with the tail folded into a single "others" entry rather than dropped, so nothing is lost between a dimension's slices.

They do not all sum to the window's total, though, and the page does not pretend they do. A dimension whose column is nullable -- account most obviously -- has rows in no named slice, which arrive as a slice with a null key rather than as an invented label. Each panel's shares are therefore against its own dimension, not against the headline figure.

Tokens throughout, never prices, for the reason the ledger gives. by_source is what keeps that honest on a page: a total that mixes reported and unmeasured rows without saying so presents an estimate as a fact, so the dashboard states what share of a window nobody measured rather than folding it in silently.

The dashboard

The overview page reads the summary and nothing else, so everything on it is about model calls -- it says nothing about turns that never reached a model, which is worth remembering before it grows panels that look like they cover everything.

The platform's own work -- naming a session, compacting a transcript -- bills to the session's agent, because a session cannot exist without one and the work was done for that agent's conversation. It is the platform's initiative rather than the agent's, which is what traffic_type records; it is not nobody's spend. Both wrote a null for a while, and the result was tokens an operator could see on a page that cuts spend by agent and nobody could account for.

Two things on it are design rather than decoration. Cache reads are kept out of the daily stack: on a transcript-heavy workload they run an order of magnitude above the rest, and stacking them leaves completion tokens a few pixels tall. The separation is real rather than cosmetic -- a cache read is context being re-read, priced differently by every provider, not new work the way a prompt or a completion is -- and the alternatives are worse, a log axis making a tenfold difference look small and a second y-axis never being the answer. They get a strip of their own beneath the stack instead, with its own 0-100% axis, showing the share of each day's input served from cache. It was once a dashed line laid over the stack against its own maximum, and its zero sat about where the stack topped out, so it read as one more band measured against an axis that was not its own; a legend saying so could not undo what its position said. A rate rather than a count also answers what a reader wants from it, which is whether caching is working. Input is prompt, cache read and cache write together, because the gateway counts cache reads outside the prompt figure. The two panels share one x-axis and one hover, and the line breaks over a day with no input rather than dropping to 0%: a day nobody called a model did not miss the cache. The "Cache hit rate" tile above, which replaced a count of cache reads, is the same figure over the whole window -- the count's share of all tokens set context re-read against completions written, and answered nothing.

The other is that every chart has a table behind it carrying the same figures, and that the series colors are checked rather than chosen: they come from the theme's own ramps, stepped until both modes cleared a validator for the lightness band, the chroma floor, adjacent-pair separation under protanopia and deuteranopia, and contrast against the surface. ui/src/lib/viz.ts holds them, and the fixed slot order is the safety mechanism -- anything past the last slot takes a neutral rather than a hue nobody checked.

The Work panel cuts tokens by traffic class, and names the platform's own classes for the work they are rather than by the word the ledger stores: assistant is people talking to agents, background is schedules and webhooks (see routing.md), session-name is naming conversations and compaction is summarizing long ones. A class an operator added keeps the name they gave it, which they chose and will recognize.

Beside "Right now", a panel headed "Running on their own" lists the workspace's schedules and webhooks, each linking to its agent's settings page. Those pages are the right place to edit one and the wrong place to notice that one stopped working: a schedule failing every morning is a broken integration nobody sees until somebody opens that particular agent. So whatever is not working comes first -- a schedule with a problem or a failed last run, a webhook whose last request failed or was refused -- then what is on, the soonest schedule leading and webhooks after it, since a webhook has no next run to rank by, then what is off. It needs agents:read, and like the skills panel it is one workspace's: an operator viewing every workspace gets no panel rather than one that quietly narrowed to their own.

The skills panel

Below the usage panels the dashboard shows what the workspace's skills are doing, from GET /v1/skills/stats. It is kept apart from them on purpose: the usage panels read the ledger and are about spend, and this one reads the transcript and is about what was carried. Nothing attributes tokens to a skill, so no figure here is a cost, and none pretends to be.

The window is the one the page's picker chose -- the same closed window the summary takes, defaulting to the 30 days ending at the next UTC midnight and refused past 370. It is one workspace only, needs SkillsRead rather than UsageRead, and has no platform-wide cut: a list of every workspace's skill names is their content, not a statistic about it. An operator viewing every workspace simply gets no panel. Lists are capped at 20 rows.

Use comes from turn_skills, the row recording which version of which skill each reply was given. Its time is the reply's created_at in agent_messages. Edits come from skill_versions, where every save is a version.

  • Skills counts the workspace's skills that are not retired, and beneath it how many of those any agent carries.
  • Times used counts turn_skills rows in the window: one per skill per reply, so a reply carrying three skills counts three times.
  • Edits counts versions written in the window, with skills first created and skills retired in it beneath.
  • Unused is skills an agent carries that served no reply in the window. Each is sent to the model on every round anyway, so it is a standing cost with nothing to show. Its "last used" reaches outside the window: "never" means never, not merely not lately.
  • Most used ranks skills by replies served, with the distinct conversations among them -- one busy session and twenty light ones are different facts -- and how many times the skill was edited in the window. The bars are drawn against the busiest skill rather than the total, since several skills share a reply.
  • Using an older version lists skills that served replies on something older than their newest version. Whether an agent pins the skill is what tells the two cases apart: a pin is somebody's decision, and its absence is an edit nobody picked up.
  • Who edited them groups the window's versions by author, with how many versions and how many distinct skills each wrote.

Authors are named by the rule every actor is, written once in crates/api/src/actor.rs and only repeated by ui/src/lib/actors.ts. Anybody is named by their display name, except the operator's staff, who appear as one row called the operator with no id -- a platform admin is named to no customer, even in a workspace they belong to. A version nobody signed, from an install or a seed, is kept rather than dropped, so the rows still add up to Edits, and is shown as set up automatically.

Not yet

  • credential_owner is always operator and fallback always none, because routing does not yet support workspace-held keys or record fallback kind. The columns exist so the bill does not have to be re-derived when it does; see routing.md.
  • Nothing spends on the platform workspace's behalf yet. The row exists for when something does.
  • Retention. The ledger grows with every call and is never pruned. A billing obligation decides how long rows must be kept, and that is a policy the operator sets, not a default the platform should guess.