Repository navigation
Description
The number does not change when the range does
usage_summary on production, 2026-08-13, same account, three ranges:
| range | totals.costMicros |
totals.chargedCostMicros |
totals.calls |
|---|---|---|---|
7d |
9,584,796,135 | 36,352,782 | 3,370 |
30d |
9,621,218,575 | 36,352,782 | 5,721 |
all |
9,621,218,575 | 36,352,782 | 5,721 |
2,351 additional calls appear when the window widens from 7d to 30d, worth $36.42 of notional value — and not one cent of it lands in the charged figure. The console renders this as Est. billed (store/console/src/pages/Usage.tsx:280), so the owner comparing his Anthropic invoice against a 30-day PAGS figure is looking at roughly seven days of it.
Mechanism — the payer column has a start date and nothing says so
payer was added by workers/api/migrations/0092_ai_usage_payer.sql, committed 2026-08-07 (6141dad). The migration is explicit that this is deliberate:
NULL is the honest default and there is no backfill: every pre-existing row genuinely does not record who paid, and stamping one on retrospectively would be the same confident-but-unfounded inference this column exists to remove.
That reasoning is right and should not be reversed. The defect is not the NULLs — it is that the aggregate presents "charged over this range" without disclosing that the range and the payer's coverage are different windows.
workers/api/src/lib/usage.ts:369:
if (isCharged(r.payer)) totals.chargedCostMicros += r.cost_micros || 0;isCharged(null) is false (lib/usage-payer.ts:47-49), so every pre-0092 row contributes 0. The arithmetic is correct; the label is not.
Quantified, exactly
The 30d daily series, summed for every day before 2026-08-07:
07-14 454,706 07-15 8,109 07-18 188,499 07-19 210,180
08-01 6,676,369 08-02 7,915,468 08-03 12,854,308 08-04 4,456,429
08-05 3,658,372 08-06 8,287,110
total 44,709,550 micros = $44.71
And the difference between the two unknown-payer buckets is 9,584,865,793 − 9,548,443,353 = 36,422,440 — exactly the sum of days 07-14 through 08-05 above. That is a precise confirmation that every row outside the 7d window has payer IS NULL, with no exceptions.
Every one of those pre-0092 rows is non-engine (byKind.engine is byte-identical at 7d and 30d — 56,035 input / 3,283,599 output / 449 calls — so all 449 engine rows are inside the last 7 days). Non-engine rows come from recordUsage (hardcoded 'byok-api', usage.ts:97) and recordPlatformUsage (hardcoded 'platform', usage.ts:237). Both are charged payers. So had the column existed, essentially the whole $44.71 would have been charged.
Reported 30d charged: $36.35. Actual: ≈ $81.06. The page understates by ~55%.
(Verified: the $44.71 total, and that all 449 engine rows are inside 7d. Inferred: that every pre-0092 row would have resolved to a charged payer — from the two recorders' hardcoded values plus the absence of engine rows in that period, not from the rows themselves.)
What to do, cheapest first
- Return the coverage, do not backfill.
GET /v1/usagealready scans every row in range; add to the response:payerCoverage: { knownCalls, unknownCalls, knownCostMicros, unknownCostMicros, earliestKnownAt // MIN(created_at) WHERE payer IS NOT NULL, over the range }
aggregateUsagecan compute all five in the same loop it already runs. - Say it on the page. Under
Est. billed, one sentence: "Payer has been recorded since 7 Aug. 2,351 earlier calls ($36.42 of value) predate it and are not counted here." The page already carries a goodunknownpayer note (Usage.tsx:44); this is the same honesty applied to the time axis rather than the credential axis. - Nothing to change in the ledger. No migration, no backfill, no new column.
Alternatives considered and rejected
- Backfill
payerfromprovider(provider='platform'→ platform, else byok-api). Rejected: it is exactly the inference 0092's header forbids, and it would be wrong for any pre-0092 engine row that happened to exist —provider='anthropic'is the vendor, reached both by a metered key and by a subscription. - Backfill only rows written by
recordUsage/recordPlatformUsage. Rejected as a migration:ai_usagehas no column recording which recorder wrote a row, so the predicate would have to be reconstructed fromkind/provider, which is the same inference in a longer form. If it is ever wanted, it belongs as a display-time treatment behind the disclosed coverage figure, not as aUPDATE. - Clamp the range to the payer's start when charged is requested. Rejected: it silently changes what "30 days" means, and the notional column beside it would still be a genuine 30 days. Two different windows in one table is the defect, not the fix.
Acceptance criteria
GET /v1/usage?range=30dreturns a coverage object; for this account it reportsunknownCalls ≈ 2,351andearliestKnownAton 2026-08-07.- The console states the coverage wherever
Est. billedappears. usage.test.tscovers a fixture spanning the boundary (rows with and withoutpayer) and asserts the coverage counts, so the disclosure cannot silently drop out.- MCP
usage_summarypasses the field through (it forwards the payload).
Regression risk
Low — additive. The one thing to get right is that the coverage counts must be computed over the same filtered row set the aggregate uses, not a separate query with its own WHERE; a second query is how the two figures start to disagree. Compute it inside aggregateUsage.
Related: #346 (added the payer axis), #543 (per-agent charged figures — the other half of "what did this agent cost me").
Activity
fix(usage): every breakdown carries the charged figure beside the not…
fix(usage): the charged figure states how far its own window reaches,…
serge-ivo commentedon Aug 13, 2026
Shipped in
1fcac9eand verified live. Yes — the page can derive its own coverage boundary, and it now states it instead of hedging. But only part of what you specified is derivable, and the part that is not turns out to matter more than the part that is.What it says now, on your account
GET /v1/usage?range=30d, live:firstAttributedAtlands on 2026-08-07 03:35, which is migration 0092's own day — arrived at from the data, with no reference to the migration. The console renders, underEst. billed:Your measured figure was $44.71 across 2,351 calls on 12 Aug; the live 30-day window is now $91.86 across 2,977, because the rolling window has moved and taken in more pre-boundary days. The understatement is real and larger than the issue reported.
Why it does not claim a start date — and why that is not a hedge
Three separate things get confused if the page says "payer has been recorded since 7 Aug":
d1_migrations.applied_at— and I rejected it. It is infrastructure rather than application schema, it would couple a user-facing figure to a migration filename that nothing else enforces, and it cannot be exercised by a test without inventing the table. It also answers the wrong question, per (2).So the coverage object splits the unattributed rows around the boundary rather than lumping them, and the note speaks only about the
beforebucket. Thesincebucket already has its own note and its own remedy — #551's "store a Claude Code sign-in token" — and the two now sit beside each other saying different things about different money. That separation is the design decision this ticket actually turned on, and the live numbers are what justify it.Against your spec
GET /v1/usage?range=30dreturns a coverage object. Fields differ from the sketch:attributed/unattributedBefore/unattributedSince/firstAttributedAt, instead ofknownCalls/unknownCalls/knownCostMicros/unknownCostMicros/earliestKnownAt. The extra split is the point above; a singleunknownpair would have been unusable for the note.aggregateUsage, over the same array the totals came from — your regression note, and it was the right one. Two passes over that array, not one, because the boundary is not known until the first is done; assuming the route'sORDER BY created_atwould have made it silently wrong for any other caller, and there is a test that reverses the input.usage.test.tsequivalent:workers/api/src/lib/usage-coverage.test.ts, fixture spanning the boundary, with a "the three slices sum to the range" identity and a case for an account where nothing is attributed (no boundary → nobefore→ the note says nothing rather than inventing a date).usage_summaryforwards it; its description now explains whatpayerCoverage.unattributedBeforemeans so a calling model stops reading a wide range as complete.One correction to the issue's arithmetic:
attributedcounts payer presence, notisCharged. Asubscriptionrow is fully attributed and contributes nothing to the charged figure; counting it as a coverage gap would send you looking for a credential that is already recorded.Verified
10/10 CI gates, 8,543 unit tests, 405 Playwright specs (chromium + webkit) including the mobile sweep. Deploy green on API/Host/MCP. The live payload above is from production after the deploy landed, and the console bundle serving
proagentstore.online/console/contains the new sentence.Closing.
fix(mcp): usage_summary names every field /v1/usage returns, and a te…