diff --git a/api/config.py b/api/config.py index 8230f2f7..1fb2b2db 100644 --- a/api/config.py +++ b/api/config.py @@ -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) diff --git a/api/providers.py b/api/providers.py index 489aedc8..fbe44bb7 100644 --- a/api/providers.py +++ b/api/providers.py @@ -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.", } diff --git a/static/i18n.js b/static/i18n.js index b7437cb3..c4026a86 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -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', diff --git a/static/panels.js b/static/panels.js index 397ce14f..1da7d6b7 100644 --- a/static/panels.js +++ b/static/panels.js @@ -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='
7-day spend
Not enough data yet. Cost chart builds after 2 daily snapshots.
'; 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=`
7-day spend Monthly pace: ${esc(pace)}
${bars}
`; + 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){ diff --git a/static/style.css b/static/style.css index 13d8c5aa..0cffa625 100644 --- a/static/style.css +++ b/static/style.css @@ -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; diff --git a/tests/test_provider_cost_budget.py b/tests/test_provider_cost_budget.py new file mode 100644 index 00000000..a8a00ded --- /dev/null +++ b/tests/test_provider_cost_budget.py @@ -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