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