diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5dfa7fd5..20ca374c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,11 @@
## [Unreleased]
+## [v0.51.323] — 2026-06-07 — Release KM (7-day provider spend chart)
+
+### Added
+- **The Active Provider Quota card now shows a 7-day spend bar chart with a projected monthly pace** (OpenRouter). Daily spend deltas render as a small bar chart with a "Monthly pace" estimate; a graceful "not enough data yet" message shows until two daily snapshots exist. (#3600, @rodboev)
+
## [v0.51.322] — 2026-06-07 — Release KL (sortable + filterable markdown tables)
### Added
diff --git a/static/panels.js b/static/panels.js
index 87ab6080..6cc0abf6 100644
--- a/static/panels.js
+++ b/static/panels.js
@@ -6863,7 +6863,10 @@ async function loadProvidersPanel(){
list.innerHTML='';
_providerCardEls.clear();
const quotaCard=_buildProviderQuotaCard(quota);
- if(quotaCard) list.appendChild(quotaCard);
+ if(quotaCard){
+ list.appendChild(quotaCard);
+ renderProviderCostChart(quotaCard); // async, fire-and-forget
+ }
if(providers.length===0){
list.style.display='none';
if(empty) empty.style.display='';
@@ -6899,6 +6902,10 @@ async function _refreshProviderQuota(card,button){
const fresh=_buildProviderQuotaCard(next);
if(fresh){
card.replaceWith(fresh);
+ // Re-render the 7-day spend chart onto the rebuilt card — the quota
+ // refresh replaces the whole card, which would otherwise drop the chart
+ // until the next full panel reload (#3600).
+ renderProviderCostChart(fresh); // async, fire-and-forget
if(typeof showToast==='function') showToast(failed?t('provider_quota_refresh_failed'):t('provider_quota_refresh_succeeded'));
return;
}
@@ -7125,6 +7132,43 @@ function _buildProviderQuotaCard(status){
return card;
}
+async function renderProviderCostChart(card){
+ let history;
+ try{
+ history=await api('/api/provider/cost-history?provider=openrouter');
+ }catch(e){
+ return; // silently skip if endpoint unavailable
+ }
+ const body=card.querySelector('.provider-quota-body');
+ if(!body||body.querySelector('.provider-cost-chart-wrap')) return;
+ if(!history||history.ok===false) return;
+ const snaps=Array.isArray(history.snapshots)?history.snapshots:[];
+ // need at least 2 snapshots to have one non-null delta
+ const hasData=snaps.filter(s=>s.delta!=null).length>=1;
+ if(!hasData){
+ const empty=document.createElement('div');
+ empty.className='provider-cost-chart-wrap';
+ empty.innerHTML='
7-day spend
Not enough data yet. Cost chart builds after 2 daily snapshots.
';
+ body.appendChild(empty);
+ return;
+ }
+ const maxDelta=Math.max(...snaps.map(s=>s.delta!=null?Number(s.delta):0),1e-9);
+ const nonNull=snaps.filter(s=>s.delta!=null).map(s=>Number(s.delta));
+ const avg=nonNull.length?nonNull.reduce((a,b)=>a+b,0)/nonNull.length:0;
+ const pace='$'+(avg*30).toFixed(2);
+ const bars=snaps.map(s=>{
+ const delta=s.delta!=null?Number(s.delta):null;
+ const pct=delta!=null?Math.max((delta/maxDelta)*100,delta>0?2:0).toFixed(1):'0';
+ const label=String(s.date||'').slice(5);
+ const tip=delta!=null?`${s.date} · $${delta.toFixed(4)}`:`${s.date} · no baseline`;
+ return ``;
+ }).join('');
+ const wrap=document.createElement('div');
+ wrap.className='provider-cost-chart-wrap';
+ wrap.innerHTML=`7-day spend Monthly pace: ${esc(pace)}
${bars}
`;
+ body.appendChild(wrap);
+}
+
function _buildProviderCard(p){
const card=document.createElement('div');
card.className='provider-card';
diff --git a/static/style.css b/static/style.css
index aab17bd8..103c06a1 100644
--- a/static/style.css
+++ b/static/style.css
@@ -3853,6 +3853,10 @@ main.main > #mainPlugin{display:none;}
.provider-quota-pool-row-head span{white-space:normal;}
.provider-quota-pool-windows{grid-template-columns:1fr;}
}
+.provider-cost-chart-wrap{width:100%;margin-top:12px;}
+.provider-cost-chart-title{font-size:11px;font-weight:650;color:var(--muted);margin-bottom:6px;display:flex;align-items:center;justify-content:space-between;gap:8px;}
+.provider-cost-chart-pace{font-weight:400;color:var(--text);}
+.provider-cost-chart-bars{height:80px;}
.provider-card{
border:1px solid var(--border);
border-radius:12px;
diff --git a/tests/test_provider_cost_chart_ui.py b/tests/test_provider_cost_chart_ui.py
new file mode 100644
index 00000000..e41b6aed
--- /dev/null
+++ b/tests/test_provider_cost_chart_ui.py
@@ -0,0 +1,30 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+
+
+def test_provider_cost_chart_ui_guards_are_present():
+ panels_js = (REPO_ROOT / "static" / "panels.js").read_text(encoding="utf-8")
+ style_css = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8")
+
+ # function is defined
+ assert "async function renderProviderCostChart(card)" in panels_js
+
+ # function is wired up inside loadProvidersPanel (fire-and-forget)
+ assert "renderProviderCostChart(quotaCard)" in panels_js
+
+ # fetch target is correct
+ assert "/api/provider/cost-history?provider=openrouter" in panels_js
+
+ # CSS container class present in both JS and CSS
+ assert "provider-cost-chart-wrap" in panels_js
+ assert "provider-cost-chart-wrap" in style_css
+
+ # monthly pace projection annotation
+ assert "Monthly pace" in panels_js
+
+ # null delta guard for the oldest snapshot
+ assert "s.delta!=null" in panels_js