feat: Cost FinOps Strip on Overview and Chat (fixes #223)

Add STITCH-013 ambient spend awareness reusing existing usage, config
and context-stats APIs. New _helpers.js FinOps utilities (MiniDonutSVG,
budget math, CostStrip/BudgetBanner markup); Overview CostStrip +
BudgetBanner with graceful degradation; Chat SessionCostChip + budget
banner updating on run_completed; FinOps config schema section with
cross-field validation; finops.* i18n keys (en/es/fr); unit tests.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
This commit is contained in:
MervinPraisonandClaude authored and praisonai-triage-agent[bot] committed 2026-07-15 15:25:36 +00:00
1 parent c3e330454c
commit c9985de128
6 files changed
+717 -2

No files matched your search

@@ -176,6 +176,49 @@ CONFIG_SCHEMA = {
},
},
},
"finops": {
"type": "object",
"title": "FinOps & Budgets",
"description": (
"Alerts appear on Overview and Chat. Does not block agent runs."
),
"properties": {
"enabled": {
"type": "boolean",
"title": "Enable FinOps",
"default": True,
},
"daily_token_budget": {
"type": "integer",
"title": "Daily Token Budget",
"minimum": 0,
},
"daily_cost_budget_usd": {
"type": "number",
"title": "Daily Cost Budget (USD)",
"minimum": 0,
},
"warn_pct": {
"type": "integer",
"title": "Warn Threshold (%)",
"minimum": 1,
"maximum": 99,
"default": 80,
},
"critical_pct": {
"type": "integer",
"title": "Critical Threshold (%)",
"minimum": 1,
"maximum": 100,
"default": 95,
},
"show_session_chip": {
"type": "boolean",
"title": "Show Session Cost Chip",
"default": True,
},
},
},
},
}
@@ -216,6 +259,24 @@ def validate_config(config: Dict[str, Any], schema: Dict[str, Any] = None) -> Li
if "maximum" in prop_schema and value > prop_schema["maximum"]:
errors.append(f"{key}: must be <= {prop_schema['maximum']}")
errors.extend(_validate_finops(config.get("finops")))
return errors
def _validate_finops(finops: Any) -> List[str]:
"""Cross-field validation for the FinOps section (E4/E21)."""
if not isinstance(finops, dict):
return []
errors: List[str] = []
warn = finops.get("warn_pct", 80)
critical = finops.get("critical_pct", 95)
if isinstance(warn, (int, float)) and isinstance(critical, (int, float)):
if critical <= warn:
errors.append("finops.critical_pct: must be greater than warn_pct")
for field in ("daily_token_budget", "daily_cost_budget_usd"):
val = finops.get(field)
if isinstance(val, (int, float)) and val < 0:
errors.append(f"finops.{field}: must be >= 0")
return errors
+18
View File
@@ -92,6 +92,12 @@ class JSONLocaleManager(I18nProtocol):
"skills.action.approve": "Approve",
"skills.action.reject": "Reject",
"skills.pending.empty": "No pending skill writes",
"finops.strip.title": "Cost FinOps",
"finops.strip.today": "Today: {tokens} tokens",
"finops.strip.view_usage": "View usage",
"finops.banner.warn": "Daily token budget at {pct}% \u2014 consider lighter models",
"finops.banner.critical": "Daily token budget at {pct}% \u2014 review usage",
"finops.chip.label": "{tokens} \u00b7 {model}",
"nav.dashboard": "Dashboard",
"nav.agents": "Agents",
"nav.settings": "Settings",
@@ -124,6 +130,12 @@ class JSONLocaleManager(I18nProtocol):
"skills.action.approve": "Aprobar",
"skills.action.reject": "Rechazar",
"skills.pending.empty": "No hay escrituras de habilidades pendientes",
"finops.strip.title": "FinOps de costos",
"finops.strip.today": "Hoy: {tokens} tokens",
"finops.strip.view_usage": "Ver uso",
"finops.banner.warn": "Presupuesto diario de tokens al {pct}% \u2014 considere modelos más ligeros",
"finops.banner.critical": "Presupuesto diario de tokens al {pct}% \u2014 revise el uso",
"finops.chip.label": "{tokens} \u00b7 {model}",
"nav.dashboard": "Panel",
"nav.agents": "Agentes",
"nav.settings": "Configuración",
@@ -156,6 +168,12 @@ class JSONLocaleManager(I18nProtocol):
"skills.action.approve": "Approuver",
"skills.action.reject": "Rejeter",
"skills.pending.empty": "Aucune \u00e9criture de comp\u00e9tence en attente",
"finops.strip.title": "FinOps des coûts",
"finops.strip.today": "Aujourd'hui : {tokens} tokens",
"finops.strip.view_usage": "Voir l'utilisation",
"finops.banner.warn": "Budget quotidien de tokens à {pct}% \u2014 envisagez des modèles plus légers",
"finops.banner.critical": "Budget quotidien de tokens à {pct}% \u2014 vérifiez l'utilisation",
"finops.chip.label": "{tokens} \u00b7 {model}",
"nav.dashboard": "Tableau de bord",
"nav.agents": "Agents",
"nav.settings": "Paramètres",
@@ -237,6 +237,206 @@ export function timeAgo(input) {
return `${Math.floor(h / 24)}d ago`;
}
/** Format a token count into a compact string (mirrors usage.js). */
export function formatTokens(tokens) {
const n = Number(tokens) || 0;
if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`;
if (n >= 1000) return `${(n / 1000).toFixed(1)}K`;
return String(Math.round(n));
}
/** Format a USD cost into a string (mirrors usage.js). */
export function formatCost(cost) {
const n = Number(cost) || 0;
if (n < 0.01) return `$${n.toFixed(4)}`;
if (n < 1) return `$${n.toFixed(3)}`;
return `$${n.toFixed(2)}`;
}
/** Strip a provider prefix from a model id (anthropic/claude-3 → claude-3). */
export function shortModelName(model) {
const s = model == null ? '' : String(model);
const idx = s.lastIndexOf('/');
return idx >= 0 ? s.slice(idx + 1) : s;
}
/**
* Sum tokens from timeseries buckets belonging to the current local day.
* Falls back to summing all buckets when no bucket carries a hour_key.
*/
export function sumTodayTokens(timeseries) {
const arr = Array.isArray(timeseries) ? timeseries : [];
const todayKey = new Date().toISOString().slice(0, 10);
const dated = arr.filter((p) => typeof p.hour_key === 'string');
const scope = dated.length ? dated.filter((p) => p.hour_key.startsWith(todayKey)) : arr;
return scope.reduce((sum, p) => sum + (Number(p.tokens) || 0), 0);
}
/**
* Percent of daily budget consumed, capped at 100.
* Returns null when the budget is unset or non-positive (E3/E4).
*/
export function computeBudgetPct(today, budget) {
const b = Number(budget);
if (!b || b <= 0) return null;
const pct = (Number(today) || 0) / b * 100;
if (!isFinite(pct) || pct < 0) return 0;
return Math.min(pct, 100);
}
/** Banner level from a budget percentage and thresholds. */
export function budgetLevelFor(pct, warnPct = 80, criticalPct = 95) {
if (pct == null || isNaN(pct)) return 'none';
if (pct >= criticalPct) return 'critical';
if (pct >= warnPct) return 'warn';
return 'none';
}
/** Gauge fill colour by budget percentage. */
export function budgetGaugeColor(pct) {
if (pct == null) return 'var(--db-accent,#6366f1)';
if (pct >= 95) return '#ef4444';
if (pct >= 80) return '#f97316';
if (pct >= 60) return '#eab308';
return '#22c55e';
}
const DONUT_PALETTE = ['#3b82f6', '#8b5cf6', '#22c55e', '#64748b'];
/**
* Pure SVG donut (no chart library).
*
* @param {Array<{label: string, value: number, color?: string}>} slices
* @param {number} [size=40]
* @returns {string} SVG markup
*/
export function MiniDonutSVG(slices, size = 40) {
const data = (Array.isArray(slices) ? slices : [])
.filter((s) => s && (Number(s.value) || 0) > 0);
const total = data.reduce((sum, s) => sum + (Number(s.value) || 0), 0);
if (!data.length || total <= 0) {
const r = size / 2 - 3;
return `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" role="img" aria-label="No model data"><circle cx="${size / 2}" cy="${size / 2}" r="${r}" fill="none" stroke="var(--db-border,#3f3f46)" stroke-width="4" stroke-dasharray="3 3" /></svg>`;
}
const cx = size / 2;
const cy = size / 2;
const r = size / 2 - 3;
const inner = r * 0.55;
let angle = -Math.PI / 2;
const parts = [];
const labels = [];
data.forEach((s, i) => {
const frac = (Number(s.value) || 0) / total;
const end = angle + frac * Math.PI * 2;
const color = s.color || DONUT_PALETTE[i % DONUT_PALETTE.length];
const large = end - angle > Math.PI ? 1 : 0;
const x1 = cx + r * Math.cos(angle);
const y1 = cy + r * Math.sin(angle);
const x2 = cx + r * Math.cos(end);
const y2 = cy + r * Math.sin(end);
const ix2 = cx + inner * Math.cos(end);
const iy2 = cy + inner * Math.sin(end);
const ix1 = cx + inner * Math.cos(angle);
const iy1 = cy + inner * Math.sin(angle);
parts.push(`<path d="M${x1.toFixed(2)},${y1.toFixed(2)} A${r},${r} 0 ${large} 1 ${x2.toFixed(2)},${y2.toFixed(2)} L${ix2.toFixed(2)},${iy2.toFixed(2)} A${inner},${inner} 0 ${large} 0 ${ix1.toFixed(2)},${iy1.toFixed(2)} Z" fill="${color}" />`);
labels.push(`${esc(s.label)} ${Math.round(frac * 100)}%`);
angle = end;
});
return `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" role="img" aria-label="${esc(labels.join(', '))}">${parts.join('')}</svg>`;
}
/**
* Reduce per-model usage rows to donut slices: top 3 by cost + aggregated Other.
*
* @param {Array<{model?: string, cost_usd?: number, total_tokens?: number}>} models
* @returns {Array<{label: string, value: number, color: string}>}
*/
export function modelDonutSlices(models) {
const rows = (Array.isArray(models) ? models : [])
.map((m) => ({ label: shortModelName(m.model), value: Number(m.cost_usd) || Number(m.total_tokens) || 0 }))
.filter((m) => m.value > 0)
.sort((a, b) => b.value - a.value);
const top = rows.slice(0, 3).map((m, i) => ({ ...m, color: DONUT_PALETTE[i] }));
const rest = rows.slice(3).reduce((sum, m) => sum + m.value, 0);
if (rest > 0) top.push({ label: 'Other', value: rest, color: DONUT_PALETTE[3] });
return top;
}
/**
* Build the Overview Cost FinOps strip markup.
*
* @param {Object} opts
* @param {number} opts.todayTokens
* @param {number} [opts.todayCost]
* @param {number|null} [opts.budget]
* @param {number} [opts.warnPct]
* @param {number} [opts.criticalPct]
* @param {Array} [opts.models]
* @param {number} [opts.delta] - signed pct vs yesterday
* @returns {string} HTML string
*/
export function costStripHTML({ todayTokens, todayCost, budget = null, warnPct = 80, criticalPct = 95, models = [], delta } = {}) {
const pct = computeBudgetPct(todayTokens, budget);
const level = budgetLevelFor(pct, warnPct, criticalPct);
const gaugeColor = budgetGaugeColor(pct);
const slices = modelDonutSlices(models);
const topModel = slices.length ? slices[0].label : '';
const budgetText = pct == null
? `${formatTokens(todayTokens)} tokens`
: `${formatTokens(todayTokens)} / ${formatTokens(budget)} tokens (${Math.round(pct)}%)`;
const gauge = pct == null ? '' : `<div class="db-finops-gauge" role="progressbar" aria-valuenow="${Math.round(pct)}" aria-valuemin="0" aria-valuemax="100" style="height:8px;border-radius:6px;background:var(--db-border,#3f3f46);overflow:hidden;margin-top:6px">
<div class="db-finops-gauge-fill" style="width:${Math.round(pct)}%;height:100%;background:${gaugeColor};transition:width 400ms ease"></div>
</div>`;
const pill = level === 'none' ? '' : `<span class="db-finops-pill" style="font-size:11px;padding:2px 8px;border-radius:10px;margin-left:8px;background:${level === 'critical' ? 'rgba(239,68,68,.15)' : 'rgba(234,179,8,.15)'};color:${level === 'critical' ? '#ef4444' : '#eab308'}">${Math.round(pct)}%</span>`;
const costText = todayCost != null ? `Est. ${formatCost(todayCost)} today` : '';
let deltaHtml = '';
if (typeof delta === 'number' && !isNaN(delta) && delta !== 0) {
const up = delta > 0;
deltaHtml = `<span style="font-size:11px;color:${up ? '#ef4444' : '#22c55e'};margin-left:8px">${up ? '↗' : '↘'} ${Math.abs(delta).toFixed(0)}% vs yesterday</span>`;
}
return `<div id="ov-finops-strip" class="db-card db-finops-strip" role="region" aria-label="Cost and usage summary" tabindex="0" data-nav="usage"
style="cursor:pointer;display:flex;align-items:center;gap:20px;padding:14px 18px;margin-top:16px;margin-bottom:8px;flex-wrap:wrap">
<div style="display:flex;align-items:center;gap:8px;min-width:130px">
<span aria-hidden="true">💰</span>
<span style="font-size:13px;font-weight:600;color:var(--db-text)">Cost FinOps</span>${pill}
</div>
<div style="flex:1;min-width:200px">
<div style="font-size:20px;font-weight:700;color:var(--db-text)">${esc(budgetText)}${deltaHtml}</div>
<div style="font-size:12px;color:var(--db-text-dim,#a1a1aa)">${esc(costText)}</div>
${gauge}
</div>
<div style="display:flex;align-items:center;gap:10px">
${MiniDonutSVG(slices, 40)}
<span style="font-size:12px;color:var(--db-text-dim,#a1a1aa)">${esc(topModel)}</span>
</div>
<span style="font-size:12px;color:var(--db-accent,#6366f1)">View usage →</span>
</div>`;
}
/**
* Build a FinOps budget banner (Overview or Chat).
*
* @param {string} level - 'none' | 'warn' | 'critical'
* @param {number} pct
* @returns {string} HTML string ('' when level is none)
*/
export function budgetBannerHTML(level, pct) {
if (level === 'none') return '';
const critical = level === 'critical';
const color = critical ? '#ef4444' : '#eab308';
const bg = critical ? 'rgba(239,68,68,.1)' : 'rgba(234,179,8,.1)';
const copy = critical
? `Daily token budget at ${Math.round(pct)}% — review usage`
: `Daily token budget at ${Math.round(pct)}% — consider lighter models`;
return `<div class="db-finops-banner" role="${critical ? 'alert' : 'status'}"
style="display:flex;align-items:center;gap:8px;padding:8px 14px;border:1px solid ${color};border-radius:10px;background:${bg};color:${color};font-size:12px;font-weight:500;margin-bottom:8px">
<span aria-hidden="true">💰</span>
<span style="flex:1">${esc(copy)}</span>
<button type="button" class="db-finops-banner-usage" style="background:none;border:none;color:inherit;cursor:pointer;font-size:12px;text-decoration:underline">Open usage</button>
<button type="button" class="db-finops-banner-dismiss" title="Dismiss" aria-label="Dismiss" style="background:none;border:none;color:inherit;cursor:pointer;font-size:13px;padding:0 4px">✕</button>
</div>`;
}
/** Modal shell for view-local dialogs. */
export function modalShell(id, innerHtml) {
return `<div id="${id}" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:100;align-items:center;justify-content:center;padding:20px">
@@ -34,6 +34,8 @@ const RING_CIRCUMFERENCE = 2 * Math.PI * 20; // r=20 → ~125.66
let contextPollTimer = null;
let contextRefreshDebounce = null;
let lastContextPct = 0;
let finopsConfig = null;
let sessionCostState = { tokens: 0, model: '', estimate: false };
// ── Floating Chat State ──────────────────────────────────────────
let chatModeConfig = { mode: 'fullpage' };
@@ -322,6 +324,16 @@ function injectStyles() {
.chat-memory-chip:hover { background:rgba(147,51,234,.16); }
.chat-memory-chip.loading { opacity:.6; }
/* ── Session Cost Chip (FinOps) ─────────────────────────────── */
.chat-session-cost-chip { display:none; align-items:center; gap:4px; font-size:11px; font-weight:500; padding:4px 10px; border-radius:20px; color:var(--db-text-dim); background:rgba(99,102,241,.08); border:1px solid rgba(99,102,241,.18); white-space:nowrap; cursor:default; }
.chat-session-cost-chip.visible { display:inline-flex; }
.chat-session-cost-chip.estimate { font-style:italic; }
#chat-finops-banner:empty { display:none; }
@media (max-width:480px) {
.chat-session-cost-chip .cost-chip-model { display:none; }
}
.chat-floating-container .chat-session-cost-chip { display:none !important; }
/* ── Enhanced memory cards ──────────────────────────────────── */
.chat-mem-item .mem-head { display:flex; align-items:center; gap:6px; margin-bottom:4px; }
.chat-mem-item .mem-badge { font-size:10px; padding:1px 6px; border-radius:6px; background:rgba(147,51,234,.12); color:rgba(147,51,234,.85); font-weight:600; text-transform:uppercase; }
@@ -770,12 +782,14 @@ export async function render(container) {
</div>
</div>
<div class="chat-compose">
<div id="chat-finops-banner"></div>
<div class="chat-attach-strip" id="chat-attach-strip"></div>
<div class="chat-compose-row">
<button class="chat-attach-btn" id="chat-attach-btn" title="Attach file (PDF, image)">📎</button>
<input type="file" id="chat-file-input" accept=".pdf,.png,.jpg,.jpeg,.gif,.webp,image/*,application/pdf" multiple style="display:none" />
<textarea id="chat-input" placeholder="Type a message…" rows="1"></textarea>
<div class="chat-compose-actions">
<div class="chat-session-cost-chip" id="chat-session-cost-chip" tabindex="0"></div>
<div class="chat-ring-wrap chat-ring-healthy" id="chat-ring-wrap">
<svg viewBox="0 0 48 48" aria-hidden="true">
<circle class="chat-ring-track" cx="24" cy="24" r="20"></circle>
@@ -865,6 +879,7 @@ export async function render(container) {
// Initial context ring render (estimate for empty/new session)
refreshContextStats();
startContextPoll();
loadFinopsConfig();
await loadAgents();
@@ -1195,6 +1210,7 @@ function handleWsMessage(data) {
// Refresh context ring + memory chip after turn completes
scheduleContextRefresh();
refreshMemoryChip();
updateFinopsBanner();
break;
case 'context_update':
@@ -1879,6 +1895,7 @@ function updateContextRing(stats) {
updateRingTooltip(stats, pct);
updateCompactionBanner(pct, estimate);
updateSessionCostChip(stats);
}
function updateRingTooltip(stats, pct) {
@@ -2039,6 +2056,124 @@ function showStreamMetrics(metrics) {
metricsEl.innerHTML = parts.join('') || '';
}
// ── Session Cost Chip (FinOps / STITCH-013) ─────────────────────
function finopsChipEnabled() {
if (!finopsConfig) return true; // default on until config loads
if (finopsConfig.enabled === false) return false;
return finopsConfig.show_session_chip !== false;
}
function fmtChipTokens(n) {
const v = Number(n) || 0;
if (v >= 1000000) return (v / 1000000).toFixed(1) + 'M';
if (v >= 1000) return (v / 1000).toFixed(1) + 'k';
return String(Math.round(v));
}
function shortModel(model) {
const s = model == null ? '' : String(model);
const idx = s.lastIndexOf('/');
return idx >= 0 ? s.slice(idx + 1) : s;
}
function updateSessionCostChip(stats) {
const chip = document.getElementById('chat-session-cost-chip');
if (!chip) return;
// E9/E2: hide when no session, feature disabled, or chip toggled off
if (!currentSessionId || !finopsChipEnabled()) {
chip.classList.remove('visible');
chip.innerHTML = '';
return;
}
const tokens = Number(stats && stats.used) || sessionCostState.tokens || 0;
const model = shortModel((stats && stats.model) || sessionCostState.model || currentAgentName || '');
const estimate = !!(stats && stats.estimate);
sessionCostState = { tokens, model, estimate };
if (tokens <= 0) {
chip.classList.remove('visible');
chip.innerHTML = '';
return;
}
chip.classList.add('visible');
chip.classList.toggle('estimate', estimate);
const prefix = estimate ? '~' : '';
const modelHtml = model
? ` · <span class="cost-chip-model">${escapeChip(model)}</span>`
: '';
chip.innerHTML = prefix + fmtChipTokens(tokens) + modelHtml;
chip.setAttribute('aria-label', 'Session token usage ' + Math.round(tokens) + ' tokens, model ' + (model || 'unknown'));
chip.title = 'Session spend\nTokens: ' + Math.round(tokens).toLocaleString() + '\nModel: ' + (model || 'unknown');
}
function escapeChip(s) {
const d = document.createElement('div');
d.textContent = s == null ? '' : String(s);
return d.innerHTML;
}
async function loadFinopsConfig() {
try {
const res = await fetch('/api/config/runtime');
if (!res.ok) return;
const data = await res.json();
finopsConfig = (data && data.config && data.config.finops) || finopsConfig;
} catch (e) {
// graceful: keep default-on behaviour
}
updateFinopsBanner();
}
function finopsBannerDismissed() {
return sessionStorage.getItem('finops-banner-dismissed') === '1';
}
async function updateFinopsBanner() {
const host = document.getElementById('chat-finops-banner');
if (!host) return;
if (!finopsConfig || finopsConfig.enabled === false) { host.innerHTML = ''; return; }
const budget = finopsConfig.daily_token_budget != null ? Number(finopsConfig.daily_token_budget) : null;
if (!budget || budget <= 0) { host.innerHTML = ''; return; }
if (finopsBannerDismissed()) { host.innerHTML = ''; return; }
let today = 0;
try {
const res = await fetch('/api/usage/timeseries?hours=24');
if (!res.ok) { host.innerHTML = ''; return; } // E1: hide, no toast
const data = await res.json();
const ts = Array.isArray(data.timeseries) ? data.timeseries : [];
const todayKey = new Date().toISOString().slice(0, 10);
const dated = ts.filter((p) => typeof p.hour_key === 'string');
const scope = dated.length ? dated.filter((p) => p.hour_key.startsWith(todayKey)) : ts;
today = scope.reduce((sum, p) => sum + (Number(p.tokens) || 0), 0);
} catch (e) {
host.innerHTML = '';
return;
}
const pct = Math.min((today / budget) * 100, 100);
const warnPct = Number(finopsConfig.warn_pct) || 80;
const criticalPct = Number(finopsConfig.critical_pct) || 95;
if (pct < warnPct) { host.innerHTML = ''; return; }
const critical = pct >= criticalPct;
const color = critical ? '#ef4444' : '#eab308';
const bg = critical ? 'rgba(239,68,68,.1)' : 'rgba(234,179,8,.1)';
const copy = critical
? 'Daily token budget at ' + Math.round(pct) + '% — review usage'
: 'Daily token budget at ' + Math.round(pct) + '% — consider lighter models';
host.innerHTML =
'<div class="chat-finops-banner-inner" role="' + (critical ? 'alert' : 'status') + '"' +
' style="display:flex;align-items:center;gap:8px;padding:8px 14px;margin-bottom:8px;border:1px solid ' + color +
';border-radius:10px;background:' + bg + ';color:' + color + ';font-size:12px;font-weight:500">' +
'<span aria-hidden="true">💰</span>' +
'<span style="flex:1">' + escapeChip(copy) + '</span>' +
'<button type="button" class="finops-banner-dismiss" title="Dismiss" aria-label="Dismiss"' +
' style="background:none;border:none;color:inherit;cursor:pointer;font-size:13px;padding:0 4px">✕</button>' +
'</div>';
const dismiss = host.querySelector('.finops-banner-dismiss');
if (dismiss) dismiss.addEventListener('click', () => {
sessionStorage.setItem('finops-banner-dismissed', '1');
host.innerHTML = '';
});
}
// ── Memory Panel ────────────────────────────────────────────────
function toggleMemoryPanel() {
const panel = document.getElementById('chat-memory-panel');
@@ -11,7 +11,11 @@
*
* Renders into [data-page="overview"].
*/
import { metricCard, esc, timeAgo } from '/plugins/views/_helpers.js';
import {
metricCard, esc, timeAgo,
sumTodayTokens, computeBudgetPct, budgetLevelFor,
costStripHTML, budgetBannerHTML,
} from '/plugins/views/_helpers.js';
const METRICS_POLL_MS = 30000;
const APPROVALS_POLL_MS = 10000;
@@ -76,6 +80,8 @@ async function loadAll() {
fetchJson('/api/gateway/status'),
fetchJson('/api/features'),
fetchJson('/api/traces?limit=10'),
fetchJson('/api/usage/models'),
fetchJson('/api/config/runtime'),
]);
const agents = toArray(settled(results, 0, {}), 'agents');
@@ -87,13 +93,17 @@ async function loadAll() {
const gateway = settled(results, 6, null);
const features = toArray(settled(results, 7, {}), 'features');
const traces = toArray(settled(results, 8, {}), 'traces');
const models = toArray(settled(results, 9, {}), 'models');
const runtime = settled(results, 10, null);
return {
agents, sessions, pending, usage, timeseries, channels, gateway, features, traces,
agents, sessions, pending, usage, timeseries, channels, gateway, features, traces, models,
finops: (runtime && runtime.config && runtime.config.finops) || null,
errors: {
agents: results[0].status === 'rejected',
sessions: results[1].status === 'rejected',
usage: results[3].status === 'rejected',
timeseries: results[4].status === 'rejected',
},
};
}
@@ -152,6 +162,35 @@ function renderMetrics(data) {
return `<div class="db-metric-grid" style="display:grid;grid-template-columns:repeat(6,1fr);gap:12px">${cards.join('')}</div>`;
}
function finopsState(data) {
const finops = data.finops || {};
// E1: usage timeseries unreachable → hide all FinOps UI (no error toast)
if (data.errors.usage || data.errors.timeseries) return null;
// E2: master toggle off → hide
if (finops.enabled === false) return null;
const ts = data.timeseries && Array.isArray(data.timeseries.timeseries) ? data.timeseries.timeseries : [];
const today = sumTodayTokens(ts);
const todayCost = ts.reduce((sum, p) => sum + (Number(p.cost) || 0), 0);
const budget = finops.daily_token_budget != null ? Number(finops.daily_token_budget) : null;
const warnPct = Number(finops.warn_pct) || 80;
const criticalPct = Number(finops.critical_pct) || 95;
const pct = computeBudgetPct(today, budget);
const level = budgetLevelFor(pct, warnPct, criticalPct);
return { today, todayCost, budget, warnPct, criticalPct, pct, level };
}
function renderFinOps(data) {
const st = finopsState(data);
if (!st) return '';
const bannerDismissed = sessionStorage.getItem('finops-banner-dismissed') === '1';
const banner = (st.level !== 'none' && !bannerDismissed) ? budgetBannerHTML(st.level, st.pct) : '';
const strip = costStripHTML({
todayTokens: st.today, todayCost: st.todayCost, budget: st.budget,
warnPct: st.warnPct, criticalPct: st.criticalPct, models: data.models,
});
return `<div id="ov-finops-banner">${banner}</div>${strip}`;
}
function renderAgentTable(agents) {
if (agents.length === 0) {
return `<div class="db-card" style="text-align:center;padding:2.5rem 1.5rem;color:var(--db-text-dim)">
@@ -265,6 +304,8 @@ function shell(data) {
<div id="ov-metrics">${renderMetrics(data)}</div>
<div id="ov-finops">${renderFinOps(data)}</div>
<div class="db-ov-columns" style="display:grid;grid-template-columns:3fr 2fr;gap:16px;margin-top:20px">
<div>
<h3 style="margin:0 0 12px;font-size:15px;font-weight:600">Active Agents</h3>
@@ -351,6 +392,17 @@ function bindEvents(container, data) {
container.querySelector('[data-omnibar]')?.addEventListener('click', openPalette);
container.querySelector('.db-finops-banner-usage')?.addEventListener('click', (e) => {
e.stopPropagation();
navigate('usage');
});
container.querySelector('.db-finops-banner-dismiss')?.addEventListener('click', (e) => {
e.stopPropagation();
sessionStorage.setItem('finops-banner-dismissed', '1');
const host = container.querySelector('#ov-finops-banner');
if (host) host.innerHTML = '';
});
container.querySelectorAll('.db-agent-row').forEach((row) => {
row.addEventListener('click', (e) => {
if (e.target.closest('.db-agent-chat')) return;
+249
View File
@@ -0,0 +1,249 @@
"""Unit tests for the Cost FinOps Strip (STITCH-013, #223).
Covers:
* Budget threshold + today-sum logic (mirrors the JS helpers).
* FinOps config schema section + cross-field validation.
* Frontend component presence in overview.js / chat.js / _helpers.js.
* i18n keys for strip/banner/chip.
"""
from __future__ import annotations
from pathlib import Path
from praisonaiui.features.config_runtime import CONFIG_SCHEMA, validate_config
from praisonaiui.features.i18n import JSONLocaleManager
FRONTEND = (
Path(__file__).resolve().parents[2]
/ "src" / "praisonaiui" / "templates" / "frontend" / "plugins"
)
HELPERS_JS = FRONTEND / "views" / "_helpers.js"
OVERVIEW_JS = FRONTEND / "views" / "overview.js"
CHAT_JS = FRONTEND / "views" / "chat.js"
def _compute_budget_pct(today: float, budget) -> float | None:
"""Python mirror of computeBudgetPct."""
if not budget or budget <= 0:
return None
pct = today / budget * 100
if pct < 0:
return 0
return min(pct, 100)
def _budget_level(pct, warn_pct: int = 80, critical_pct: int = 95) -> str:
"""Python mirror of budgetLevelFor."""
if pct is None:
return "none"
if pct >= critical_pct:
return "critical"
if pct >= warn_pct:
return "warn"
return "none"
def _sum_today_tokens(timeseries: list[dict]) -> int:
"""Python mirror of sumTodayTokens (uses fixed today key for determinism)."""
today_key = "2026-07-15"
dated = [p for p in timeseries if isinstance(p.get("hour_key"), str)]
scope = [p for p in dated if p["hour_key"].startswith(today_key)] if dated else timeseries
return sum(int(p.get("tokens", 0) or 0) for p in scope)
def _short_model(model: str) -> str:
idx = model.rfind("/")
return model[idx + 1:] if idx >= 0 else model
class TestBudgetPct:
def test_80_percent(self):
assert _compute_budget_pct(1_600_000, 2_000_000) == 80
def test_null_budget(self):
assert _compute_budget_pct(1_000_000, None) is None
def test_zero_budget_treated_as_unset(self):
assert _compute_budget_pct(1_000_000, 0) is None
def test_over_budget_capped_at_100(self):
assert _compute_budget_pct(3_000_000, 2_000_000) == 100
class TestBudgetLevel:
def test_below_warn_is_none(self):
assert _budget_level(79) == "none"
def test_at_warn_is_warn(self):
assert _budget_level(80) == "warn"
assert _budget_level(82) == "warn"
def test_at_critical_is_critical(self):
assert _budget_level(95) == "critical"
assert _budget_level(96) == "critical"
def test_null_pct_is_none(self):
assert _budget_level(None) == "none"
class TestSumTodayTokens:
def test_only_today_buckets_summed(self):
ts = [
{"hour_key": "2026-07-15-08", "tokens": 100},
{"hour_key": "2026-07-15-09", "tokens": 200},
{"hour_key": "2026-07-14-23", "tokens": 999},
]
assert _sum_today_tokens(ts) == 300
def test_falls_back_to_all_when_undated(self):
ts = [{"tokens": 10}, {"tokens": 20}]
assert _sum_today_tokens(ts) == 30
class TestShortModel:
def test_strips_provider_prefix(self):
assert _short_model("anthropic/claude-3-5-sonnet") == "claude-3-5-sonnet"
def test_no_prefix_unchanged(self):
assert _short_model("gpt-4o-mini") == "gpt-4o-mini"
class TestFinOpsConfigSchema:
def test_finops_section_present(self):
assert "finops" in CONFIG_SCHEMA["properties"]
def test_finops_fields_present(self):
props = CONFIG_SCHEMA["properties"]["finops"]["properties"]
for field in (
"enabled",
"daily_token_budget",
"daily_cost_budget_usd",
"warn_pct",
"critical_pct",
"show_session_chip",
):
assert field in props, f"missing finops field: {field}"
def test_threshold_defaults(self):
props = CONFIG_SCHEMA["properties"]["finops"]["properties"]
assert props["warn_pct"]["default"] == 80
assert props["critical_pct"]["default"] == 95
class TestFinOpsValidation:
def test_critical_must_exceed_warn(self):
errors = validate_config({"finops": {"warn_pct": 90, "critical_pct": 80}})
assert any("critical_pct" in e for e in errors)
def test_equal_thresholds_rejected(self):
errors = validate_config({"finops": {"warn_pct": 80, "critical_pct": 80}})
assert any("critical_pct" in e for e in errors)
def test_valid_thresholds_pass(self):
errors = validate_config({"finops": {"warn_pct": 80, "critical_pct": 95}})
assert not any("critical_pct" in e for e in errors)
def test_negative_budget_rejected(self):
errors = validate_config({"finops": {"daily_token_budget": -5}})
assert any("daily_token_budget" in e for e in errors)
def test_no_finops_section_ok(self):
assert validate_config({"model": {"name": "gpt-4o-mini"}}) == []
class TestHelpersJs:
def test_donut_and_strip_helpers_present(self):
src = HELPERS_JS.read_text()
assert "export function MiniDonutSVG(" in src
assert "export function costStripHTML(" in src
assert "export function budgetBannerHTML(" in src
def test_math_helpers_present(self):
src = HELPERS_JS.read_text()
assert "export function computeBudgetPct(" in src
assert "export function budgetLevelFor(" in src
assert "export function sumTodayTokens(" in src
assert "export function formatTokens(" in src
assert "export function formatCost(" in src
def test_donut_empty_state_accessible(self):
src = HELPERS_JS.read_text()
assert 'aria-label="No model data"' in src
def test_strip_navigates_and_is_region(self):
src = HELPERS_JS.read_text()
assert 'data-nav="usage"' in src
assert 'role="region"' in src
class TestOverviewJs:
def test_finops_imports_and_render(self):
src = OVERVIEW_JS.read_text()
assert "costStripHTML" in src
assert "budgetBannerHTML" in src
assert "function renderFinOps(" in src
assert 'id="ov-finops"' in src
def test_fetches_models_and_config(self):
src = OVERVIEW_JS.read_text()
assert "/api/usage/models" in src
assert "/api/config/runtime" in src
def test_graceful_degrade_state_machine(self):
src = OVERVIEW_JS.read_text()
assert "function finopsState(" in src
assert "finops.enabled === false" in src
class TestChatJs:
def test_session_cost_chip_present(self):
src = CHAT_JS.read_text()
assert 'id="chat-session-cost-chip"' in src
assert "function updateSessionCostChip(" in src
def test_finops_banner_present(self):
src = CHAT_JS.read_text()
assert 'id="chat-finops-banner"' in src
assert "function updateFinopsBanner(" in src
assert "function loadFinopsConfig(" in src
def test_chip_updates_on_run_completed(self):
src = CHAT_JS.read_text()
assert "updateFinopsBanner()" in src
assert "updateSessionCostChip(stats)" in src
def test_context_ring_regression_preserved(self):
src = CHAT_JS.read_text()
assert 'id="chat-ring-wrap"' in src
assert "function ringStateFor(" in src
assert 'class="chat-ring-send" id="chat-send-btn"' in src
class TestI18nKeys:
KEYS = [
"finops.strip.title",
"finops.strip.today",
"finops.strip.view_usage",
"finops.banner.warn",
"finops.banner.critical",
"finops.chip.label",
]
def test_all_keys_present_all_locales(self):
mgr = JSONLocaleManager()
for locale in ("en", "es", "fr"):
strings = mgr.get_strings(locale)
for k in self.KEYS:
assert k in strings, f"missing {locale} key: {k}"
def test_banner_interpolates_pct(self):
mgr = JSONLocaleManager()
out = mgr.t("finops.banner.warn", locale="en", pct=82)
assert "82" in out
def test_chip_interpolates_tokens_and_model(self):
mgr = JSONLocaleManager()
out = mgr.t("finops.chip.label", locale="en", tokens="12.4k", model="gpt-4o-mini")
assert "12.4k" in out
assert "gpt-4o-mini" in out