Заметка

Нативные против нормализованных токенов: почему свой расчёт расходится со счётом OpenRouter

Три системы показали три суммы за одно окно. Независимым измерением там было одно, а токены в ответе и токены в счёте оказались разными величинами.

За одно и то же окно, по одному ключу, три системы показали три суммы: $0.20 в личном кабинете провайдера, $0.07231 в Langfuse, $0.0711 в Grafana. Ключ был заведён специально под этот стенд, так что посторонний расход исключён. Вопрос выглядел как «кто из трёх врёт».

Правильный вопрос оказался другим.

Сколько здесь независимых измерений

Langfuse и Grafana — не два свидетеля. Обе системы получали число из одной и той же формулы: токены_из_ответа × статичный прайс из конфига. Поэтому они и совпадали друг с другом — не потому, что обе правы, а потому, что у них общий вход. Независимый источник ровно один, и это леджер провайдера: по нему списывают деньги.

ИсточникОткуда берёт числоНезависим
леджер провайдерапо нему списывают деньгида
Langfuseформула «токены × прайс»нет
Prometheus / Grafanaта же формуланет

Согласие двух дашбордов читается как подтверждение и им не является. Считать свидетелей нужно по источникам данных, а не по количеству панелей. После этого разбор сузился до одного вопроса: почему единственная формула недосчитывает почти втрое.

Прайсы оказались верными

Первое подозрение — цены в конфиге устарели. Гипотеза отсекается дёшево: каталог моделей у агрегатора публичный и ключа не требует. Сверка дала совпадение до третьего знака: 0.43 / 0.87 против 0.435 / 0.87 у одной модели, точное 0.10 / 0.40 у другой.

Заодно всплыла деталь, которая позже пригодилась: у reasoning-модели в каталоге стоит отдельная ценовая строка internal_reasoning, не равная цене completion.

Улика: один и тот же множитель по обоим направлениям

Прямая проба с включённым usage-accounting показала расхождение не везде:

МодельСвоя формулаВыставил провайдерОтношение
gemini-класс$6.67e-5$6.67e-51.00
deepseek-класс$2.81e-4$4.82e-41.7×

Одна модель сходилась до цента, вторая расходилась в 1.7 раза. Решает здесь не сам разрыв, а его форма. В cost_details промптовая часть была посчитана по цене 0.745/M вместо 0.435/M — и коэффициент 1.7 оказался одинаковым и на промпте, и на completion.

При верных ценах один и тот же множитель сразу по обоим направлениям может означать только одно: числа токенов в ответе — не те числа, по которым выставляют счёт.

Что было под расхождением

Агрегатор биллил по нативным токенам модели-провайдера, а в usage.prompt_tokens и usage.completion_tokens отдавал нормализованные — приведённые к общей шкале, чтобы разные модели можно было сравнивать между собой. У одной модели нативный токенайзер плотнее, отсюда 1.7×. У второй нормализованные совпадают с нативными — поэтому она и сходилась.

Дальше арифметика: самый дорогой и самый нагруженный алиас на стенде был как раз тем, который расходился. Он и тянул общий итог с $0.20 до $0.07.

Разделение двух счётчиков никуда не делось — оно живёт в API до сих пор. Эндпоинт статистики генерации документирует оба набора отдельными полями: tokens_prompt и tokens_completion против native_tokens_prompt, native_tokens_completion, native_tokens_reasoning, native_tokens_cached — последние с пометкой «as reported by provider».

Правило, которое переживает смену провайдера

Собственная формула стоимости — это оценка, а не измерение. Её место — запасной путь. Авторитетное число отдаёт тот, кто выставляет счёт.

