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
+69
View File
@@ -0,0 +1,69 @@
"""Реальный smoke: uvicorn + мок OmniRoute, проверка живых HTTP-запросов."""
import json
import os
import signal
import subprocess
import sys
import time
import urllib.request
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from conftest import OmniMock
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
TMP = os.path.join(REPO, ".smoke")
os.makedirs(TMP, exist_ok=True)
mock = OmniMock()
mock.set_completions(usage={"prompt_tokens": 9, "completion_tokens": 4, "total_tokens": 13})
env = dict(os.environ)
env.update({
"DB_PATH": os.path.join(TMP, "arena.db"),
"OMNIROUTE_URL": mock.url,
"OMNIROUTE_API_KEY": "smoke",
"ARENA_CACHE_TEXT": "/nonexistent/a.json",
"ARENA_CACHE_AGENT": "/nonexistent/b.json",
})
proc = subprocess.Popen(
[os.path.join(REPO, ".venv/bin/uvicorn"), "main:app", "--host", "127.0.0.1", "--port", "18700"],
cwd=os.path.join(REPO, "app"), env=env,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
)
# pip install uvicorn into venv if missing handled outside
try:
base = "http://127.0.0.1:18700"
for _ in range(50):
try:
urllib.request.urlopen(f"{base}/api/providers", timeout=1)
break
except Exception:
time.sleep(0.2)
# 1. UI отдаётся (статика через __file__-путь)
ui = urllib.request.urlopen(f"{base}/", timeout=5).read().decode()
assert "Arena Dashboard" in ui and "s-tokens-24h" in ui and "loadTokenStats" in ui, "UI не содержит token-элементов"
# 2. Ручная проверка с usage
chk = json.loads(urllib.request.urlopen(f"{base}/api/check/zai/test-model-a", timeout=10).read())
assert chk["input_tokens"] == 9 and chk["total_tokens"] == 13, chk
# 3. Агрегат
ts = json.loads(urllib.request.urlopen(f"{base}/api/token_stats", timeout=5).read())
assert ts["window_24h"]["known"] >= 1 and ts["window_24h"]["total_tokens"] >= 13, ts
print("SMOKE_OK", json.dumps({
"check_usage": [chk["input_tokens"], chk["output_tokens"], chk["total_tokens"]],
"token_stats_24h": ts["window_24h"],
"providers": [(p["provider"], p["window_24h"]["total_tokens"]) for p in ts["providers"]],
}, ensure_ascii=False))
finally:
proc.send_signal(signal.SIGTERM)
try:
out, _ = proc.communicate(timeout=10)
print("--- uvicorn log tail ---")
print("\n".join(out.splitlines()[-8:]))
except subprocess.TimeoutExpired:
proc.kill()
mock.close()