cd /news/ai-tools/the-budget-panel-said-189-left-the-c… · home › topics › ai-tools › article
[ARTICLE · art-151712] src=dev.to ↗ pub= topic=ai-tools verified=true sentiment=· neutral

The budget panel said $189 left. The console said $291. Both numbers were wrong.

A developer building a Claude Code status-pane plugin found that its budget display and the provider's console reported conflicting remaining balances ($189.20 versus $291.77) because the pane compared a real spend figure against an unmeasured placeholder budget of $200 and blended two incompatible accounting sources — the OpenAI-compatible billing endpoint and the new-api console's quota fields. The fix replaces arithmetic reconciliation with a discriminated union tagging each figure by origin ('console' or 'billing') and a merge function that only carries values across snapshots of the same origin, so the two accountings can never be silently combined.

by read5 min views47 publishedOct 17, 2026

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.

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.

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.

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:

New-Api-User header is not optional. 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.)

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 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.

200 as 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. 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.

Prior art that shaped how I thought about this, from the DEV archive:

── more in #ai-tools 4 stories · sorted by recency
── more on @claude code 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/the-budget-panel-sai…] indexed:0 read:5min 2026-10-17 · —