跳到正文
DEV Community· Ruslan Sivak·· 4 小時前AI 評分51

Claude Code 餘額面板顯示 $189、供應商控制台顯示 $291:兩個數字都不對

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

AI 導讀

Ruslan Sivak 修正 Claude Code Agent harness 插件的餘額面板,改以供應商控制台作主要餘額來源,避免混用兩套不一致的計費數據。

正文

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-User header 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. 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.
  • 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:

來源:DEV Community · dev.to