Our little status pane said $10.80 spent of $200.00, $189.20 left. The provider's console, open in the next tab, said $291.77 remaining. Same account, same day.
Both numbers were wrong, and for different reasons. This is the write-up of finding the number that is actually true, and of the one-line-shaped design change that stops the pane from ever mixing two accountings again.
The setup
It's a plugin for the Claude Code agent harness: a mod that registers a UI pane and redraws it on a timer. It shows the account's remaining balance next to the rate-limit windows, the context fill, and the session cost. It runs in TypeScript, it is loaded as source at session start, and it has a test suite that drives it through a mocked engine.
The provider is a reseller running new-api, the open-source console that a lot of LLM API resellers are built on (its ancestor, one-api, behaves the same way). That detail matters later.
Bug one: a budget nobody set
The pane compared a spend figure against budgetUsd, a config value with a default of 200:
const DEFAULT_BUDGET_USD = 200
Nobody measured that 200. It was a placeholder that shipped, and because it was rendered with two decimal places next to a real spend number, it read as data. The pane confidently announced that 189 dollars were left, which is exactly the kind of wrong answer that is worse than "unknown" — you act on it.
The spend figure came from the billing API:
GET /v1/dashboard/billing/usage
{"total_usage": 3004} // cents
That endpoint is the OpenAI-compatible one, and on this provider it reports a different number from the console. More on that below.
Bug two: it was counting the wrong thing
The console exposes the account itself:
GET /api/user/self
Authorization: Bearer <console token>
New-Api-User: <numeric user id>
{"data": {"quota": 145883249, "used_quota": 16616751}, "success": true}
quota is what's left; used_quota is what's been burned. Both are in quota units, not cents, not dollars.
Two gotchas, both of which cost me a round trip:
-
The
New-Api-Userheader is not optional. Without it the request fails in a way that looks like a bad token, so you go and re-paste the token for no reason. - You need the unit ratio. new-api and one-api default to 500,000 units per US dollar, but it's configurable per deployment, so it is not something to hardcode a second time.
I didn't have to look the ratio up, though. The tell was in the numbers: quota + used_quota came to 162,500,000, and the console's own page showed a tidy $325.00 total. Round totals don't happen by accident. 162,500,000 / 325 = 500,000.
So the honest reading was: $291.77 left, $33.23 spent, $325.00 total. (The total is what pinned the ratio down; the pane itself no longer carries it — more at the end.)
The two accountings do not reconcile
| Source | Spent |
|---|---|
/v1/dashboard/billing/usage |
$30.04 |
Console (used_quota) |
$33.23 |
Neither is a rounding error. If you average them you get a number that is true in no system. The fix is not arithmetic — it's to decide which source is authoritative and to label every figure with where it came from, so two accountings can never be silently blended.
That's a discriminated union and a null:
export type QuotaOrigin = 'console' | 'billing'
export type QuotaBudget = {
at: number
origin: QuotaOrigin
balanceUsd: number | null // console only
spentUsd: number | null // billing only
budgetUsd: number
// ...
}
And the merge function only carries figures across from a previous snapshot of the same origin:
const merged = (was: QuotaBudget | null, now: QuotaBudget): QuotaBudget => {
const same = was !== null && was.origin === now.origin ? was : null
return {
origin: now.origin,
balanceUsd: now.balanceUsd ?? same?.balanceUsd ?? null,
spentUsd: now.spentUsd ?? same?.spentUsd ?? null,
// ...
}
}
The parsing is deliberately strict about what counts as an answer:
const balanceOf = (body: unknown, quotaPerUnit: number) => {
if (typeof body !== 'object' || body === null) return null
const data = (body as Record<string, unknown>).data
if (typeof data !== 'object' || data === null) return null
const { quota } = data as Record<string, unknown>
if (typeof quota !== 'number' || !Number.isFinite(quota) || quota < 0) return null
return { balanceUsd: quota / quotaPerUnit }
}
Anything that isn't a finite, non-negative number returns null, and null renders as "no data" — not as zero, and not as last week's number dressed up as this week's.
The console is now the primary source, and the billing API survives only as a fallback for when the console token isn't configured, because it works with the session token that's always present. The percentage is the fallback's alone — a share of a limit that the same line spells out — while the console's line is one number with no denominator to divide by:
const budgetPercent = (shown: QuotaBudget) =>
shown.spentUsd === null || shown.budgetUsd <= 0
? 0
: Math.max(0, Math.min(100, (shown.spentUsd / shown.budgetUsd) * 100))
The test that keeps it honest
The most useful test in the suite is the one that asserts a request was not made:
test('balance comes from the console and the billing API is never asked', async ($, on) => {
const asked: string[] = []
on('http.fetch', (_$, e) => {
asked.push(e.url)
if (e.url.includes('/api/user/self')) return { value: SELF }
if (e.url.includes('/api/log/self?p=1')) return { value: logPage(ones(1), 1) }
return { value: { status: 404, ok: false, headers: {}, text: '' } }
})
// ...
expect(await ui.find({ text: /^balance \$291\.77$/ })).toBeDefined()
expect(asked.some(url => url.includes('/v1/dashboard/billing/usage'))).toBe(false)
})
Plus one for a configured unit ratio, and one where the console answers 500 and the pane is required to show the previous console balance, marked stale, with the reason:
balance $291.77 · 5 min ago · response 500
Stale-and-labelled beats fresh-and-fabricated. Both assertions are anchored to the whole line, so a second dollar figure reappearing next to the balance fails the suite rather than sneaking back in.
What I'd do differently
-
Never ship a plausible default for a measurement.
200as a "budget" was the actual bug. A default that looks like data will be read as data. If the value isn't known, the UI should say so. - Make the unit ratio config, not a second constant. The ratio is a property of the deployment, and if it's configurable on the server side, it will eventually be configured.
- Give "unknown" its own state. Empty, zero, and stale are three different things. Collapsing them is how a panel ends up confidently wrong.
- Only send the session token to the host it was configured for. This pane reads an env var that also carries the session credential; it checks that the request's host matches the configured provider base URL before attaching it, and a test pins that behaviour. A panel is a nice place to accidentally exfiltrate a token from.
The pane now reads $291.77 left, which is what the provider's own console says. It stopped being a guess.
The console's total went with it, and so did the bar showing what share of the top-up was gone. Once the remaining balance is the number you act on, those were two more figures answering a question nobody asked — and the bar's percentage had no denominator left on screen to mean anything.
Community wisdom
Prior art that shaped how I thought about this, from the DEV archive:
- Monitoring API credit usage before an automation runs out — messora. Same failure mode: a scraper that is perfectly healthy and quietly producing nothing because the balance hit zero.
- Three Ways to Monitor Prepaid API Balances (Excel, Python + Cron, Power Automate) — saint_vandora. Useful on where the check belongs in a pipeline rather than in a UI.
- Hard caps on autonomous agent API spend — infracore. The harder question this pane only reports on: enforcing a ceiling instead of watching one.
Top comments (0)