Наша статус-панель показывала израсходовано $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. Вопрос сложнее того, о котором эта панель только сообщает: как потолок не наблюдать, а обеспечивать.