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

Claude Code 用量面板顯示「餘額 $189」,控制台顯示「$291」:兩個數字皆有誤

Панель говорила «осталось $189». Консоль говорила «$291». Оба числа были неверны.

AI 導讀

作者重做 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, которые повлияли на то, как я об этом думал:

來源:DEV Community · dev.to