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
+43 -5
View File
@@ -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');