Практически это означает четыре вещи:

  • Брать usage.cost и cost_details из ответа провайдера как источник истины.
  • Статичную таблицу прайсов держать страховочной сеткой: она нужна там, где провайдер цены не отдаёт вовсе — например, для локального тира, где цена и так ноль.
  • Написать прямо в конфиге, что цены — fallback. Иначе через полгода их снова начнут читать как правду.
  • Проверить, что клиентский SDK не срезает нестандартные поля. cost и cost_details в OpenAI-схему usage не входят и попадают в «лишние» поля модели ответа; на практике они переживают SDK и достаются через getattr, model_dump и model_extra — но это стоит подтвердить пробой, а не верой.

Рецепт устарел, правило — нет

Замер сделан 14 июля 2026. Перед публикацией я сверился с документацией провайдера — и она описывает другое поведение.

ЧтоЗамер 14.07.2026Документация на 23.08.2026
счётчики в ответенормализованные«calculated using the model's native tokenizer»
основа биллинганативные токены«pricing are based on these native token counts»
включение usage-accountingпараметром в теле запроса«always included automatically»

Провайдер выровнял поведение: счётчики в ответе стали нативными, а прежний параметр usage: {include: true} объявлен устаревшим и, по документации, ни на что не влияет. Ставить его больше незачем — и это ровно та строка кода, которую не стоит копировать из статей полугодовой давности.

Замер при этом не опровергнут: цены были сверены с живым каталогом, одна модель сходилась до цента, вторая расходилась одинаковым множителем по обоим направлениям. Изменился вендор, а не факт. Читать авторитетное число вместо собственного пересчёта по-прежнему остаётся единственным способом не разойтись с леджером — и об экономии токенов это ничего не говорит, это отдельный разговор.

Почему арифметики из двух слагаемых мало

Формула prompt × цена_in + completion × цена_out предполагает, что вся генерация описывается двумя числами. Для моделей с reasoning это неверно как минимум двумя способами.

Первый — отдельная ценовая строка: reasoning может тарифицироваться по своей цене, отличной от completion. Второй — неочевидная вложенность. На замере reasoning-токены лежали внутри completion_tokens (261 из 317), но это факт про конкретную пару провайдер-модель, а не общее правило. Если у другой пары они лежат рядом, формула молча потеряет целую статью расхода.

Сюда же кэш: input_cache_read и cache_write_tokens тарифицируются отдельно. В моём замере они были нулевыми, так что поведение формулы при непустом кэше я не проверял.

Есть и соседний класс, где цены нет вовсе: генерация, завершившаяся ошибкой на стороне провайдера, приходит с кодом 200, без контента и без строки стоимости — её не видит ни один слой защиты. Там цена отсутствует. Здесь она есть, просто посчитана не по тем токенам.

Проверка тоже врёт

Сверка «в ноль» получилась не с первого раза, и оба промаха были в самой проверке, а не в системе.

Сначала grep пошёл по несуществующему имени метрики и вернул ноль. Ноль выглядит как «фикс не сработал», хотя означает «я спросил не то».

Потом дельта леджера, прочитанная сразу после прогона, оказалась меньше записанного: дочерние воркфлоу досылали свои вызовы последними, а аккаунтный счётчик провайдера догонял с задержкой. Через пару минут числа совпали ровно — $0.019571 против $0.019571.

Обе ловушки одного класса: проверка соврала про себя, а не про предмет. Тот же класс, что зелёный заголовок политики безопасности, который ничего не доказывает, потому что снимался в состоянии, где нужных запросов ещё нет.

Что остаётся, когда вендор всё исправит

Расхождение нативных и нормализованных счётчиков — деталь конкретного агрегатора, и она уже сгладилась. Механизм под ней остался.

Любой слой между вами и моделью нормализует: приводит счётчики к общей шкале, агрегирует, округляет, добавляет свои строки тарификации. Пока стоимость считается умножением на своей стороне, вы измеряете не расход, а собственную модель расхода. Расходиться она начнёт молча и ровно в тот день, когда поменяется что-то у провайдера.

Простое правило: у стоимости должен быть один источник, и это не ваша формула.

© 2026 axyi.ru · CC BY 4.0