mirror of
https://github.com/MervinPraison/PraisonAIUI.git
synced 2026-11-07 05:56:17 -10:00
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:
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
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user