feat(#692): add configurable budget limit with percentage-used tracking
This commit is contained in:
1 parent
e24e6c54a6
commit
62e25ad9c1
6 files changed
+483
-3
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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.",
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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){
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user