Files
arena-dashboard/tests/test_probe.py
T
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

123 lines
5.6 KiB
Python

"""Путь cron-пробы (probe.py): захват usage, NULL при неизвестном, сохранение при ошибках."""
import json
import sqlite3
def _rows(db_path):
con = sqlite3.connect(db_path)
con.row_factory = sqlite3.Row
rows = con.execute("SELECT * FROM checks ORDER BY id").fetchall()
out = [dict(r) for r in rows]
con.close()
return out
def test_probe_captures_usage(core, omni_server):
omni_server.set_completions(usage={"prompt_tokens": 11, "completion_tokens": 4, "total_tokens": 15},
no_usage=False)
ok, lat, err, usage = core.probe.check_model("zai/test-model-a")
assert ok is True and err is None
assert usage["input_tokens"] == 11
assert usage["output_tokens"] == 4
assert usage["total_tokens"] == 15
assert json.loads(usage["usage_json"])["prompt_tokens"] == 11
def test_probe_missing_usage_stays_null(core, omni_server):
omni_server.set_completions(no_usage=True)
ok, lat, err, usage = core.probe.check_model("zai/test-model-a")
assert ok is True # контент есть — проверка успешна
# но usage неизвестен: NULL, не нули
assert usage == {"input_tokens": None, "output_tokens": None,
"total_tokens": None, "usage_json": None}
def test_probe_zero_usage_treated_as_unknown(core, omni_server):
omni_server.set_completions(usage={"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
no_usage=False)
_, _, _, usage = core.probe.check_model("agy/test-model-b")
assert usage["total_tokens"] is None and usage["input_tokens"] is None
def test_probe_failed_content_preserves_usage(core, omni_server):
# ответ без choices: парсинг контента упадёт, но usage уже зафиксирован
omni_server.set_completions(usage={"prompt_tokens": 9, "completion_tokens": 2, "total_tokens": 11},
omit_choices=True)
ok, lat, err, usage = core.probe.check_model("zai/test-model-a")
assert ok is False
assert err and "KeyError" in err
assert usage["input_tokens"] == 9 # токены не потеряны
assert usage["total_tokens"] == 11
def test_probe_timeout_usage_null(core, omni_server):
omni_server.set_completions(delay=6) # CHECK_TIMEOUT=2 в тестовом env
ok, lat, err, usage = core.probe.check_model("zai/test-model-a")
assert ok is False
assert err and ("timed out" in err.lower() or "timeout" in err.lower())
assert usage["total_tokens"] is None
assert usage["usage_json"] is None
assert lat and lat >= 2000 # latency фиксируется всегда
omni_server.set_completions(delay=0)
def test_probe_http_error_usage_null(core, omni_server):
omni_server.set_completions(status=500)
ok, lat, err, usage = core.probe.check_model("agy/test-model-b")
assert ok is False and err
assert usage["input_tokens"] is None
def test_probe_run_end_to_end_both_models(core, omni_server):
"""Полный прогон run(): разные модели — разный исход по usage."""
omni_server.set_completions(usage={"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15})
omni_server.set_per_model("agy/test-model-b", {"no_usage": True})
omni_server.set_per_model("zai/test-model-a",
{"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}})
core.probe.run()
rows = {r["model_id"]: r for r in _rows(core.db_path)}
assert set(rows) == {"zai/test-model-a", "agy/test-model-b"}
a = rows["zai/test-model-a"]
assert a["ok"] == 1 and (a["input_tokens"], a["output_tokens"], a["total_tokens"]) == (10, 5, 15)
assert json.loads(a["usage_json"])["total_tokens"] == 15
b = rows["agy/test-model-b"]
assert b["ok"] == 1 # без usage проверка всё равно успешна
assert b["input_tokens"] is None and b["total_tokens"] is None and b["usage_json"] is None
def test_probe_run_migrates_old_schema_standalone(tmp_path, omni_server):
"""Cron стартует раньше uvicorn: probe должен сам уметь мигрировать старую БД."""
from conftest import _set_env, restore_env, MODULES
import sys as _sys
db = str(tmp_path / "probe-only.db")
con = sqlite3.connect(db)
con.execute("CREATE TABLE checks (id INTEGER PRIMARY KEY AUTOINCREMENT, model_id TEXT NOT NULL, "
"checked_at TEXT NOT NULL, ok INTEGER NOT NULL, latency_ms INTEGER, error TEXT)")
con.execute("CREATE TABLE models (id TEXT PRIMARY KEY, provider TEXT NOT NULL, name TEXT NOT NULL, "
"raw_base TEXT NOT NULL, norm_base TEXT NOT NULL, clean_base TEXT NOT NULL, "
"text_model_match TEXT, agent_model_match TEXT, updated_at TEXT)")
con.execute("INSERT INTO models (id, provider, name, raw_base, norm_base, clean_base) "
"VALUES ('zai/test-model-a', 'zai', 'zai/test-model-a', 'test-model-a', 'testmodela', 'testmodela')")
con.commit()
con.close()
_set_env(db, omni_server.url)
try:
for m in MODULES:
_sys.modules.pop(m, None)
import probe as standalone_probe
omni_server.set_per_model("zai/test-model-a",
{"usage": {"input_tokens": 6, "output_tokens": 2}})
standalone_probe.run()
finally:
restore_env()
rows = _rows(db)
assert len(rows) == 1
assert rows[0]["input_tokens"] == 6
assert rows[0]["output_tokens"] == 2
assert rows[0]["total_tokens"] == 8 # вычислен из отчётных значений