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

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,440exactly 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

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