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
+77
View File
@@ -0,0 +1,77 @@
"""Миграция схемы checks: идемпотентность и сохранение старых NULL."""
import sqlite3
def _cols(db_path):
con = sqlite3.connect(db_path)
cols = [r[1] for r in con.execute("PRAGMA table_info(checks)").fetchall()]
con.close()
return cols
def _make_old_db(db_path, with_error_col=False):
"""БД со старой схемой checks (до учёта usage) и живыми данными."""
con = sqlite3.connect(db_path)
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)""")
if with_error_col:
con.execute("ALTER TABLE checks ADD COLUMN error TEXT")
con.execute(
"INSERT INTO checks (model_id, checked_at, ok, latency_ms, error) VALUES (?,?,?,?,?)"
if with_error_col else
"INSERT INTO checks (model_id, checked_at, ok, latency_ms) VALUES (?,?,?,?)",
("zai/old-model", "2026-01-01T00:00:00+00:00", 1, 123, None)
if with_error_col else ("zai/old-model", "2026-01-01T00:00:00+00:00", 1, 123),
)
con.commit()
con.close()
def test_migration_old_schema_preserves_null(fresh_main):
_make_old_db(fresh_main.DB_PATH, with_error_col=True)
fresh_main.init_db()
cols = _cols(fresh_main.DB_PATH)
for c in ("input_tokens", "output_tokens", "total_tokens", "usage_json"):
assert c in cols, f"колонка {c} не добавлена"
con = sqlite3.connect(fresh_main.DB_PATH)
con.row_factory = sqlite3.Row
row = con.execute("SELECT * FROM checks WHERE model_id='zai/old-model'").fetchone()
con.close()
assert row["ok"] == 1 and row["latency_ms"] == 123
# старые данные: неизвестное потребление = NULL, не нули
assert row["input_tokens"] is None
assert row["output_tokens"] is None
assert row["total_tokens"] is None
assert row["usage_json"] is None
def test_migration_from_ancient_schema_without_error(fresh_main):
_make_old_db(fresh_main.DB_PATH, with_error_col=False)
fresh_main.init_db() # не должен падать: добавит и error, и usage-колонки
cols = _cols(fresh_main.DB_PATH)
assert "error" in cols
assert "usage_json" in cols
def test_migration_idempotent(fresh_main):
_make_old_db(fresh_main.DB_PATH, with_error_col=True)
fresh_main.init_db()
fresh_main.init_db() # повторный запуск — no-op без ошибок
con = sqlite3.connect(fresh_main.DB_PATH)
n = con.execute("SELECT COUNT(*) FROM checks").fetchone()[0]
con.close()
assert n == 1 # данные не задвоились и не потерялись
def test_fresh_db_has_usage_columns(fresh_main):
fresh_main.init_db()
cols = _cols(fresh_main.DB_PATH)
for c in ("input_tokens", "output_tokens", "total_tokens", "usage_json"):
assert c in cols