feat(#692): add configurable budget limit with percentage-used tracking

This commit is contained in:
Rod Boev committed 2026-06-28 10:10:35 -04:00
1 parent e24e6c54a6
commit 62e25ad9c1
6 files changed
+483 -3

No files matched your search

+13
View File
@@ -7743,6 +7743,7 @@ _SETTINGS_DEFAULTS = {
"busy_input_mode": "queue", # behavior when sending while agent is running: queue | interrupt | steer
"password_hash": None, # PBKDF2-HMAC-SHA256 hash; None = auth disabled
"auth_disabled_acknowledged": False, # user acknowledged unauthenticated risk
"provider_cost_budget": None,
}
_SETTINGS_LEGACY_DROP_KEYS = {
"assistant_language",
@@ -8057,6 +8058,18 @@ def save_settings(settings: dict) -> dict:
seen.add(s)
cleaned.append(s)
v = cleaned
if k == "provider_cost_budget":
if v is None or v == "":
current[k] = None
continue
try:
v = float(v)
except (TypeError, ValueError):
continue
if not (0 < v < 1e9) or not __import__("math").isfinite(v):
continue
current[k] = round(v, 2)
continue
# Coerce bool keys
if k in _SETTINGS_BOOL_KEYS:
v = bool(v)
+19
View File
@@ -2050,6 +2050,21 @@ _COST_SNAPSHOT_MAX_DAYS = 365 # hard cap to prevent unbounded growth
_COST_SNAPSHOT_LOCK = threading.Lock()
def _get_provider_cost_budget() -> float | None:
"""Return the user-configured monthly spend budget, or None if unset."""
try:
from api.config import load_settings
raw = load_settings().get("provider_cost_budget")
if raw is None:
return None
val = float(raw)
if val > 0 and __import__("math").isfinite(val):
return round(val, 2)
except Exception:
pass
return None
def _cost_snapshots_dir() -> Path:
"""Return the directory for cost-snapshot JSON files.
@@ -2269,6 +2284,7 @@ def get_provider_cost_history(provider_id: str | None = None, days: int = 7) ->
}
display_name = _PROVIDER_DISPLAY.get("openrouter", "OpenRouter")
monthly_budget = _get_provider_cost_budget()
api_key = _get_provider_api_key("openrouter")
if not api_key:
return {
@@ -2277,6 +2293,7 @@ def get_provider_cost_history(provider_id: str | None = None, days: int = 7) ->
"display_name": display_name,
"supported": True,
"status": "no_key",
"monthly_budget": monthly_budget,
"message": "OpenRouter cost history needs an OPENROUTER_API_KEY configured on the server.",
}
@@ -2297,6 +2314,7 @@ def get_provider_cost_history(provider_id: str | None = None, days: int = 7) ->
"snapshots": deltas,
"limit": None,
"label": None,
"monthly_budget": monthly_budget,
"message": "OpenRouter cost history is temporarily unavailable. Showing last known data.",
}
@@ -2318,6 +2336,7 @@ def get_provider_cost_history(provider_id: str | None = None, days: int = 7) ->
"snapshots": deltas,
"limit": key_info.get("limit"),
"label": key_info.get("label") or "OpenRouter credits",
"monthly_budget": monthly_budget,
"message": "OpenRouter cost history loaded.",
}
+6
View File
@@ -1225,6 +1225,12 @@ const LOCALES = {
provider_quota_pool_summary_exhausted: '{0} exhausted',
provider_quota_pool_summary_failed: '{0} failed',
provider_quota_pool_summary_checked: '{0} checked',
provider_cost_budget_label: 'Monthly budget',
provider_cost_budget_placeholder: 'e.g. 50.00',
provider_cost_budget_set: 'Set',
provider_cost_budget_clear: 'Clear',
provider_cost_budget_pct: '{0}% of ${1} budget (monthly pace)',
provider_cost_budget_save_failed: 'Failed to save budget',
cancel: 'Cancel',
create_job: 'Create job',
save_skill: 'Save skill',
+100 -3
View File
@@ -9336,8 +9336,7 @@ async function renderProviderCostChart(card){
}
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:[];
const snaps=Array.isArray(history&&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){
@@ -9345,12 +9344,14 @@ async function renderProviderCostChart(card){
empty.className='provider-cost-chart-wrap';
empty.innerHTML='<div class="provider-cost-chart-title">7-day spend</div><div class="provider-quota-message">Not enough data yet. Cost chart builds after 2 daily snapshots.</div>';
body.appendChild(empty);
_attachBudgetControls(empty,history,card,0);
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 paceNum=avg*30;
const pace='$'+paceNum.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';
@@ -9361,7 +9362,103 @@ async function renderProviderCostChart(card){
const wrap=document.createElement('div');
wrap.className='provider-cost-chart-wrap';
wrap.innerHTML=`<div class="provider-cost-chart-title">7-day spend <span class="provider-cost-chart-pace">Monthly pace: ${esc(pace)}</span></div><div class="provider-cost-chart-bars insights-daily-token-chart">${bars}</div>`;
const monthly_budget=history&&history.monthly_budget!=null?history.monthly_budget:null;
if(monthly_budget!=null&&paceNum>0){
const paceSpan=wrap.querySelector('.provider-cost-chart-pace');
if(paceSpan){
const pct=Math.round((paceNum/monthly_budget)*100);
const pctSpan=document.createElement('span');
pctSpan.className='provider-cost-chart-pct'+(pct>=100?' over':pct>=80?' warn':'');
pctSpan.textContent=`(${pct}%)`;
paceSpan.appendChild(pctSpan);
}
}
body.appendChild(wrap);
_attachBudgetControls(wrap,history,card,paceNum);
}
function _attachBudgetControls(wrap,history,card,paceNum){
const budget=history&&history.monthly_budget!=null?Number(history.monthly_budget):null;
const row=document.createElement('div');
row.className='provider-cost-budget-row';
const titleDiv=document.createElement('div');
titleDiv.className='provider-cost-budget-title';
titleDiv.textContent=t('provider_cost_budget_label');
row.appendChild(titleDiv);
const inputGroup=document.createElement('div');
inputGroup.className='provider-cost-budget-input-group';
const prefix=document.createElement('span');
prefix.className='provider-cost-budget-prefix';
prefix.textContent='$';
inputGroup.appendChild(prefix);
const input=document.createElement('input');
input.type='number';
input.min='0.01';
input.step='0.01';
input.className='provider-cost-budget-input';
input.placeholder=t('provider_cost_budget_placeholder')||'e.g. 50.00';
if(budget!=null) input.value=budget.toFixed(2);
inputGroup.appendChild(input);
const setBtn=document.createElement('button');
setBtn.type='button';
setBtn.className='provider-cost-budget-set';
setBtn.textContent=t('provider_cost_budget_set');
inputGroup.appendChild(setBtn);
const clearBtn=document.createElement('button');
clearBtn.type='button';
clearBtn.className='provider-cost-budget-clear';
clearBtn.textContent=t('provider_cost_budget_clear');
if(budget==null) clearBtn.style.display='none';
inputGroup.appendChild(clearBtn);
row.appendChild(inputGroup);
if(budget!=null&&paceNum>0){
const pct=Math.round((paceNum/budget)*100);
const barWrap=document.createElement('div');
barWrap.className='provider-cost-budget-bar-wrap';
const bar=document.createElement('div');
bar.className='provider-cost-budget-bar';
const fill=document.createElement('div');
fill.className='provider-cost-budget-bar-fill'+(pct>=100?' over':pct>=80?' warn':'');
fill.style.width=Math.min(100,pct)+'%';
bar.appendChild(fill);
barWrap.appendChild(bar);
const pctLabel=document.createElement('span');
pctLabel.className='provider-cost-budget-pct-label';
pctLabel.textContent=t('provider_cost_budget_pct',pct,budget.toFixed(2));
barWrap.appendChild(pctLabel);
row.appendChild(barWrap);
}
wrap.appendChild(row);
async function _saveBudget(value){
try{
await api('/api/settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({provider_cost_budget:value})});
const existing=card.querySelector('.provider-cost-chart-wrap');
if(existing) existing.remove();
renderProviderCostChart(card);
}catch(e){
if(typeof showToast==='function') showToast(t('provider_cost_budget_save_failed'));
}
}
setBtn.addEventListener('click',()=>{
const val=parseFloat(input.value);
if(!isFinite(val)||val<=0) return;
_saveBudget(val);
});
clearBtn.addEventListener('click',()=>{
_saveBudget(null);
});
}
function _buildProviderCard(p){
+15
View File
@@ -4968,6 +4968,21 @@ main.main > #mainPlugin{display:none;}
.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-cost-budget-row{margin-top:10px;display:flex;flex-direction:column;gap:6px;}
.provider-cost-budget-input-group{display:flex;align-items:center;gap:4px;}
.provider-cost-budget-prefix{font-size:12px;color:var(--muted);}
.provider-cost-budget-input{width:90px;padding:3px 6px;border:1px solid var(--border);border-radius:6px;background:var(--sidebar);color:var(--text);font-size:12px;}
.provider-cost-budget-set,.provider-cost-budget-clear{font-size:11px;padding:3px 8px;border:1px solid var(--border);border-radius:6px;background:transparent;color:var(--text);cursor:pointer;transition:background .15s;}
.provider-cost-budget-set:hover,.provider-cost-budget-clear:hover{background:var(--surface);}
.provider-cost-budget-bar-wrap{display:flex;align-items:center;gap:6px;}
.provider-cost-budget-bar{flex:1;height:6px;background:var(--border);border-radius:3px;overflow:hidden;}
.provider-cost-budget-bar-fill{height:100%;background:var(--success,#22c55e);border-radius:3px;transition:width .3s;}
.provider-cost-budget-bar-fill.warn{background:var(--warning,#f59e0b);}
.provider-cost-budget-bar-fill.over{background:var(--error,#ef4444);}
.provider-cost-budget-pct-label{font-size:11px;color:var(--muted);white-space:nowrap;}
.provider-cost-chart-pct{font-size:10.5px;margin-left:4px;}
.provider-cost-chart-pct.warn{color:var(--warning,#f59e0b);}
.provider-cost-chart-pct.over{color:var(--error,#ef4444);}
.provider-card{
border:1px solid var(--border);
border-radius:12px;
+330
View File
@@ -0,0 +1,330 @@
"""Tests for configurable monthly budget limit with percentage-used tracking (#692).
Covers:
- Static analysis: hooks present in config.py, providers.py, panels.js, style.css, i18n.js
- Backend: monthly_budget propagates through all three response paths
- Backend: save_settings validates and coerces provider_cost_budget
"""
from __future__ import annotations
import json
import os
from io import BytesIO
from pathlib import Path
import urllib.error
import api.config as config
import api.profiles as profiles
ROOT = Path(__file__).resolve().parents[1]
def _read(rel):
return (ROOT / rel).read_text(encoding="utf-8")
# ── Static analysis ───────────────────────────────────────────────────────────
def test_provider_cost_budget_in_settings_defaults():
assert "provider_cost_budget" in config._SETTINGS_DEFAULTS, (
"provider_cost_budget must appear in _SETTINGS_DEFAULTS"
)
def test_monthly_budget_in_providers_response_dicts():
src = _read("api/providers.py")
assert '"monthly_budget"' in src or "'monthly_budget'" in src, (
"monthly_budget must appear in providers.py response dicts"
)
def test_get_provider_cost_budget_defined():
src = _read("api/providers.py")
assert "_get_provider_cost_budget" in src, (
"_get_provider_cost_budget helper must be defined in providers.py"
)
def test_attach_budget_controls_defined():
src = _read("static/panels.js")
assert "function _attachBudgetControls" in src, (
"_attachBudgetControls must be defined in panels.js"
)
def test_attach_budget_controls_called_in_data_branch():
src = _read("static/panels.js")
# Must be called in the hasData branch (after body.appendChild(wrap))
assert src.count("_attachBudgetControls") >= 2, (
"_attachBudgetControls must be called in both the data and no-data branches"
)
def test_budget_dom_classes_in_panels_js():
src = _read("static/panels.js")
for cls in ("provider-cost-budget-row", "provider-cost-budget-bar", "provider-cost-budget-input"):
assert cls in src, f"DOM class '{cls}' must appear in panels.js"
def test_budget_dom_classes_in_style_css():
src = _read("static/style.css")
for cls in ("provider-cost-budget-row", "provider-cost-budget-bar", "provider-cost-budget-input"):
assert cls in src, f"CSS class '{cls}' must appear in style.css"
def test_budget_i18n_keys_in_i18n_js():
src = _read("static/i18n.js")
for key in (
"provider_cost_budget_label",
"provider_cost_budget_pct",
"provider_cost_budget_save_failed",
):
assert key in src, f"i18n key '{key}' must appear in i18n.js"
def test_settings_post_in_panels_js_for_budget():
src = _read("static/panels.js")
assert "provider_cost_budget" in src, (
"panels.js must POST provider_cost_budget to /api/settings for budget save"
)
# ── Fixtures ──────────────────────────────────────────────────────────────────
class _FakeResponse:
def __init__(self, payload: bytes):
self._payload = payload
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def read(self):
return self._payload
def _with_config(model=None, providers=None):
old_cfg = dict(config.cfg)
old_mtime = config._cfg_mtime
config.cfg.clear()
config.cfg["model"] = model or {}
if providers is not None:
config.cfg["providers"] = providers
try:
config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime
except Exception:
config._cfg_mtime = 0.0
return old_cfg, old_mtime
def _restore_config(old_cfg, old_mtime):
config.cfg.clear()
config.cfg.update(old_cfg)
config._cfg_mtime = old_mtime
def _fake_urlopen_with_usage(usage=5.0, limit=20.0, label="Credits"):
def fake_urlopen(req, timeout):
payload = {"data": {"usage": usage, "limit": limit, "label": label}}
return _FakeResponse(json.dumps(payload).encode("utf-8"))
return fake_urlopen
# ── Behavioral: monthly_budget in responses ───────────────────────────────────
def test_monthly_budget_in_available_response(monkeypatch, tmp_path):
"""get_provider_cost_history returns monthly_budget when configured."""
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
(tmp_path / ".env").write_text("OPENROUTER_API_KEY=test-key\n", encoding="utf-8")
settings_path = tmp_path / "settings.json"
settings_path.write_text(json.dumps({"provider_cost_budget": 75.0}), encoding="utf-8")
monkeypatch.setattr(config, "SETTINGS_FILE", settings_path)
old_cfg, old_mtime = _with_config(model={"provider": "openrouter"})
import api.providers as providers
monkeypatch.setattr(providers.urllib.request, "urlopen", _fake_urlopen_with_usage(5.0, 20.0))
try:
result = providers.get_provider_cost_history("openrouter", days=7)
finally:
_restore_config(old_cfg, old_mtime)
assert result["monthly_budget"] == 75.0
def test_monthly_budget_none_when_not_configured(monkeypatch, tmp_path):
"""get_provider_cost_history returns monthly_budget: None when no budget set."""
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
(tmp_path / ".env").write_text("OPENROUTER_API_KEY=test-key\n", encoding="utf-8")
settings_path = tmp_path / "settings.json"
settings_path.write_text(json.dumps({}), encoding="utf-8")
monkeypatch.setattr(config, "SETTINGS_FILE", settings_path)
old_cfg, old_mtime = _with_config(model={"provider": "openrouter"})
import api.providers as providers
monkeypatch.setattr(providers.urllib.request, "urlopen", _fake_urlopen_with_usage(5.0, 20.0))
try:
result = providers.get_provider_cost_history("openrouter", days=7)
finally:
_restore_config(old_cfg, old_mtime)
assert result["monthly_budget"] is None
def test_monthly_budget_in_unavailable_response(monkeypatch, tmp_path):
"""monthly_budget is included when upstream OpenRouter call fails."""
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
(tmp_path / ".env").write_text("OPENROUTER_API_KEY=test-key\n", encoding="utf-8")
settings_path = tmp_path / "settings.json"
settings_path.write_text(json.dumps({"provider_cost_budget": 50.0}), encoding="utf-8")
monkeypatch.setattr(config, "SETTINGS_FILE", settings_path)
old_cfg, old_mtime = _with_config(model={"provider": "openrouter"})
import api.providers as providers
req = providers.urllib.request.Request("https://openrouter.ai/api/v1/key")
def fail_urlopen(_req, timeout=None):
raise urllib.error.HTTPError(req.full_url, 500, "Server Error", {}, BytesIO(b""))
monkeypatch.setattr(providers.urllib.request, "urlopen", fail_urlopen)
try:
result = providers.get_provider_cost_history("openrouter", days=7)
finally:
_restore_config(old_cfg, old_mtime)
assert result["status"] == "unavailable"
assert result["monthly_budget"] == 50.0
def test_monthly_budget_in_no_key_response(monkeypatch, tmp_path):
"""monthly_budget is included in no_key response."""
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
# No .env → no key
settings_path = tmp_path / "settings.json"
settings_path.write_text(json.dumps({"provider_cost_budget": 100.0}), encoding="utf-8")
monkeypatch.setattr(config, "SETTINGS_FILE", settings_path)
old_cfg, old_mtime = _with_config(model={"provider": "openrouter"})
import api.providers as providers
def explode(*_a, **_kw):
raise AssertionError("must not call network without a key")
monkeypatch.setattr(providers.urllib.request, "urlopen", explode)
try:
result = providers.get_provider_cost_history("openrouter", days=7)
finally:
_restore_config(old_cfg, old_mtime)
assert result["status"] == "no_key"
assert result["monthly_budget"] == 100.0
# ── Behavioral: save_settings validation ─────────────────────────────────────
def test_save_settings_persists_float_budget(monkeypatch, tmp_path):
settings_path = tmp_path / "settings.json"
monkeypatch.setattr(config, "SETTINGS_FILE", settings_path)
monkeypatch.setattr(config, "DEFAULT_WORKSPACE", tmp_path)
monkeypatch.setattr(config, "resolve_default_workspace", lambda x: tmp_path)
config.save_settings({"provider_cost_budget": 100.0})
loaded = config.load_settings()
assert loaded["provider_cost_budget"] == 100.0
def test_save_settings_coerces_string_budget(monkeypatch, tmp_path):
settings_path = tmp_path / "settings.json"
monkeypatch.setattr(config, "SETTINGS_FILE", settings_path)
monkeypatch.setattr(config, "DEFAULT_WORKSPACE", tmp_path)
monkeypatch.setattr(config, "resolve_default_workspace", lambda x: tmp_path)
config.save_settings({"provider_cost_budget": "50.00"})
loaded = config.load_settings()
assert loaded["provider_cost_budget"] == 50.0
def test_save_settings_clears_budget_with_none(monkeypatch, tmp_path):
settings_path = tmp_path / "settings.json"
# Pre-seed a budget
settings_path.write_text(json.dumps({"provider_cost_budget": 75.0}), encoding="utf-8")
monkeypatch.setattr(config, "SETTINGS_FILE", settings_path)
monkeypatch.setattr(config, "DEFAULT_WORKSPACE", tmp_path)
monkeypatch.setattr(config, "resolve_default_workspace", lambda x: tmp_path)
config.save_settings({"provider_cost_budget": None})
loaded = config.load_settings()
assert loaded["provider_cost_budget"] is None
def test_save_settings_clears_budget_with_empty_string(monkeypatch, tmp_path):
settings_path = tmp_path / "settings.json"
settings_path.write_text(json.dumps({"provider_cost_budget": 75.0}), encoding="utf-8")
monkeypatch.setattr(config, "SETTINGS_FILE", settings_path)
monkeypatch.setattr(config, "DEFAULT_WORKSPACE", tmp_path)
monkeypatch.setattr(config, "resolve_default_workspace", lambda x: tmp_path)
config.save_settings({"provider_cost_budget": ""})
loaded = config.load_settings()
assert loaded["provider_cost_budget"] is None
def test_save_settings_rejects_zero_budget(monkeypatch, tmp_path):
settings_path = tmp_path / "settings.json"
settings_path.write_text(json.dumps({"provider_cost_budget": 50.0}), encoding="utf-8")
monkeypatch.setattr(config, "SETTINGS_FILE", settings_path)
monkeypatch.setattr(config, "DEFAULT_WORKSPACE", tmp_path)
monkeypatch.setattr(config, "resolve_default_workspace", lambda x: tmp_path)
config.save_settings({"provider_cost_budget": 0})
loaded = config.load_settings()
# Previous value must be unchanged
assert loaded["provider_cost_budget"] == 50.0
def test_save_settings_rejects_negative_budget(monkeypatch, tmp_path):
settings_path = tmp_path / "settings.json"
settings_path.write_text(json.dumps({"provider_cost_budget": 50.0}), encoding="utf-8")
monkeypatch.setattr(config, "SETTINGS_FILE", settings_path)
monkeypatch.setattr(config, "DEFAULT_WORKSPACE", tmp_path)
monkeypatch.setattr(config, "resolve_default_workspace", lambda x: tmp_path)
config.save_settings({"provider_cost_budget": -10})
loaded = config.load_settings()
assert loaded["provider_cost_budget"] == 50.0
def test_save_settings_rejects_invalid_string_budget(monkeypatch, tmp_path):
settings_path = tmp_path / "settings.json"
settings_path.write_text(json.dumps({"provider_cost_budget": 50.0}), encoding="utf-8")
monkeypatch.setattr(config, "SETTINGS_FILE", settings_path)
monkeypatch.setattr(config, "DEFAULT_WORKSPACE", tmp_path)
monkeypatch.setattr(config, "resolve_default_workspace", lambda x: tmp_path)
config.save_settings({"provider_cost_budget": "invalid"})
loaded = config.load_settings()
assert loaded["provider_cost_budget"] == 50.0