Token Usage
A four-state LLM spend card: stacked bars split input, cached input, output and reasoning tokens per period, with the cache-hit share called out and a cost line derived in-component from per-million rates.
Loading previewโฆ
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-token-usage.jsonPrompt
Build a React + TypeScript + Tailwind "ChartTokenUsage" LLM-spend card in plain
SVG with zod. Not recharts, and not a generic stacked bar: the second axis is
not a supplied series โ it is tokens multiplied by per-million list prices,
computed in the component โ and the four stacked classes are a closed taxonomy
whose whole point is that they are priced differently.
Contract
- One zod schema is the source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
periods: { label: string; prompt: number; cachedPrompt: number;
completion: number; reasoning?: number }[];
rates: { prompt: number; cachedPrompt: number; completion: number;
reasoning?: number };
currency?: string }.
- The four classes are fixed and ordered: prompt, cachedPrompt, completion,
reasoning โ bottom to top in the stack, palette slot 1..4. Export the tuple
and a label map so the legend, the readouts and the table all read one source.
- prompt and cachedPrompt are DISJOINT halves of one input stream: cachedPrompt
is the part a prefix cache served, so the cache-hit share is
cachedPrompt / (prompt + cachedPrompt). Say so in the JSDoc โ a feed that
sends the input total in prompt AND the hits in cachedPrompt double-counts
every figure on the card.
- Counts are per period, never running totals. Rates are currency per 1,000,000
tokens, the unit every vendor publishes.
- reasoning is optional per period; the class exists if ANY period supplied the
field, decided by the schema and not by the values (a week that spent zero
reasoning tokens still has a reasoning budget, and dropping the class would
redraw the legend between refreshes). rates.reasoning falls back to
rates.completion โ every frontier vendor bills thinking tokens as output.
- currency is an ISO 4217 code, restricted by regex so an unvalidated feed
cannot make Intl.NumberFormat throw; default USD, and re-check it in the
component anyway.
- refine: a ready card needs at least one period; period labels must be unique
(a label is the bar's tick and its table row header).
- Props = z.infer plus height (default 190, clamped 140..420), onRetry,
className and the div's native props (Omit "title"); forwardRef to the card.
- Export the maths beside the component so a test can print the numbers the
picture is made of: buildTokenUsageModel(), apportionPercent(),
tokenAxisStep().
Behavior
- DERIVE THE MONEY, NEVER ACCEPT IT. Cost per class per period is
tokens * rate / 1e6, summed up. A cost series handed in alongside the tokens
can silently disagree with them, and then the card is drawing two months.
- LEGEND IS THE FILTER IS THE LEDGER. One chip per class, a real button with
aria-pressed: swatch, name, total tokens, its share of what is currently
shown, its cost and its per-million rate. Pressing it removes the class from
the stack, from the cost line and from both totals. Refuse to switch the last
one off and say why in a role="status" line rather than no-opping silently.
A switched-off chip is toggled, NOT disabled, so it stays fully operable and
keeps AA contrast: mark the off state with a dashed border and a struck-through
class name, never a blanket opacity that drags the muted readouts under 4.5:1.
- EXACTLY 100, IN EVERY FILTER STATE. Shares are apportioned by largest
remainder (Hamilton): floor every share, then hand the leftover points to the
largest fractional parts, ties broken by the larger raw value then by
position. Rounding each share on its own is how four classes print 101 under
one filter and 99 under the next. A class with nothing in it has no fraction
to claim a point, so a real zero survives as a zero.
- ONE MARK, ONE VALUE. Each segment's top and bottom come from the running
total in VALUE space, so a segment is always positioned by the same number
that colours and labels it. No minimum-height floor: it would push every
segment above it off its own value.
- TWO AXES, ONE GRID. Pick the token step as the smallest nice rung (1 / 2 /
2.5 / 5 x 10^n) that fits the tallest visible bar into about 4 intervals, then
choose the cost step to fit the SAME interval count, so a money label always
sits level with a token label instead of implying a second, invisible grid.
- CALL OUT THE CACHE. Two tiles: the cache-hit share of the input stream with
the money the spread saved (cachedPrompt * (promptRate - cachedRate) / 1e6,
floored at zero โ a cache read priced above a fresh one saves nothing), and
the blended rate per million of whatever is on screen. Say in words that the
cache figure is measured on the whole input stream and does not move with the
filter. A zero saving has two causes and the caption must not confuse them:
no rate spread at all, versus a real spread nothing has hit yet (every period
pre-caching). Only the first may be worded as "no spread" โ the second says
the spread exists and no cache reads have landed on it.
- NAME THE CLASS THAT COSTS MORE THAN IT WEIGHS. Apportion token shares and
cost shares over the whole stream, take the class with the largest positive
gap, and print both integers in one sentence. It is the insight a token count
alone hides: reasoning is routinely a fifteenth of the tokens and a fifth of
the bill.
- Four first-class branches of one card: loading (skeleton mirroring tiles,
plot and chips, aria-hidden, plus an sr-only role=status line), empty (worded
so it cannot be mistaken for a failed fetch, and it explains that rates alone
cannot draw a chart), error (Try again only when onRetry was passed), ready.
status "ready" with no periods, or with every count zero, renders the empty
branch and says which of the two happened.
- CLEANUP: one ResizeObserver measuring the plot column so SVG user units are
CSS pixels, disconnected on unmount and whenever the node changes. No timers,
no rAF, no window listeners.
Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground panel, border gridlines
and tiles, muted skeleton, muted-foreground axis text, text-destructive for
the error headline, ring for focus. Classes take var(--chart-1..4) from one
index formula shared by the bar fill, the legend swatch and the table order;
the derived cost line takes var(--chart-5), because it is not one of the four.
Never a hex, never a low-alpha wash that disappears on a near-black card.
- The cost line is stroked twice: a wider stroke-card halo underneath, then the
chart token on top, so it stays readable wherever it crosses a bar in either
theme. Segments carry a half-pixel stroke-card seam so neighbouring slabs
separate without eroding a thin one.
- Tick thinning, not tick overlap: the widest period label sets a stride, and
the stride is counted back from the newest period so the right-hand end of a
time series always keeps its name. Money ticks take compact notation with a
precision derived from the step, so a sub-unit step still prints cents.
- ACCESSIBILITY: role="img" on the svg with a full sentence label โ honest,
because nothing inside the frame is a tab stop; the filter lives in real
buttons and every number is repeated in an sr-only table (sr-only on the
WRAPPER div, never on the table itself: CSS width is only a lower bound for a
table box). The svg label describes the FILTERED plot; the table is the
unfiltered ledger, so give it its own caption carrying the all-class totals
and naming how many classes are hidden from the plot but still listed below.
Captioning the table with the filtered sentence is how a screen reader hears
two contradictory totals for one card. Chips are focus-visible ringed and
carry a spoken sentence.
- Motion: the only animation is the skeleton pulse and the chip hover; both
carry motion-reduce:animate-none / motion-reduce:transition-none.
Customization levers
- Period grain: the contract takes labels, not dates, so the same card serves
days, weeks, deploys or eval runs โ change the label and the axis follows.
- Density: height is the plot box; the two tiles and the footnote paragraph are
the first things to drop for a dashboard tile, the chips the last.
- Class set: drop reasoning by omitting the field everywhere; add a fifth class
(cache WRITES bill above the base rate on some vendors) by extending the
tuple, the label map and the palette formula together โ they are one edit.
- Money: pass currency for locale formatting, or swap the blended-rate tile for
cost per request or per task by dividing by a count you already have.
- Interaction: for a static report card, render the chips as plain legend rows
and delete the hidden set; for a shared filter across a page of charts, lift
hidden into a controlled prop and emit onVisibleChange.
- Axis pairing: TARGET_INTERVALS trades gridline density against how much air
the money axis carries; raising it tightens the cost scale at the price of a
busier grid.
- Counterfactual: the saving figure already computes what the cached tokens
would have cost at the full input rate โ draw it as a second dashed line if a
before-and-after story is the point of the page.Concepts
- Tokens and money are two different pictures โ in the shipped example the bars barely move from night to night while the cost line falls by almost half, because prefix caching went live mid-week and pushed most of the input stream (78% of it by the last night) onto a rate exactly a tenth of the old one. A usage chart that plots only tokens shows a flat week and explains nothing about the invoice; that gap is why this card carries a second axis at all.
- The second axis is derived, not delivered โ cost is
tokens ร rate รท 1,000,000, computed from the same counts the bars are drawn from. Accepting a cost series alongside the tokens lets the two disagree, and a card that disagrees with itself is worse than no card. - A closed taxonomy, because the prices differ โ input, cached input, output and reasoning are not interchangeable "series": a cache read costs about a tenth of a fresh read, and a reasoning token costs what an output token costs even though nobody ever reads it. Collapsing them into "in and out" destroys the only figure worth looking at.
- Legend, ledger and filter are one control โ the swatch that names a class carries its tokens, its share, its rate and its cost, and pressing it takes the class out of the stack, the line and the totals. Switching the last one off is refused out loud rather than silently ignored.
- Largest remainder, so every filter state totals 100 โ shares take their floor first and the leftover points go to the largest fractional parts. Rounding each share on its own is how a four-way split prints 101 in one filter state and 99 in the next, which on a billing card reads as a data error, because it would be one.
- The class that costs more than it weighs โ the card apportions token shares and cost shares over the whole stream and names the widest positive gap in a sentence. Reasoning tokens are the usual answer, and they are the line item nobody budgets for: invisible to the user, billed as output, and absent from any chart that only counts what the model returned.
Scaling Law
A four-state log-log scaling plot that fits L = E + AยทC^(โฮฑ) to the runs it is given โ one power law per model family, exponent, irreducible loss and Rยฒ printed, with the stretch past the largest run hatched and projected.
Attention Heads
A small-multiples grid of causal attention maps โ one miniature per (layer, head), sortable by row entropy or previous-token mass, with the picked head enlarged beside it under token labels on both axes and its mass split into four shares that add to exactly 100.