Skip to content

"Est. billed" is identical for 7d, 30d and all-time ($36.35) — the payer column starts 2026-08-07 and the page never says so, understating 30-day charged spend by ~55% #544

Closed

Description

@serge-ivo

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

  1. Return the coverage, do not backfill. GET /v1/usage already 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
    }
    aggregateUsage can compute all five in the same loop it already runs.
  2. 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 good unknown payer note (Usage.tsx:44); this is the same honesty applied to the time axis rather than the credential axis.
  3. Nothing to change in the ledger. No migration, no backfill, no new column.

Alternatives considered and rejected

  • Backfill payer from provider (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_usage has no column recording which recorder wrote a row, so the predicate would have to be reconstructed from kind/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 a UPDATE.
  • 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=30d returns a coverage object; for this account it reports unknownCalls ≈ 2,351 and earliestKnownAt on 2026-08-07.
  • The console states the coverage wherever Est. billed appears.
  • usage.test.ts covers a fixture spanning the boundary (rows with and without payer) and asserts the coverage counts, so the disclosure cannot silently drop out.
  • MCP usage_summary passes 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

  1. added
    bugSomething isn't working
    backendBackend / Worker / API work
    on Aug 12, 2026
  2. serge-ivo commented on Aug 13, 2026

    @serge-ivo
    ContributorAuthor

    Shipped in 1fcac9e and 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:

    totals            : value $9,624.80   charged $38.46   calls 5,738
    payerCoverage:
      firstAttributedAt  : 2026-08-07 03:35:02
      attributed         : 2,325 calls   $38.46
      unattributedBefore : 2,977 calls   $91.86     ← cannot be in the charged figure
      unattributedSince  :   436 calls   $9,494.47
      (2,325 + 2,977 + 436 = 5,738 = totals.calls)
    

    firstAttributedAt lands 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, under Est. billed:

    Charged counts only calls whose payer we could establish, and the earliest in this range is 7 Aug 2026. 2,977 calls before that ($91.86 of value) carry no payer at all, so none of them is counted above.

    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":

    1. The migration's timestamp exists — D1 keeps 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).
    2. NULL has two causes and they are not distinguishable per row: older than the column, or a coding engine on a machine login whose payer genuinely could not be established. On your account the second cause is $9,494.47 of the $9,586.33 unattributed — 99.0%. A disclosure that blamed the date for all of it would have replaced one confident wrong number with another, and it would have been wrong by two orders of magnitude.
    3. The earliest attributed call is a lower bound, not the date. An account idle for a week after 0092 shipped has a later one.

    So the coverage object splits the unattributed rows around the boundary rather than lumping them, and the note speaks only about the before bucket. The since bucket 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=30d returns a coverage object. Fields differ from the sketch: attributed / unattributedBefore / unattributedSince / firstAttributedAt, instead of knownCalls/unknownCalls/knownCostMicros/unknownCostMicros/earliestKnownAt. The extra split is the point above; a single unknown pair would have been unusable for the note.
    • ✅ Coverage counts computed inside 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's ORDER BY created_at would have made it silently wrong for any other caller, and there is a test that reverses the input.
    • ✅ usage.test.ts equivalent: 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 → no before → the note says nothing rather than inventing a date).
    • ✅ MCP usage_summary forwards it; its description now explains what payerCoverage.unattributedBefore means so a calling model stops reading a wide range as complete.
    • ✅ No backfill, no migration, no ledger change. Nothing here infers a payer for any row.

    One correction to the issue's arithmetic: attributed counts payer presence, not isCharged. A subscription row 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.

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 working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions