Claude Code 用量面板顯示「餘額 $189」,控制台顯示「$291」:兩個數字皆有誤
Панель говорила «осталось $189». Консоль говорила «$291». Оба числа были неверны.
作者重做 Claude Code 插件的用量面板,改以供應商控制台作為餘額主來源,避免把預設預算與 billing API 的帳目混為一談。舊面板以預設 $200 預算計算餘額 $189.20;billing API 顯示支出 $30.04,控制台配額換算後則為餘額 $291.77、支出 $33.23、總額 $325.00。
Наша статус-панель показывала израсходовано $10.80 из $200.00, осталось $189.20. Консоль провайдера в соседней вкладке показывала осталось $291.77. Один аккаунт, один день.
Оба числа были неверны, и по разным причинам. Это разбор того, как найти число, которое на самом деле верно, и того изменения в дизайне, после которого панель больше не сможет смешать две разные системы учёта.
Что за задача
Это плагин для агентной обвязки Claude Code: мод, который регистрирует панель интерфейса и перерисовывает её по таймеру. Панель показывает остаток на счету рядом с окнами лимитов, заполнением контекста и стоимостью сессии. Написан на TypeScript, подгружается исходником при старте сессии, и у него есть набор тестов, который прогоняет его через поддельный движок.
Провайдер — реселлер, работающий на new-api, открытой консоли, на которой построена изрядная доля реселлеров LLM-API (её предок one-api ведёт себя так же). Эта деталь пригодится дальше.
Ошибка первая: лимит, которого никто не задавал
Панель сравнивала расход с budgetUsd — настройкой со значением по умолчанию 200:
const DEFAULT_BUDGET_USD = 200
Эти 200 никто не измерял. Это была заглушка, которая уехала в релиз, и поскольку она рисовалась с двумя знаками после запятой рядом с настоящим расходом, читалась она как данные. Панель уверенно сообщала, что осталось 189 долларов, — а это ровно тот сорт неверного ответа, который хуже, чем «неизвестно»: по нему начинаешь действовать.
Расход брался из billing-API:
GET /v1/dashboard/billing/usage
{"total_usage": 3004} // центы
Это endpoint, совместимый с OpenAI, и у этого провайдера он отдаёт другое число, чем консоль. Об этом ниже.
Ошибка вторая: считалось не то
Консоль отдаёт сам аккаунт:
GET /api/user/self
Authorization: Bearer <токен консоли>
New-Api-User: <числовой id пользователя>
{"data": {"quota": 145883249, "used_quota": 16616751}, "success": true}
quota — сколько осталось, used_quota — сколько сожжено. Оба в единицах квоты, не в центах и не в долларах.
Две засады, каждая стоила лишнего круга:
-
Заголовок
New-Api-Userне опционален. Без него запрос падает так, что это выглядит как плохой токен, — и идёшь перевыпускать токен на ровном месте. - Нужен коэффициент единиц. new-api и one-api по умолчанию кладут 500 000 единиц на доллар США, но это настраивается на конкретной установке, так что хардкодить его второй раз нельзя.
Впрочем, искать коэффициент не пришлось. Подсказка была в самих числах: quota + used_quota дало 162 500 000, а собственная страница консоли показывала аккуратные $325.00 итого. Круглые итоги случайно не получаются. 162 500 000 / 325 = 500 000.
Так что честное прочтение такое: осталось $291.77, израсходовано $33.23, всего $325.00. (Именно «всего» и позволило вычислить коэффициент; сама панель его больше не тащит — об этом в конце.)
Две системы учёта не сходятся
| Источник | Израсходовано |
|---|---|
/v1/dashboard/billing/usage |
$30.04 |
Консоль (used_quota) |
$33.23 |
Ни то, ни другое не ошибка округления. Если усреднить, получится число, верное сразу нигде. Решение здесь не арифметическое — надо выбрать, какой источник главный, и пометить каждую цифру тем, откуда она пришла, чтобы две системы учёта никогда не смешались молча.
Это размеченное объединение и null:
export type QuotaOrigin = 'console' | 'billing'
export type QuotaBudget = {
at: number
origin: QuotaOrigin
balanceUsd: number | null // только консоль
spentUsd: number | null // только billing
budgetUsd: number
// ...
}
А слияние снимков переносит цифры только из прошлого снимка того же источника:
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,
// ...
}
}
Разбор ответа намеренно строг в том, что вообще считается ответом:
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 }
}
Всё, что не конечное неотрицательное число, возвращает null, а null рисуется как «данных нет» — не как ноль и не как прошлонедельное число, выданное за сегодняшнее.
Главный источник теперь консоль, а billing-API остался запасным путём на случай, когда токен консоли не задан: он работает с токеном сессии, который есть всегда. Процент принадлежит только запасному пути — это доля от предела, который назван в той же строке, — а в строке консоли одно число, и делить его не на что:
const budgetPercent = (shown: QuotaBudget) =>
shown.spentUsd === null || shown.budgetUsd <= 0
? 0
: Math.max(0, Math.min(100, (shown.spentUsd / shown.budgetUsd) * 100))
Тест, который держит это честным
Самый полезный тест в наборе — тот, который проверяет, что запрос не был сделан:
test('остаток счёта берётся из консоли, а billing-API не спрашивается', 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: /^остаток \$291\.77$/ })).toBeDefined()
expect(asked.some(url => url.includes('/v1/dashboard/billing/usage'))).toBe(false)
})
Плюс отдельный — на заданный коэффициент единиц, и ещё один: консоль отвечает 500, а панель обязана показать прошлый консольный остаток, помеченный как устаревший, и с причиной:
остаток $291.77 · 5 мин назад · ответ 500
Устаревшее с честной пометкой лучше свежего и выдуманного. Обе проверки привязаны к строке целиком: если рядом с остатком снова появится второй доллар, набор упадёт, а не пропустит его обратно.
Что я сделал бы иначе
-
Никогда не выпускать правдоподобное значение по умолчанию для измерения. Именно
200в роли «лимита» и был настоящей ошибкой. Значение по умолчанию, похожее на данные, прочитают как данные. Если значение неизвестно, интерфейс должен это сказать. - Коэффициент единиц — в настройки, а не во вторую константу. Коэффициент — свойство конкретной установки, и если он настраивается на стороне сервера, его в конце концов настроят.
- Дать «неизвестно» собственное состояние. Пусто, ноль и устаревшее — три разные вещи. Схлопывание их в одно и есть то, из-за чего панель уверенно ошибается.
- Токен сессии отправлять только на тот хост, для которого он настроен. Панель читает переменную окружения, в которой лежит в том числе учётка сессии; перед тем как её приложить, она сверяет хост запроса с настроенным базовым адресом провайдера, и на это поведение есть тест. Панель — отличное место, чтобы случайно утечь токеном.
Сейчас панель показывает остаток $291.77, ровно как консоль провайдера. Она перестала угадывать.
Вместе с этим ушли «всего» и полоса, показывавшая, какая доля пополнения израсходована. Когда есть остаток, по которому и принимаешь решение, это были ещё два числа в ответ на вопрос, которого никто не задавал, — а проценту полосы на экране не осталось знаменателя, чтобы хоть что-то значить.
Что уже писали по этой теме
Материалы с DEV, которые повлияли на то, как я об этом думал:
- Monitoring API credit usage before an automation runs out — messora. Тот же отказ: сборщик совершенно здоров и тихо ничего не собирает, потому что баланс дошёл до нуля.
- Three Ways to Monitor Prepaid API Balances (Excel, Python + Cron, Power Automate) — saint_vandora. Полезно о том, где такая проверка должна жить в конвейере, а не в интерфейсе.
- Hard caps on autonomous agent API spend — infracore. Вопрос сложнее того, о котором эта панель только сообщает: как потолок не наблюдать, а обеспечивать.
來源:DEV Community · dev.to