387 lines
13 KiB
Python
387 lines
13 KiB
Python
import concurrent.futures
|
|
import contextvars
|
|
import threading
|
|
import time
|
|
from datetime import datetime, timezone
|
|
|
|
import pytest
|
|
|
|
from agent.account_usage import (
|
|
AccountUsageSnapshot,
|
|
AccountUsageWindow,
|
|
_fetch_portal_account,
|
|
fetch_account_usage,
|
|
render_account_usage_lines,
|
|
)
|
|
from providers.base import ProviderProfile
|
|
|
|
|
|
class _UsageProfile(ProviderProfile):
|
|
def __init__(self, snapshot=None, error=None, name="plugin-usage"):
|
|
super().__init__(name=name)
|
|
self.snapshot = snapshot
|
|
self.error = error
|
|
self.calls = 0
|
|
|
|
def fetch_account_usage(self, *, base_url=None, api_key=None):
|
|
self.calls += 1
|
|
if self.error:
|
|
raise self.error
|
|
return self.snapshot
|
|
|
|
|
|
class _Response:
|
|
def __init__(self, payload, status_code=200):
|
|
self._payload = payload
|
|
self.status_code = status_code
|
|
|
|
def raise_for_status(self):
|
|
if self.status_code >= 400:
|
|
raise RuntimeError(f"HTTP {self.status_code}")
|
|
|
|
def json(self):
|
|
return self._payload
|
|
|
|
|
|
class _Client:
|
|
def __init__(self, payload):
|
|
self._payload = payload
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
def get(self, url, headers=None):
|
|
return _Response(self._payload)
|
|
|
|
|
|
class _RoutingClient:
|
|
def __init__(self, payloads):
|
|
self._payloads = payloads
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
def get(self, url, headers=None):
|
|
return _Response(self._payloads[url])
|
|
|
|
|
|
def test_fetch_account_usage_codex(monkeypatch):
|
|
monkeypatch.setattr(
|
|
"agent.account_usage.resolve_codex_runtime_credentials",
|
|
lambda refresh_if_expiring=True: {
|
|
"provider": "openai-codex",
|
|
"base_url": "https://chatgpt.com/backend-api/codex",
|
|
"api_key": "access-token",
|
|
},
|
|
)
|
|
monkeypatch.setattr(
|
|
"agent.account_usage._read_codex_tokens",
|
|
lambda: {"tokens": {"account_id": "acct_123"}},
|
|
)
|
|
monkeypatch.setattr(
|
|
"agent.account_usage.httpx.Client",
|
|
lambda timeout=15.0: _Client(
|
|
{
|
|
"plan_type": "pro",
|
|
"rate_limit": {
|
|
"primary_window": {
|
|
"used_percent": 15,
|
|
"reset_at": 1_900_000_000,
|
|
"limit_window_seconds": 18000,
|
|
},
|
|
"secondary_window": {
|
|
"used_percent": 40,
|
|
"reset_at": 1_900_500_000,
|
|
"limit_window_seconds": 604800,
|
|
},
|
|
},
|
|
"credits": {"has_credits": True, "balance": 12.5},
|
|
}
|
|
),
|
|
)
|
|
|
|
snapshot = fetch_account_usage("openai-codex")
|
|
|
|
assert snapshot is not None
|
|
assert snapshot.plan == "Pro"
|
|
assert len(snapshot.windows) == 2
|
|
assert snapshot.windows[0].label == "Session"
|
|
assert snapshot.windows[0].used_percent == 15.0
|
|
assert snapshot.windows[0].reset_at == datetime.fromtimestamp(1_900_000_000, tz=timezone.utc)
|
|
assert "Credits balance: $12.50" in snapshot.details
|
|
|
|
|
|
def _register_profile(monkeypatch, profile):
|
|
import providers
|
|
|
|
monkeypatch.setattr(providers, "_REGISTRY", dict(providers._REGISTRY))
|
|
monkeypatch.setattr(providers, "_ALIASES", dict(providers._ALIASES))
|
|
providers.register_provider(profile)
|
|
|
|
|
|
def test_fetch_account_usage_reaches_registered_plugin_profile_and_fails_open(monkeypatch):
|
|
"""A profile registered through the public registry (as a plugin does) feeds /usage; a profile
|
|
without the hook, or one whose hook raises, is indistinguishable from today's empty block."""
|
|
snapshot = AccountUsageSnapshot(
|
|
provider="plugin-usage", source="plugin", fetched_at=datetime.now(timezone.utc),
|
|
details=("Credit: 10/100",),
|
|
)
|
|
profile = _UsageProfile(snapshot)
|
|
_register_profile(monkeypatch, profile)
|
|
_register_profile(monkeypatch, ProviderProfile(name="plugin-silent"))
|
|
_register_profile(monkeypatch, _UsageProfile(error=RuntimeError("nope"), name="plugin-broken"))
|
|
|
|
assert fetch_account_usage("plugin-usage", base_url="https://plugin.test", api_key="key") is snapshot
|
|
assert profile.calls == 1
|
|
assert fetch_account_usage("plugin-silent") is None
|
|
assert fetch_account_usage("plugin-broken") is None
|
|
|
|
|
|
def test_fetch_account_usage_prefers_builtin_fetcher_over_profile(monkeypatch):
|
|
builtin = AccountUsageSnapshot(
|
|
provider="openrouter", source="builtin", fetched_at=datetime.now(timezone.utc),
|
|
)
|
|
profile = _UsageProfile(
|
|
AccountUsageSnapshot(provider="openrouter", source="plugin", fetched_at=datetime.now(timezone.utc)),
|
|
name="openrouter",
|
|
)
|
|
monkeypatch.setattr("agent.account_usage._USAGE_FETCHERS", {"openrouter": lambda base_url, api_key: builtin})
|
|
_register_profile(monkeypatch, profile)
|
|
|
|
assert fetch_account_usage("openrouter") is builtin
|
|
assert profile.calls == 0
|
|
|
|
|
|
def test_render_account_usage_lines_includes_reset_and_provider():
|
|
snapshot = AccountUsageSnapshot(
|
|
provider="openai-codex",
|
|
source="usage_api",
|
|
fetched_at=datetime.now(timezone.utc),
|
|
plan="Pro",
|
|
windows=(
|
|
AccountUsageWindow(
|
|
label="Session",
|
|
used_percent=25,
|
|
reset_at=datetime.now(timezone.utc),
|
|
),
|
|
),
|
|
details=("Credits balance: $9.99",),
|
|
)
|
|
lines = render_account_usage_lines(snapshot)
|
|
|
|
assert lines[0] == "📈 Account limits"
|
|
assert "openai-codex (Pro)" in lines[1]
|
|
assert "Session: 75% remaining (25% used)" in lines[2]
|
|
assert "Credits balance: $9.99" in lines[3]
|
|
|
|
|
|
def test_fetch_account_usage_openrouter_uses_limit_remaining_and_ignores_deprecated_rate_limit(monkeypatch):
|
|
monkeypatch.setattr(
|
|
"agent.account_usage.resolve_runtime_provider",
|
|
lambda requested, explicit_base_url=None, explicit_api_key=None: {
|
|
"provider": "openrouter",
|
|
"base_url": "https://openrouter.ai/api/v1",
|
|
"api_key": "sk-test",
|
|
},
|
|
)
|
|
monkeypatch.setattr(
|
|
"agent.account_usage.httpx.Client",
|
|
lambda timeout=10.0: _RoutingClient(
|
|
{
|
|
"https://openrouter.ai/api/v1/credits": {
|
|
"data": {"total_credits": 300.0, "total_usage": 10.92}
|
|
},
|
|
"https://openrouter.ai/api/v1/key": {
|
|
"data": {
|
|
"limit": 100.0,
|
|
"limit_remaining": 70.0,
|
|
"limit_reset": "monthly",
|
|
"usage": 12.5,
|
|
"usage_daily": 0.5,
|
|
"usage_weekly": 2.0,
|
|
"usage_monthly": 8.0,
|
|
"rate_limit": {"requests": -1, "interval": "10s"},
|
|
}
|
|
},
|
|
}
|
|
),
|
|
)
|
|
|
|
snapshot = fetch_account_usage("openrouter")
|
|
|
|
assert snapshot is not None
|
|
assert snapshot.windows == (
|
|
AccountUsageWindow(
|
|
label="API key quota",
|
|
used_percent=30.0,
|
|
detail="$70.00 of $100.00 remaining • resets monthly",
|
|
),
|
|
)
|
|
assert "Credits balance: $289.08" in snapshot.details
|
|
assert "API key usage: $12.50 total • $0.50 today • $2.00 this week • $8.00 this month" in snapshot.details
|
|
assert all("-1 requests / 10s" not in line for line in render_account_usage_lines(snapshot))
|
|
|
|
|
|
def test_fetch_account_usage_openrouter_omits_quota_window_when_key_has_no_limit(monkeypatch):
|
|
monkeypatch.setattr(
|
|
"agent.account_usage.resolve_runtime_provider",
|
|
lambda requested, explicit_base_url=None, explicit_api_key=None: {
|
|
"provider": "openrouter",
|
|
"base_url": "https://openrouter.ai/api/v1",
|
|
"api_key": "sk-test",
|
|
},
|
|
)
|
|
monkeypatch.setattr(
|
|
"agent.account_usage.httpx.Client",
|
|
lambda timeout=10.0: _RoutingClient(
|
|
{
|
|
"https://openrouter.ai/api/v1/credits": {
|
|
"data": {"total_credits": 100.0, "total_usage": 25.5}
|
|
},
|
|
"https://openrouter.ai/api/v1/key": {
|
|
"data": {
|
|
"limit": None,
|
|
"limit_remaining": None,
|
|
"usage": 25.5,
|
|
"usage_daily": 1.25,
|
|
"usage_weekly": 4.5,
|
|
"usage_monthly": 18.0,
|
|
}
|
|
},
|
|
}
|
|
),
|
|
)
|
|
|
|
snapshot = fetch_account_usage("openrouter")
|
|
|
|
assert snapshot is not None
|
|
assert snapshot.windows == ()
|
|
assert "Credits balance: $74.50" in snapshot.details
|
|
assert "API key usage: $25.50 total • $1.25 today • $4.50 this week • $18.00 this month" in snapshot.details
|
|
|
|
|
|
def test_plugin_usage_hook_is_bounded_and_fails_open(monkeypatch):
|
|
"""A plugin hook that overruns the shared deadline yields None within deadline+1 s on every surface
|
|
(gateway/TUI call ``fetch_account_usage`` with no bound of their own); built-in fetchers are untouched."""
|
|
import threading
|
|
import time
|
|
|
|
from agent import account_usage
|
|
|
|
started = threading.Event()
|
|
|
|
class _Hang(ProviderProfile):
|
|
def fetch_account_usage(self, *, base_url=None, api_key=None):
|
|
started.set()
|
|
time.sleep(5)
|
|
return AccountUsageSnapshot(provider=self.name, source="late", fetched_at=datetime.now(timezone.utc))
|
|
|
|
_register_profile(monkeypatch, _Hang(name="plugin-hang"))
|
|
monkeypatch.setattr(account_usage, "PLUGIN_USAGE_HOOK_DEADLINE_S", 0.3)
|
|
builtin_calls = []
|
|
monkeypatch.setattr(account_usage, "_USAGE_FETCHERS",
|
|
{"openrouter": lambda base_url, api_key: builtin_calls.append(1)})
|
|
|
|
t0 = time.monotonic()
|
|
assert account_usage.fetch_account_usage("plugin-hang") is None
|
|
assert time.monotonic() - t0 < 1.3 and started.is_set()
|
|
account_usage.fetch_account_usage("openrouter")
|
|
assert builtin_calls == [1]
|
|
|
|
|
|
def test_plugin_usage_hook_failure_never_reaches_threading_excepthook(monkeypatch):
|
|
"""A raising hook fails open in the caller — it must not die on a worker thread, where
|
|
``threading.excepthook`` prints a traceback into every ``/usage`` surface."""
|
|
import threading
|
|
|
|
from agent import account_usage
|
|
|
|
class _Boom(ProviderProfile):
|
|
def fetch_account_usage(self, *, base_url=None, api_key=None):
|
|
raise RuntimeError("boom from plugin")
|
|
|
|
_register_profile(monkeypatch, _Boom(name="plugin-boom"))
|
|
monkeypatch.setattr(account_usage, "_USAGE_FETCHERS", {})
|
|
escaped = []
|
|
monkeypatch.setattr(threading, "excepthook", lambda args: escaped.append(args.exc_value))
|
|
|
|
assert account_usage.fetch_account_usage("plugin-boom") is None
|
|
for t in threading.enumerate():
|
|
if t is not threading.current_thread() and "account-usage" in t.name:
|
|
t.join(2)
|
|
assert escaped == []
|
|
|
|
|
|
def test_base_noop_usage_hook_spawns_no_thread(monkeypatch):
|
|
"""A profile inheriting ``ProviderProfile.fetch_account_usage`` costs no thread."""
|
|
import threading
|
|
|
|
from agent import account_usage
|
|
|
|
_register_profile(monkeypatch, ProviderProfile(name="plugin-noop"))
|
|
monkeypatch.setattr(account_usage, "_USAGE_FETCHERS", {})
|
|
monkeypatch.setattr(threading.Thread, "start",
|
|
lambda self: pytest.fail(f"base no-op hook spawned thread {self.name!r}"))
|
|
|
|
assert account_usage.fetch_account_usage("plugin-noop") is None
|
|
|
|
|
|
def test_fetch_portal_account_is_wall_clock_bounded(monkeypatch):
|
|
"""A portal that accepts the connection but never answers must release the
|
|
caller at ``timeout``, not when the wedged worker finishes on its own
|
|
(``Executor.__exit__`` used to join it via ``shutdown(wait=True)``)."""
|
|
release = threading.Event()
|
|
|
|
def hanging_portal_fetch(*, force_fresh):
|
|
release.wait(timeout=30)
|
|
return object()
|
|
|
|
monkeypatch.setattr(
|
|
"hermes_cli.nous_account.get_nous_portal_account_info", hanging_portal_fetch
|
|
)
|
|
started = time.monotonic()
|
|
try:
|
|
with pytest.raises(concurrent.futures.TimeoutError):
|
|
_fetch_portal_account(timeout=0.5)
|
|
finally:
|
|
release.set()
|
|
assert time.monotonic() - started < 10
|
|
|
|
|
|
def test_fetch_portal_account_returns_value_and_keeps_caller_context(monkeypatch):
|
|
marker = contextvars.ContextVar("portal_fetch_test_marker", default="unset")
|
|
sentinel = object()
|
|
seen = {}
|
|
|
|
def probing_portal_fetch(*, force_fresh):
|
|
seen["force_fresh"] = force_fresh
|
|
seen["marker"] = marker.get()
|
|
return sentinel
|
|
|
|
monkeypatch.setattr(
|
|
"hermes_cli.nous_account.get_nous_portal_account_info", probing_portal_fetch
|
|
)
|
|
token = marker.set("profile-scope")
|
|
try:
|
|
assert _fetch_portal_account(timeout=5) is sentinel
|
|
finally:
|
|
marker.reset(token)
|
|
assert seen == {"force_fresh": True, "marker": "profile-scope"}
|
|
|
|
|
|
def test_fetch_portal_account_propagates_worker_error(monkeypatch):
|
|
def failing_portal_fetch(*, force_fresh):
|
|
raise RuntimeError("portal down")
|
|
|
|
monkeypatch.setattr(
|
|
"hermes_cli.nous_account.get_nous_portal_account_info", failing_portal_fetch
|
|
)
|
|
with pytest.raises(RuntimeError, match="portal down"):
|
|
_fetch_portal_account(timeout=5)
|