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
+146 -9
View File
@@ -17,6 +17,8 @@ from fastapi import FastAPI, Query
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
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", "")
@@ -25,6 +27,10 @@ ARENA_CACHE_AGENT = os.environ.get("ARENA_CACHE_AGENT", "/data/arena_agent.json"
# Сколько последних проверок показывать сегментами в колонке Uptime.
HISTORY_LIMIT = int(os.environ.get("HISTORY_LIMIT", "40"))
# Статика — относительно этого файла: работает и в контейнере (/app/app),
# и при локальном запуске/тестах из чекаута репозитория.
STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
# ─── DB setup ───────────────────────────────────────────────────────────────
@@ -98,7 +104,11 @@ def init_db():
model_id TEXT NOT NULL,
checked_at TEXT NOT NULL,
ok INTEGER NOT NULL,
latency_ms INTEGER
latency_ms INTEGER,
input_tokens INTEGER,
output_tokens INTEGER,
total_tokens INTEGER,
usage_json TEXT
);
CREATE INDEX IF NOT EXISTS idx_checks_model ON checks(model_id);
@@ -112,6 +122,17 @@ def init_db():
if "error" not in cols:
db.execute("ALTER TABLE checks ADD COLUMN error TEXT")
# Идемпотентная миграция: учёт token usage. Старые строки остаются NULL
# (неизвестное потребление не подменяется нулями), повторный запуск — no-op.
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()
db.close()
@@ -360,7 +381,7 @@ def model_stats(db: sqlite3.Connection, model_id: str, hours: int = 24) -> dict:
).fetchone()
rows = db.execute(
"SELECT ok, latency_ms, checked_at, error FROM checks "
"SELECT ok, latency_ms, checked_at, error, input_tokens, output_tokens, total_tokens FROM checks "
"WHERE model_id=? AND checked_at > ? ORDER BY checked_at ASC",
(model_id, cutoff)
).fetchall()
@@ -370,13 +391,16 @@ def model_stats(db: sqlite3.Connection, model_id: str, hours: int = 24) -> dict:
pct = round(100 * success / total) if total else None
# История последних проверок (старые → новые) для сегментной витрины.
# Каждый элемент — детали проверки: ok, latency, время, текст ошибки.
# Каждый элемент — детали проверки: ok, latency, время, текст ошибки, токены.
history = [
{
"ok": bool(r["ok"]),
"latency_ms": r["latency_ms"],
"at": r["checked_at"],
"error": r["error"],
"input_tokens": r["input_tokens"],
"output_tokens": r["output_tokens"],
"total_tokens": r["total_tokens"],
}
for r in rows[-HISTORY_LIMIT:]
]
@@ -391,6 +415,93 @@ def model_stats(db: sqlite3.Connection, model_id: str, hours: int = 24) -> dict:
}
# ─── Token usage aggregates ───────────────────────────────────────────────────
def _token_agg_rows(db: sqlite3.Connection, where: str, params: list) -> list[dict]:
"""GROUP BY провайдер по checks с суммами токенов и покрытием known/unknown."""
# Провайдер — из таблицы models; если модели уже нет, выводим из model_id.
prov_expr = (
"COALESCE(m.provider, "
"CASE WHEN instr(c.model_id, '/') > 0 "
"THEN substr(c.model_id, 1, instr(c.model_id, '/') - 1) "
"ELSE 'unknown' END)"
)
return [
{
"provider": r["provider"],
"checks": r["checks"],
"known": r["known"],
"unknown": r["unknown"],
"coverage_pct": round(100 * r["known"] / r["checks"]) if r["checks"] else None,
"input_tokens": r["input_tokens"],
"output_tokens": r["output_tokens"],
"total_tokens": r["total_tokens"],
}
for r in db.execute(
f"""
SELECT {prov_expr} AS provider,
COUNT(*) AS checks,
SUM(CASE WHEN c.total_tokens IS NOT NULL THEN 1 ELSE 0 END) AS known,
SUM(CASE WHEN c.total_tokens IS NULL THEN 1 ELSE 0 END) AS unknown,
SUM(c.input_tokens) AS input_tokens,
SUM(c.output_tokens) AS output_tokens,
SUM(c.total_tokens) AS total_tokens
FROM checks c
LEFT JOIN models m ON c.model_id = m.id
WHERE {where}
GROUP BY provider
ORDER BY total_tokens DESC
""",
params,
).fetchall()
]
def _merge_aggs(rows: list[dict]) -> dict:
"""Свернуть группы провайдеров в одну сводку."""
checks = sum(r["checks"] for r in rows)
known = sum(r["known"] for r in rows)
return {
"checks": checks,
"known": known,
"unknown": checks - known,
"coverage_pct": round(100 * known / checks) if checks else None,
"input_tokens": sum(r["input_tokens"] or 0 for r in rows),
"output_tokens": sum(r["output_tokens"] or 0 for r in rows),
"total_tokens": sum(r["total_tokens"] or 0 for r in rows),
}
def token_stats(db: sqlite3.Connection) -> dict:
"""Сводка token usage: скользящие 24h и всё время + разбивка по провайдерам."""
cutoff24 = datetime.fromtimestamp(time.time() - 24 * 3600, timezone.utc).isoformat()
now = datetime.now(timezone.utc).isoformat()
prov24 = _token_agg_rows(db, "c.checked_at > ?", [cutoff24])
prov_all = _token_agg_rows(db, "1=1", [])
all_map = {r["provider"]: r for r in prov_all}
return {
"generated_at": now,
"window_24h": _merge_aggs(prov24),
"all_time": _merge_aggs(prov_all),
"providers": [
{
"provider": r["provider"],
"window_24h": r,
"all_time": all_map.get(r["provider"], _merge_aggs([])),
}
for r in prov24
] + [
{
"provider": r["provider"],
"window_24h": _merge_aggs([]),
"all_time": r,
}
for r in prov_all if r["provider"] not in {p["provider"] for p in prov24}
],
}
# ─── Lifespan ────────────────────────────────────────────────────────────────
@asynccontextmanager
@@ -493,6 +604,8 @@ def api_check(model_id: str):
ok = False
latency = None
error = None
usage = {"input_tokens": None, "output_tokens": None,
"total_tokens": None, "usage_json": None}
try:
body = json.dumps({
"model": model_id,
@@ -509,6 +622,9 @@ def api_check(model_id: str):
)
with urllib.request.urlopen(req, timeout=30) as r:
resp = json.loads(r.read())
# Usage фиксируем ДО валидации контента: даже если парсинг choices
# упадёт, реально потраченные токены сохраняются в checks.
usage = extract_usage(resp)
content = resp["choices"][0]["message"]["content"]
ok = bool(content.strip())
except Exception as e:
@@ -520,8 +636,12 @@ def api_check(model_id: str):
now = datetime.now(timezone.utc).isoformat()
db = get_db()
db.execute(
"INSERT INTO checks (model_id, checked_at, ok, latency_ms, error) VALUES (?,?,?,?,?)",
(model_id, now, int(ok), latency, error),
"INSERT INTO checks (model_id, checked_at, ok, latency_ms, error, "
"input_tokens, output_tokens, total_tokens, usage_json) "
"VALUES (?,?,?,?,?,?,?,?,?)",
(model_id, now, int(ok), latency, error,
usage["input_tokens"], usage["output_tokens"],
usage["total_tokens"], usage["usage_json"]),
)
db.commit()
stats = model_stats(db, model_id)
@@ -531,6 +651,9 @@ def api_check(model_id: str):
"ok": ok,
"latency_ms": latency,
"checked_at": now,
"input_tokens": usage["input_tokens"],
"output_tokens": usage["output_tokens"],
"total_tokens": usage["total_tokens"],
"history": stats["history"],
"uptime_pct": stats["uptime_pct"],
}
@@ -542,8 +665,8 @@ def api_checks(model_id: str):
limit = int(os.environ.get("CHECKS_LIMIT", "100"))
db = get_db()
rows = db.execute(
"SELECT ok, latency_ms, checked_at, error FROM checks "
"WHERE model_id=? ORDER BY checked_at DESC LIMIT ?",
"SELECT ok, latency_ms, checked_at, error, input_tokens, output_tokens, total_tokens, usage_json "
"FROM checks WHERE model_id=? ORDER BY checked_at DESC LIMIT ?",
(model_id, limit),
).fetchall()
db.close()
@@ -555,17 +678,31 @@ def api_checks(model_id: str):
"latency_ms": r["latency_ms"],
"checked_at": r["checked_at"],
"error": r["error"],
"input_tokens": r["input_tokens"],
"output_tokens": r["output_tokens"],
"total_tokens": r["total_tokens"],
"usage_json": r["usage_json"],
}
for r in rows
],
}
@app.get("/api/token_stats")
def api_token_stats():
"""Агрегат token usage: скользящие 24h и всё время, покрытие known/unknown,
разбивка по провайдерам."""
db = get_db()
result = token_stats(db)
db.close()
return JSONResponse(result)
@app.get("/", response_class=HTMLResponse)
def index():
with open("/app/app/static/index.html") as f:
with open(os.path.join(STATIC_DIR, "index.html")) as f:
return f.read()
app.mount("/static", StaticFiles(directory="/app/app/static"), name="static")
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")