feat: учёт token usage в checks с агрегатами и UI
Build and deploy / build-deploy (push) Has been cancelled

- миграция 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)
This commit is contained in:
ai_coder
2026-10-25 17:27:38 +00:00
parent 0a0455af73
commit 5c8c13891f
11 changed files with 1007 additions and 21 deletions
+42 -7
View File
@@ -12,6 +12,8 @@ import time
import urllib.request
from datetime import datetime, timezone
from usage_utils import extract_usage
DB_PATH = os.environ.get("DB_PATH", "/data/arena.db")
OMNIROUTE_URL = os.environ.get("OMNIROUTE_URL", "http://192.168.1.101:20128")
OMNIROUTE_KEY = os.environ.get("OMNIROUTE_API_KEY", "")
@@ -19,9 +21,17 @@ TIMEOUT = int(os.environ.get("CHECK_TIMEOUT", "30"))
CONCURRENCY = int(os.environ.get("CHECK_CONCURRENCY", "5"))
def check_model(model_id: str) -> tuple[bool, int | None, str | None]:
def check_model(model_id: str) -> tuple[bool, int | None, str | None, dict]:
"""Возвращает (ok, latency_ms, error, usage).
usage извлекается ДО валидации контента: если парсинг choices падает,
потраченные токены всё равно сохраняются. Таймаут/ошибка сети без тела
ответа дают usage=None по всем полям (NULL, а не нули).
"""
t0 = time.time()
error = None
usage = {"input_tokens": None, "output_tokens": None,
"total_tokens": None, "usage_json": None}
try:
body = json.dumps({
"model": model_id,
@@ -38,6 +48,8 @@ def check_model(model_id: str) -> tuple[bool, int | None, str | None]:
)
with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
resp = json.loads(r.read())
# Usage — до любой валидации контента.
usage = extract_usage(resp)
content = resp["choices"][0]["message"]["content"]
ok = bool(content.strip())
except Exception as e:
@@ -45,11 +57,30 @@ def check_model(model_id: str) -> tuple[bool, int | None, str | None]:
print(f" ✗ {model_id}: {error}", file=sys.stderr)
ok = False
latency = int((time.time() - t0) * 1000)
return ok, latency, error
return ok, latency, error, usage
def _ensure_usage_columns(db: sqlite3.Connection):
"""Идемпотентно добавить колонки token usage, если БД ещё со старой схемой.
Cron стартует раньше uvicorn, поэтому probe не может полагаться на то,
что init_db из main.py уже прогнал миграцию.
"""
cols = [r[1] for r in db.execute("PRAGMA table_info(checks)").fetchall()]
for col, decl in (
("input_tokens", "INTEGER"),
("output_tokens", "INTEGER"),
("total_tokens", "INTEGER"),
("usage_json", "TEXT"),
):
if col not in cols:
db.execute(f"ALTER TABLE checks ADD COLUMN {col} {decl}")
db.commit()
def run():
db = sqlite3.connect(DB_PATH, check_same_thread=False)
_ensure_usage_columns(db)
models = [r[0] for r in db.execute("SELECT id FROM models").fetchall()]
db.close()
@@ -58,8 +89,8 @@ def run():
from concurrent.futures import ThreadPoolExecutor, as_completed
def do(mid):
ok, lat, err = check_model(mid)
return mid, ok, lat, err
ok, lat, err, usage = check_model(mid)
return mid, ok, lat, err, usage
db = sqlite3.connect(DB_PATH, check_same_thread=False)
now = datetime.now(timezone.utc).isoformat()
@@ -68,10 +99,14 @@ def run():
futs = {ex.submit(do, m): m for m in models}
done = ok_count = 0
for f in as_completed(futs):
mid, ok, lat, err = f.result()
mid, ok, lat, err, usage = f.result()
db.execute(
"INSERT INTO checks (model_id, checked_at, ok, latency_ms, error) VALUES (?,?,?,?,?)",
(mid, now, int(ok), lat, err),
"INSERT INTO checks (model_id, checked_at, ok, latency_ms, error, "
"input_tokens, output_tokens, total_tokens, usage_json) "
"VALUES (?,?,?,?,?,?,?,?,?)",
(mid, now, int(ok), lat, err,
usage["input_tokens"], usage["output_tokens"],
usage["total_tokens"], usage["usage_json"]),
)
done += 1
if ok: