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_tokensis the input billed at full rate: what the model evaluated afresh. It never includes a cached token.cache_read_tokensandcache_write_tokensare the input served from a cache and the input written into one, beside the prompt count rather than inside it.completion_tokensis everything the model produced, thinking included;reasoning_tokensis 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 isfromthe first andtothe first of the next.after: thenextof 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_skillsrows 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_owneris alwaysoperatorandfallbackalwaysnone, 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.