feat: учёт token usage в checks с агрегатами и UI
Build and deploy / build-deploy (push) Has been cancelled
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:
+42
-7
@@ -12,6 +12,8 @@ import time
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
|
||||
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", "")
|
||||
@@ -19,9 +21,17 @@ TIMEOUT = int(os.environ.get("CHECK_TIMEOUT", "30"))
|
||||
CONCURRENCY = int(os.environ.get("CHECK_CONCURRENCY", "5"))
|
||||
|
||||
|
||||
def check_model(model_id: str) -> tuple[bool, int | None, str | None]:
|
||||
def check_model(model_id: str) -> tuple[bool, int | None, str | None, dict]:
|
||||
"""Возвращает (ok, latency_ms, error, usage).
|
||||
|
||||
usage извлекается ДО валидации контента: если парсинг choices падает,
|
||||
потраченные токены всё равно сохраняются. Таймаут/ошибка сети без тела
|
||||
ответа дают usage=None по всем полям (NULL, а не нули).
|
||||
"""
|
||||
t0 = time.time()
|
||||
error = None
|
||||
usage = {"input_tokens": None, "output_tokens": None,
|
||||
"total_tokens": None, "usage_json": None}
|
||||
try:
|
||||
body = json.dumps({
|
||||
"model": model_id,
|
||||
@@ -38,6 +48,8 @@ def check_model(model_id: str) -> tuple[bool, int | None, str | None]:
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
|
||||
resp = json.loads(r.read())
|
||||
# Usage — до любой валидации контента.
|
||||
usage = extract_usage(resp)
|
||||
content = resp["choices"][0]["message"]["content"]
|
||||
ok = bool(content.strip())
|
||||
except Exception as e:
|
||||
@@ -45,11 +57,30 @@ def check_model(model_id: str) -> tuple[bool, int | None, str | None]:
|
||||
print(f" ✗ {model_id}: {error}", file=sys.stderr)
|
||||
ok = False
|
||||
latency = int((time.time() - t0) * 1000)
|
||||
return ok, latency, error
|
||||
return ok, latency, error, usage
|
||||
|
||||
|
||||
def _ensure_usage_columns(db: sqlite3.Connection):
|
||||
"""Идемпотентно добавить колонки token usage, если БД ещё со старой схемой.
|
||||
|
||||
Cron стартует раньше uvicorn, поэтому probe не может полагаться на то,
|
||||
что init_db из main.py уже прогнал миграцию.
|
||||
"""
|
||||
cols = [r[1] for r in db.execute("PRAGMA table_info(checks)").fetchall()]
|
||||
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()
|
||||
|
||||
|
||||
def run():
|
||||
db = sqlite3.connect(DB_PATH, check_same_thread=False)
|
||||
_ensure_usage_columns(db)
|
||||
models = [r[0] for r in db.execute("SELECT id FROM models").fetchall()]
|
||||
db.close()
|
||||
|
||||
@@ -58,8 +89,8 @@ def run():
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
def do(mid):
|
||||
ok, lat, err = check_model(mid)
|
||||
return mid, ok, lat, err
|
||||
ok, lat, err, usage = check_model(mid)
|
||||
return mid, ok, lat, err, usage
|
||||
|
||||
db = sqlite3.connect(DB_PATH, check_same_thread=False)
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
@@ -68,10 +99,14 @@ def run():
|
||||
futs = {ex.submit(do, m): m for m in models}
|
||||
done = ok_count = 0
|
||||
for f in as_completed(futs):
|
||||
mid, ok, lat, err = f.result()
|
||||
mid, ok, lat, err, usage = f.result()
|
||||
db.execute(
|
||||
"INSERT INTO checks (model_id, checked_at, ok, latency_ms, error) VALUES (?,?,?,?,?)",
|
||||
(mid, now, int(ok), lat, err),
|
||||
"INSERT INTO checks (model_id, checked_at, ok, latency_ms, error, "
|
||||
"input_tokens, output_tokens, total_tokens, usage_json) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?)",
|
||||
(mid, now, int(ok), lat, err,
|
||||
usage["input_tokens"], usage["output_tokens"],
|
||||
usage["total_tokens"], usage["usage_json"]),
|
||||
)
|
||||
done += 1
|
||||
if ok:
|
||||
|
||||
Reference in New Issue
Block a user