Files
hermes-webui/tests/test_provider_cost_budget.py
T

367 lines
14 KiB
Python

"""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
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
assert result["limit"] == 20.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
def test_save_settings_rejects_infinity_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": float("inf")})
loaded = config.load_settings()
assert loaded["provider_cost_budget"] == 50.0
def test_save_settings_rejects_nan_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": float("nan")})
loaded = config.load_settings()
assert loaded["provider_cost_budget"] == 50.0
def test_save_settings_rejects_huge_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": 1e9})
loaded = config.load_settings()
assert loaded["provider_cost_budget"] == 50.0