Files
ai_coder 5c8c13891f
Build and deploy / build-deploy (push) Has been cancelled
feat: учёт token usage в checks с агрегатами и UI
- миграция checks: nullable input_tokens/output_tokens/total_tokens/usage_json
  (идемпотентно, старые строки остаются NULL; probe мигрирует сам — cron стартует раньше uvicorn)
- probe.py и /api/check фиксируют usage ДО валидации контента: токены
  сохраняются при пустом/сломанном ответе; таймауты и ответы без usage = NULL, не нули
- usage_json хранит сырой usage провайдера (OpenAI/Anthropic/camelCase форматы)
- /api/checks и сегментная история несут токены; новый /api/token_stats:
  rolling 24h + all time, покрытие known/unknown, разбивка по провайдерам
  (провайдер из models с fallback на префикс model_id)
- UI: статы «Токены 24h» и «Покрытие usage» в шапке, токены в тултипах
  сегментов и логе проверок
- статика через __file__-относительный путь (работает и в /app/app, и локально)
- тесты (30, реальные HTTP против мока OmniRoute): миграция/идемпотентность,
  NULL при missing/zero usage, usage при упавшем контенте, таймаут, агрегаты,
  оба пути записи (probe.run + /api/check)
2026-10-25 17:27:38 +00:00

84 lines
3.5 KiB
Python

"""
Общий модуль извлечения token usage из ответа OmniRoute.
Используется и cron-пробой (probe.py), и ручной проверкой (main.py).
Правила:
* usage извлекается ДО валидации контента — чтобы провал content-парсинга
не терял реально потраченные токены;
* если провайдер не отдал usage (или отдал полностью нулевой) — все поля
остаются NULL: неизвестное потребление не подменяется нулями;
* usage_json хранит сырой словарь usage как пришел от провайдера.
"""
from __future__ import annotations
import json
from typing import Optional
# Возможные имена полей в разных диалектах (OpenAI / Anthropic / camelCase).
_INPUT_KEYS = ("prompt_tokens", "input_tokens", "promptTokens", "inputTokens")
_OUTPUT_KEYS = ("completion_tokens", "output_tokens", "completionTokens", "outputTokens")
_TOTAL_KEYS = ("total_tokens", "totalTokens")
def _first_int(usage: dict, keys: tuple[str, ...]) -> Optional[int]:
"""Первое числовое значение из ключей; не-числа и None игнорируются."""
for k in keys:
v = usage.get(k)
if isinstance(v, bool): # bool — подкласс int, отсекаем явно
continue
if isinstance(v, (int, float)) and v >= 0:
return int(v)
return None
def extract_usage(resp) -> dict:
"""Достать usage из тела ответа chat/completions.
Возвращает dict с ключами input_tokens / output_tokens / total_tokens /
usage_json. Отсутствующий или вырожденный (полностью нулевой) usage дает
None во всех полях — NULL, а не нулевая оценка.
"""
empty = {"input_tokens": None, "output_tokens": None,
"total_tokens": None, "usage_json": None}
usage = None
if isinstance(resp, dict):
u = resp.get("usage")
if isinstance(u, dict):
usage = u
elif isinstance(u, str):
# редко: usage пришел строкой-JSON
try:
parsed = json.loads(u)
if isinstance(parsed, dict):
usage = parsed
except (ValueError, TypeError):
usage = None
if not isinstance(usage, dict):
return empty
inp = _first_int(usage, _INPUT_KEYS)
out = _first_int(usage, _OUTPUT_KEYS)
tot = _first_int(usage, _TOTAL_KEYS)
# Полностью нулевой usage (0/0/0) трактуем как «не отдал» — провайдеры
# так отвечают в ошибочных путях; реальные токены всегда > 0.
if not any(v for v in (inp, out, tot) if v is not None):
return empty
# total — точная арифметика по отчетным значениям, не оценка.
if tot is None and inp is not None and out is not None:
tot = inp + out
try:
usage_json = json.dumps(usage, ensure_ascii=False, sort_keys=True)
except (TypeError, ValueError):
usage_json = None
return {
"input_tokens": inp,
"output_tokens": out,
"total_tokens": tot,
"usage_json": usage_json,
}