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
+171
View File
@@ -0,0 +1,171 @@
"""Ручной путь (main.py /api/check) + API-агрегаты token usage."""
import json
import sqlite3
from datetime import datetime, timedelta, timezone
def _con(db_path):
con = sqlite3.connect(db_path)
con.row_factory = sqlite3.Row
return con
def _insert_check(db_path, model_id, at, ok=1, inp=None, out=None, tot=None, ujson=None):
con = _con(db_path)
con.execute(
"INSERT INTO checks (model_id, checked_at, ok, latency_ms, error, "
"input_tokens, output_tokens, total_tokens, usage_json) VALUES (?,?,?,?,?,?,?,?,?)",
(model_id, at, ok, 50, None, inp, out, tot, ujson),
)
con.commit()
con.close()
def _iso(**kw):
return (datetime.now(timezone.utc) + timedelta(**kw)).isoformat()
# ─── ручная проверка /api/check ───────────────────────────────────────────────
def test_api_check_captures_usage(core, omni_server):
omni_server.set_completions(usage={"prompt_tokens": 12, "completion_tokens": 3, "total_tokens": 15})
r = core.client.get("/api/check/zai/test-model-a")
assert r.status_code == 200
body = r.json()
assert body["ok"] is True
assert body["input_tokens"] == 12
assert body["output_tokens"] == 3
assert body["total_tokens"] == 15
row = _con(core.db_path).execute(
"SELECT * FROM checks WHERE model_id='zai/test-model-a'").fetchone()
assert row["input_tokens"] == 12 and row["total_tokens"] == 15
assert json.loads(row["usage_json"])["prompt_tokens"] == 12
def test_api_check_missing_usage_null(core, omni_server):
omni_server.set_completions(no_usage=True)
body = core.client.get("/api/check/zai/test-model-a").json()
assert body["ok"] is True
assert body["input_tokens"] is None and body["total_tokens"] is None
row = _con(core.db_path).execute(
"SELECT input_tokens, total_tokens, usage_json FROM checks WHERE model_id='zai/test-model-a'").fetchone()
assert row["input_tokens"] is None and row["total_tokens"] is None and row["usage_json"] is None
def test_api_check_failed_content_preserves_usage(core, omni_server):
omni_server.set_completions(usage={"prompt_tokens": 8, "completion_tokens": 1, "total_tokens": 9},
omit_choices=True)
body = core.client.get("/api/check/agy/test-model-b").json()
assert body["ok"] is False
assert body["input_tokens"] == 8 # usage пережил ошибку контента
row = _con(core.db_path).execute(
"SELECT ok, error, total_tokens FROM checks WHERE model_id='agy/test-model-b'").fetchone()
assert row["ok"] == 0 and row["error"]
assert row["total_tokens"] == 9
def test_api_check_no_response_stores_null(core, omni_server):
# недоступный OmniRoute: ответа нет — usage остаётся NULL (не нулями)
saved = core.main.OMNIROUTE_URL
core.main.OMNIROUTE_URL = "http://127.0.0.1:1" # connection refused, мгновенно
try:
body = core.client.get("/api/check/zai/test-model-a").json()
finally:
core.main.OMNIROUTE_URL = saved
assert body["ok"] is False
assert body["input_tokens"] is None and body["total_tokens"] is None
row = _con(core.db_path).execute(
"SELECT ok, error, total_tokens, usage_json FROM checks WHERE model_id='zai/test-model-a'").fetchone()
assert row["ok"] == 0 and row["error"]
assert row["total_tokens"] is None and row["usage_json"] is None
# ─── истории: usage в /api/checks и /api/models.history ──────────────────────
def test_api_checks_history_includes_usage(core, omni_server):
omni_server.set_completions(usage={"prompt_tokens": 5, "completion_tokens": 2, "total_tokens": 7})
core.client.get("/api/check/zai/test-model-a")
omni_server.set_completions(no_usage=True)
core.client.get("/api/check/zai/test-model-a")
data = core.client.get("/api/checks/zai/test-model-a").json()
assert len(data["checks"]) == 2
with_usage = [c for c in data["checks"] if c["total_tokens"] == 7]
without = [c for c in data["checks"] if c["total_tokens"] is None]
assert len(with_usage) == 1 and with_usage[0]["input_tokens"] == 5
assert with_usage[0]["usage_json"]
assert len(without) == 1 and without[0]["input_tokens"] is None
# сегментная история тоже несёт токены (для тултипов UI)
models = core.client.get("/api/models").json()
model = next(m for m in models if m["id"] == "zai/test-model-a")
toks = [h["total_tokens"] for h in model["history"]]
assert set(toks) == {7, None}
# ─── /api/token_stats ─────────────────────────────────────────────────────────
def test_token_stats_empty_db(core):
ts = core.client.get("/api/token_stats").json()
assert ts["window_24h"]["checks"] == 0
assert ts["window_24h"]["coverage_pct"] is None
assert ts["all_time"]["checks"] == 0
assert ts["providers"] == []
def test_token_stats_windows_coverage_providers(core):
# zai: 2 свежих с usage + 1 старая (>24h) с usage
_insert_check(core.db_path, "zai/test-model-a", _iso(minutes=-10), inp=10, out=5, tot=15)
_insert_check(core.db_path, "zai/test-model-a", _iso(minutes=-20), inp=20, out=2, tot=22)
_insert_check(core.db_path, "zai/test-model-a", _iso(hours=-30), inp=100, out=50, tot=150)
# agy: 1 свежая без usage + 1 старая с usage
_insert_check(core.db_path, "agy/test-model-b", _iso(minutes=-5))
_insert_check(core.db_path, "agy/test-model-b", _iso(hours=-40), inp=7, out=3, tot=10)
# модель, которой нет в таблице models: провайдер выводится из model_id
_insert_check(core.db_path, "orphan/x", _iso(minutes=-1), inp=5, out=5, tot=10)
ts = core.client.get("/api/token_stats").json()
w = ts["window_24h"]
assert w["checks"] == 4 and w["known"] == 3 and w["unknown"] == 1
assert w["coverage_pct"] == 75
assert (w["input_tokens"], w["output_tokens"], w["total_tokens"]) == (35, 12, 47)
a = ts["all_time"]
assert a["checks"] == 6 and a["known"] == 5 and a["unknown"] == 1
assert (a["input_tokens"], a["output_tokens"], a["total_tokens"]) == (142, 65, 207)
provs = {p["provider"]: p for p in ts["providers"]}
assert set(provs) == {"zai", "agy", "orphan"}
assert provs["zai"]["window_24h"]["total_tokens"] == 37
assert provs["zai"]["all_time"]["total_tokens"] == 187
assert provs["agy"]["window_24h"]["known"] == 0
assert provs["agy"]["window_24h"]["unknown"] == 1
assert provs["agy"]["window_24h"]["total_tokens"] is None # сумма по NULL-группе
assert provs["agy"]["all_time"]["total_tokens"] == 10
assert provs["orphan"]["window_24h"]["total_tokens"] == 10 # fallback из model_id
def test_token_stats_counts_both_paths(core, omni_server):
"""probe.run() и ручной /api/check пишут usage, агрегат считает оба пути."""
omni_server.set_completions(usage={"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15})
core.probe.run() # путь cron: zai 15 + agy 15
core.client.get("/api/check/zai/test-model-a") # путь ручной: +15
omni_server.set_completions(no_usage=True)
core.client.get("/api/check/agy/test-model-b") # путь ручной без usage: NULL
ts = core.client.get("/api/token_stats").json()
w = ts["window_24h"]
assert w["checks"] == 4 # 2 probe + 2 ручных
assert w["known"] == 3 # probe×2 + ручной zai
assert w["unknown"] == 1 # ручной agy без usage
assert w["total_tokens"] == 45 # 15 × 3 известных
assert w["input_tokens"] == 30 and w["output_tokens"] == 15
provs = {p["provider"]: p for p in ts["providers"]}
assert provs["zai"]["window_24h"]["total_tokens"] == 30 # probe + manual
assert provs["agy"]["window_24h"]["total_tokens"] == 15 # только probe