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:
+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');
|
||||
|
||||
Reference in New Issue
Block a user