Skip to content

The Usage page prints $9,566.69 next to "Repo Coder" and $36.35 as "Est. billed" — every breakdown carries only the notional figure, so no agent has a charged cost #543

Description

@serge-ivo

The question and the two answers the page gives

"What did this agent cost me?" On the Usage page today, the Repo Coder row reads $9,566.69 and the headline "Est. billed" stat reads $36.35. Both are on screen at once, they differ by 263x, and nothing on the page says which of the two the Repo Coder row belongs to.

It belongs to neither. The row is notional list-price value; the headline is charged money; and there is no per-agent charged figure anywhere in the payload.

Measured

usage_summary, production, 2026-08-13, range: "all" (identical to 30d — the whole ledger is inside 30 days):

totals.costMicros          9,621,218,575   $9,621.22   notional
totals.chargedCostMicros      36,352,782      $36.35   money
                                                       → charged is 0.378% of notional

byAgent[0] Repo Coder      9,566,686,384   $9,566.69   99.43% of the notional total
byKind[0]  engine          9,541,310,295   $9,541.31   99.17%, 449 calls
byPayer    unknown         9,584,865,793   $9,584.87   99.62%
           byok-api           36,336,533      $36.34
           platform               16,249       $0.02
           subscription        (no bucket — zero rows)

So the one agent that dominates the page is 99.7% kind:engine at payer: unknown. The dollar figure printed beside its name is, for that agent, almost entirely not money — and the page renders it in the same $ format, in the same column, as every other row.

Mechanism

totals carries the charged figure. UsageBucket does not.

workers/api/src/lib/usage.ts:281-297 — the bucket type:

export interface UsageBucket {
	key: string;
	label?: string;
	inputTokens: number;
	outputTokens: number;
	cacheReadTokens: number;
	cacheWriteTokens: number;
	costMicros: number;
	calls: number;
}

usage.ts:324-334bump() receives the whole row, payer included, and accumulates everything except the one field that answers the question:

function bump(b: UsageBucket, r: UsageRow) {
	b.inputTokens += r.input_tokens || 0;
	b.outputTokens += r.output_tokens || 0;
	b.cacheReadTokens += r.cache_read_tokens || 0;
	b.cacheWriteTokens += r.cache_write_tokens || 0;
	b.costMicros += r.cost_micros || 0;
	b.calls += 1;
}

Six lines above, at usage.ts:369, the totals loop does exactly the thing the buckets do not:

if (isCharged(r.payer)) totals.chargedCostMicros += r.cost_micros || 0;

Same loop body, same row, same predicate available. byModel, byKind, byAgent and byPayer all go through bump(), so none of them carries it. aggregateAdminUsage has the same split — addInto (usage.ts:451-457) accumulates chargedMicros, but its per-bucket into() calls bump(), so the admin by-user and by-agent breakdowns are notional-only too.

The renderer then prints the notional figure with no qualifier. store/console/src/pages/Usage.tsx:164:

<span className="w-16 text-right shrink-0 tabular-nums text-muted">{usd(r.costMicros)}</span>

That one line renders the by-agent, by-model, by-kind and by-payer breakdowns. The "Payer not established" row therefore prints $9,584.87 in dollars — the row whose entire purpose is to say "this is not money".

Why it composed into a bug

Two individually-correct decisions:

  1. [design] The usage ledger records value, not charge — add payer attribution #346 put the charge on totals and stopped there. Correct as far as it went — it ended the "everything is a bill" reading of the headline.
  2. The breakdowns were written before payer existed and were left as pure token/value rollups, which is exactly right for the "how busy is this agent" question they were originally for.

Composed, the page states a true total it cannot decompose. The owner's actual question is decomposition.

What to do, cheapest first

  1. Accumulate it. Three lines: chargedCostMicros: 0 in emptyBucket (usage.ts:322), if (isCharged(r.payer)) b.chargedCostMicros += r.cost_micros || 0; in bump (usage.ts:324), and the field on UsageBucket. Every breakdown gets it at once, including byPayer and both admin aggregates, because they all share bump(). isCharged is already imported at usage.ts:11.
  2. Render two columns, not one. Usage.tsx:164 shows usd(r.costMicros); add the charged figure beside it, and size the bar by charged when any row has one. A row where charged is $0.00 of $9,566.69 is the most informative thing the page could say, and today it is the one thing it cannot.
  3. Do NOT drop the notional column. It is the only figure that reflects what a subscription-run Coder actually consumed; stats-sources.ts:321-329 already documents why filtering it to charged would show $0.00 for an agent that plainly did a great deal of work. Two numbers, labelled.

Alternatives considered and rejected

  • Filter the breakdowns to charged rows only. Rejected for the reason above, and for the same reason usageValue is deliberately unfiltered: the Repo Coder would render as $0.00 and read as idle.
  • Add a chargedByAgent array beside byAgent. Rejected — two arrays that must be joined by key, when one shared bump() already visits every bucket in one pass.
  • Compute it client-side from byPayer. Impossible: byPayer and byAgent are separate rollups of the same rows with no cross-product, so there is no way to get "charged, for this agent" out of them.

Acceptance criteria

  • GET /v1/usage returns chargedCostMicros on every bucket in byModel, byKind, byAgent, byPayer.
  • For the account measured above, byPayer returns {key:"unknown", costMicros: 9_584_865_793, chargedCostMicros: 0} and {key:"byok-api", costMicros: 36_336_533, chargedCostMicros: 36_336_533}.
  • sum(byAgent[].chargedCostMicros) === totals.chargedCostMicros — assert it in usage.test.ts over a fixture containing rows of all four payer states (byok-api, platform, subscription, NULL).
  • The console shows both figures per row, and a row with chargedCostMicros: 0 is visually distinguishable from one that was not measured.
  • aggregateAdminUsage's byUser / byAgent carry it too (same bump()), so the operator view stops having the same defect.

Regression risk

Breakdown sizes its bars by costMicros and falls back to tokens when every row is zero (Usage.tsx:151-154). Switching the bar metric to charged would make the Repo Coder — 99.4% of the account's consumption — render as a 2%-wide stub, which is a worse lie than the current one. Size the bar by notional, print both numbers. A snapshot test over the fixture above is what catches a later "simplification" to one column.

Related: #346 (which added the totals-level split), #526 (the same page cannot attribute per instance either), and the range-truncation defect in the charged figure filed separately.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    backendBackend / Worker / API workbugSomething isn't workingfrontendFrontend / UI work

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions