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:
+146
-9
@@ -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")
|
||||
|
||||
|
||||
+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:
|
||||
|
||||
+43
-5
@@ -245,6 +245,8 @@
|
||||
<div class="stat"><div class="val" id="s-ranked">—</div><div class="lbl">В рейтинге</div></div>
|
||||
<div class="stat"><div class="val" id="s-alive">—</div><div class="lbl">Живых</div></div>
|
||||
<div class="stat"><div class="val" id="s-checked">—</div><div class="lbl">Проверено сегодня</div></div>
|
||||
<div class="stat"><div class="val" id="s-tokens-24h">—</div><div class="lbl">Токены 24ч</div></div>
|
||||
<div class="stat"><div class="val" id="s-tokens-cover">—</div><div class="lbl">Покрытие usage</div></div>
|
||||
</div>
|
||||
|
||||
<div class="search-wrap">
|
||||
@@ -351,9 +353,10 @@ function showSegTip(el) {
|
||||
const tip = segTip();
|
||||
const ok = data.ok;
|
||||
const lat = data.latency_ms !== null && data.latency_ms !== undefined ? `${data.latency_ms} мс` : '—';
|
||||
const toks = usageStr(data);
|
||||
tip.innerHTML = `
|
||||
<div class="tip-head ${ok ? 'tip-ok' : 'tip-fail'}">${ok ? '✓ Успех' : '✗ Провал'}</div>
|
||||
<div class="tip-meta">${fmtTime(data.at)} · ${lat}</div>
|
||||
<div class="tip-meta">${fmtTime(data.at)} · ${lat}${toks ? ` · ${escapeHtml(toks)}` : ''}</div>
|
||||
${data.error ? `<div class="tip-err">${escapeHtml(data.error)}</div>` : ''}
|
||||
`;
|
||||
tip.style.display = 'block';
|
||||
@@ -413,6 +416,37 @@ function renderRow(m) {
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function fmtTokens(n) {
|
||||
if (n === null || n === undefined) return null;
|
||||
if (n >= 1e9) return (n / 1e9).toFixed(1) + 'B';
|
||||
if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
|
||||
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k';
|
||||
return String(n);
|
||||
}
|
||||
|
||||
// Строка токенов для тултипа/лога: "in 12 → out 5" или null если usage неизвестен.
|
||||
function usageStr(u) {
|
||||
if (!u || u.total_tokens === null || u.total_tokens === undefined) return null;
|
||||
const inp = u.input_tokens !== null && u.input_tokens !== undefined ? u.input_tokens : '?';
|
||||
const out = u.output_tokens !== null && u.output_tokens !== undefined ? u.output_tokens : '?';
|
||||
return `in ${inp} → out ${out} (${u.total_tokens})`;
|
||||
}
|
||||
|
||||
async function loadTokenStats() {
|
||||
try {
|
||||
const res = await fetch('/api/token_stats');
|
||||
const ts = await res.json();
|
||||
const w = ts.window_24h || {};
|
||||
const t24 = document.getElementById('s-tokens-24h');
|
||||
const cov = document.getElementById('s-tokens-cover');
|
||||
const tot = fmtTokens(w.total_tokens);
|
||||
t24.textContent = tot === null ? '—' : tot;
|
||||
t24.title = `in ${fmtTokens(w.input_tokens) ?? '—'} / out ${fmtTokens(w.output_tokens) ?? '—'} · всё время: ${fmtTokens((ts.all_time || {}).total_tokens) ?? '—'}`;
|
||||
cov.textContent = w.coverage_pct === null || w.coverage_pct === undefined ? '—' : w.coverage_pct + '%';
|
||||
cov.title = `24ч: известно ${w.known ?? 0} из ${w.checks ?? 0} проверок (неизвестно ${w.unknown ?? 0})`;
|
||||
} catch (e) { /* статы токенов не критичны */ }
|
||||
}
|
||||
|
||||
function updateStats(data) {
|
||||
document.getElementById('s-total').textContent = data.length;
|
||||
document.getElementById('s-ranked').textContent = data.filter(m => m.arena_text_score).length;
|
||||
@@ -498,6 +532,7 @@ async function loadData() {
|
||||
document.getElementById('last-updated').textContent = `Обновлено: ${now.toLocaleTimeString('ru')}`;
|
||||
|
||||
applyFilters();
|
||||
loadTokenStats();
|
||||
}
|
||||
|
||||
async function loadProviders() {
|
||||
@@ -527,6 +562,7 @@ async function checkOne(modelId, btn) {
|
||||
if (data.uptime_pct !== undefined) allData[idx].uptime_pct = data.uptime_pct;
|
||||
}
|
||||
applyFilters();
|
||||
loadTokenStats();
|
||||
} finally {
|
||||
btn.textContent = '▶ Тест';
|
||||
btn.classList.remove('checking');
|
||||
@@ -562,15 +598,17 @@ async function openCheckHistory(modelId, btn) {
|
||||
if (!checks.length) {
|
||||
body.innerHTML = '<div class="no-data" style="text-align:center;padding:20px;">Нет данных о проверках</div>';
|
||||
} else {
|
||||
body.innerHTML = checks.map(c => `
|
||||
body.innerHTML = checks.map(c => {
|
||||
const toks = usageStr(c);
|
||||
return `
|
||||
<div class="check-log-item">
|
||||
<div class="log-line1">
|
||||
<span class="log-status ${c.ok ? 'ok' : 'fail'}">${c.ok ? '✓ Успех' : '✗ Провал'}</span>
|
||||
<span class="log-meta">${fmtTime(c.checked_at)} · ${c.latency_ms !== null && c.latency_ms !== undefined ? c.latency_ms + ' мс' : '—'}</span>
|
||||
<span class="log-meta">${fmtTime(c.checked_at)} · ${c.latency_ms !== null && c.latency_ms !== undefined ? c.latency_ms + ' мс' : '—'}${toks ? ` · ${escapeHtml(toks)} т.` : ''}</span>
|
||||
</div>
|
||||
${c.error ? `<div class="log-err">${escapeHtml(c.error)}</div>` : ''}
|
||||
</div>
|
||||
`).join('');
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
modal.classList.add('open');
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""
|
||||
Общий модуль извлечения token usage из ответа OmniRoute.
|
||||
|
||||
Используется и cron-пробой (probe.py), и ручной проверкой (main.py).
|
||||
Правила:
|
||||
* usage извлекается ДО валидации контента — чтобы провал content-парсинга
|
||||
не терял реально потраченные токены;
|
||||
* если провайдер не отдал usage (или отдал полностью нулевой) — все поля
|
||||
остаются NULL: неизвестное потребление не подменяется нулями;
|
||||
* usage_json хранит сырой словарь usage как пришел от провайдера.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
# Возможные имена полей в разных диалектах (OpenAI / Anthropic / camelCase).
|
||||
_INPUT_KEYS = ("prompt_tokens", "input_tokens", "promptTokens", "inputTokens")
|
||||
_OUTPUT_KEYS = ("completion_tokens", "output_tokens", "completionTokens", "outputTokens")
|
||||
_TOTAL_KEYS = ("total_tokens", "totalTokens")
|
||||
|
||||
|
||||
def _first_int(usage: dict, keys: tuple[str, ...]) -> Optional[int]:
|
||||
"""Первое числовое значение из ключей; не-числа и None игнорируются."""
|
||||
for k in keys:
|
||||
v = usage.get(k)
|
||||
if isinstance(v, bool): # bool — подкласс int, отсекаем явно
|
||||
continue
|
||||
if isinstance(v, (int, float)) and v >= 0:
|
||||
return int(v)
|
||||
return None
|
||||
|
||||
|
||||
def extract_usage(resp) -> dict:
|
||||
"""Достать usage из тела ответа chat/completions.
|
||||
|
||||
Возвращает dict с ключами input_tokens / output_tokens / total_tokens /
|
||||
usage_json. Отсутствующий или вырожденный (полностью нулевой) usage дает
|
||||
None во всех полях — NULL, а не нулевая оценка.
|
||||
"""
|
||||
empty = {"input_tokens": None, "output_tokens": None,
|
||||
"total_tokens": None, "usage_json": None}
|
||||
|
||||
usage = None
|
||||
if isinstance(resp, dict):
|
||||
u = resp.get("usage")
|
||||
if isinstance(u, dict):
|
||||
usage = u
|
||||
elif isinstance(u, str):
|
||||
# редко: usage пришел строкой-JSON
|
||||
try:
|
||||
parsed = json.loads(u)
|
||||
if isinstance(parsed, dict):
|
||||
usage = parsed
|
||||
except (ValueError, TypeError):
|
||||
usage = None
|
||||
if not isinstance(usage, dict):
|
||||
return empty
|
||||
|
||||
inp = _first_int(usage, _INPUT_KEYS)
|
||||
out = _first_int(usage, _OUTPUT_KEYS)
|
||||
tot = _first_int(usage, _TOTAL_KEYS)
|
||||
|
||||
# Полностью нулевой usage (0/0/0) трактуем как «не отдал» — провайдеры
|
||||
# так отвечают в ошибочных путях; реальные токены всегда > 0.
|
||||
if not any(v for v in (inp, out, tot) if v is not None):
|
||||
return empty
|
||||
|
||||
# total — точная арифметика по отчетным значениям, не оценка.
|
||||
if tot is None and inp is not None and out is not None:
|
||||
tot = inp + out
|
||||
|
||||
try:
|
||||
usage_json = json.dumps(usage, ensure_ascii=False, sort_keys=True)
|
||||
except (TypeError, ValueError):
|
||||
usage_json = None
|
||||
|
||||
return {
|
||||
"input_tokens": inp,
|
||||
"output_tokens": out,
|
||||
"total_tokens": tot,
|
||||
"usage_json": usage_json,
|
||||
}
|
||||
Reference in New Issue
Block a user