Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
201 changes: 201 additions & 0 deletions tests/tui_gateway/test_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -740,3 +740,204 @@ def test_unregister_live_transport_stops_delivery(capture):
# No live transports left → fell back to stdio.
assert json.loads(buf.getvalue())["params"]["type"] == "skin.changed"


def test_usage_live_and_session_output_include_provider_limits(server, monkeypatch):
from agent import account_usage

class Agent:
provider = "openai-codex"
base_url = "https://example.invalid/v1"
api_key = "secret"
model = "gpt-test"
session_input_tokens = 123
session_output_tokens = 45
session_total_tokens = 168
session_api_calls = 2
session_reasoning_tokens = 0
session_prompt_tokens = 123
session_completion_tokens = 45
context_compressor = None

def get_rate_limit_state(self):
return types.SimpleNamespace(has_data=True)

fetch = MagicMock(return_value=object())
monkeypatch.setattr(account_usage, "fetch_account_usage", fetch)
monkeypatch.setattr(
account_usage,
"render_account_usage_lines",
lambda _: ["Account limits", "Session: 93% remaining"],
)
monkeypatch.setattr(account_usage, "nous_credits_lines", lambda: [])
monkeypatch.setattr(
"agent.rate_limit_tracker.format_rate_limit_display",
lambda _: "Requests: 50% remaining",
)
server._sessions["usage-live"] = {
"session_key": "usage-live",
"agent": Agent(),
"history": [],
"history_lock": threading.Lock(),
}
desktop = server.handle_request(
{
"id": 1,
"method": "slash.exec",
"params": {"session_id": "usage-live", "command": "usage"},
}
)
tui = server.handle_request(
{
"id": 2,
"method": "session.usage",
"params": {"session_id": "usage-live"},
}
)
assert "Session Token Usage" in desktop["result"]["output"]
assert "Session: 93% remaining" in desktop["result"]["output"]
assert "Requests: 50% remaining" in desktop["result"]["output"]
assert "secret" not in desktop["result"]["output"]
assert tui["result"]["account_lines"] == ["Account limits", "Session: 93% remaining"]
assert tui["result"]["rate_limit_lines"] == ["Requests: 50% remaining"]
assert fetch.call_args.args == ("openai-codex",)


def test_usage_no_agent_uses_metadata_and_codex_pool_fallback(server, monkeypatch):
from agent import account_usage

seen = []

def fetch(provider, **kwargs):
seen.append({"provider": provider, **kwargs})
return object()

monkeypatch.setattr(account_usage, "fetch_account_usage", fetch)
monkeypatch.setattr(
account_usage,
"render_account_usage_lines",
lambda _: ["Weekly: 80% remaining"],
)
monkeypatch.setattr(account_usage, "nous_credits_lines", lambda: [])
server._sessions["usage-mirror"] = {
"session_key": "usage-mirror",
"agent": None,
"_metadata_mirror": {
"provider": "openai-codex",
"usage": {"calls": 1},
},
"history": [],
"history_lock": threading.Lock(),
}
desktop = server.handle_request(
{
"id": 1,
"method": "slash.exec",
"params": {"session_id": "usage-mirror", "command": "usage"},
}
)
tui = server.handle_request(
{
"id": 2,
"method": "session.usage",
"params": {"session_id": "usage-mirror"},
}
)
assert "Session Token Usage" in desktop["result"]["output"]
assert "Weekly: 80% remaining" in desktop["result"]["output"]
assert tui["result"]["account_lines"] == ["Weekly: 80% remaining"]
assert seen == [
{"provider": "openai-codex", "base_url": None, "api_key": None},
{"provider": "openai-codex", "base_url": None, "api_key": None},
]


def test_usage_provider_failure_is_fail_open(server, monkeypatch):
from agent import account_usage

monkeypatch.setattr(
account_usage,
"fetch_account_usage",
MagicMock(side_effect=RuntimeError("offline")),
)
monkeypatch.setattr(account_usage, "nous_credits_lines", lambda: [])
server._sessions["usage-fail"] = {
"session_key": "usage-fail",
"agent": None,
"_metadata_mirror": {
"provider": "openai-codex",
"usage": {"calls": 0},
},
"history": [],
"history_lock": threading.Lock(),
}
response = server.handle_request(
{
"id": 1,
"method": "session.usage",
"params": {"session_id": "usage-fail"},
}
)
assert "error" not in response and "account_lines" not in response["result"]


def test_usage_provider_timeout_is_prompt_and_fail_open(server, monkeypatch):
from agent import account_usage

release_fetch = threading.Event()
fetch_started = threading.Event()

def fetch(*args, **kwargs):
fetch_started.set()
release_fetch.wait()
return object()

monkeypatch.setattr(server, "_ACCOUNT_USAGE_TIMEOUT_SECONDS", 0.01)
monkeypatch.setattr(account_usage, "fetch_account_usage", fetch)
monkeypatch.setattr(account_usage, "nous_credits_lines", lambda: [])
server._sessions["usage-timeout"] = {
"session_key": "usage-timeout",
"agent": None,
"_metadata_mirror": {
"provider": "openai-codex",
"usage": {"calls": 0},
},
"history": [],
"history_lock": threading.Lock(),
}

started = time.monotonic()
try:
response = server.handle_request(
{
"id": 1,
"method": "session.usage",
"params": {"session_id": "usage-timeout"},
}
)
elapsed = time.monotonic() - started
finally:
release_fetch.set()

assert fetch_started.is_set()
assert elapsed < 0.5
assert "error" not in response
assert "account_lines" not in response["result"]


@pytest.mark.parametrize("command", ["usage reset --force", "usage unexpected"])
def test_usage_subcommands_fall_through_to_worker(server, command):
worker = MagicMock(run=MagicMock(return_value="worker result"))
server._sessions["usage-worker"] = {
"session_key": "usage-worker",
"agent": None,
"slash_worker": worker,
}
response = server.handle_request(
{
"id": 1,
"method": "slash.exec",
"params": {"session_id": "usage-worker", "command": command},
}
)
assert response["result"] == {"output": "worker result"}
worker.run.assert_called_once_with(command)
5 changes: 5 additions & 0 deletions tui_gateway/methods_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -1363,6 +1363,11 @@ def _(rid, params: dict) -> dict:
usage: dict = _session_usage_snapshot(session)
if agent is None and not usage:
usage = {"calls": 0, "input": 0, "output": 0, "total": 0}
account_lines, rate_limit_lines = _usage_provider_lines(session)
if account_lines:
usage["account_lines"] = account_lines
if rate_limit_lines:
usage["rate_limit_lines"] = rate_limit_lines
# Nous credits block — agent-independent (a portal fetch), so it shows even
# with zero API calls or on a resumed session. The TUI /usage panel renders
# these lines regardless of `calls`. Fail-open: [] when not logged into Nous
Expand Down
86 changes: 86 additions & 0 deletions tui_gateway/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,16 @@ def _thread_panic_hook(args):
}
)

# Quota endpoints are remote and cosmetic. Keep timed-out calls off both the
# RPC reader and the general long-handler pool.
_account_usage_pool = concurrent.futures.ThreadPoolExecutor(
max_workers=2, thread_name_prefix="hermes-account-usage"
)
atexit.register(
lambda: _account_usage_pool.shutdown(wait=False, cancel_futures=True)
)
_ACCOUNT_USAGE_TIMEOUT_SECONDS = 10.0

try:
_rpc_pool_workers = max(
2, int(os.environ.get("HERMES_TUI_RPC_POOL_WORKERS") or "8")
Expand Down Expand Up @@ -12509,9 +12519,83 @@ def _format_live_usage_output(session: dict) -> str:
f"Compressions: {int(usage.get('compressions') or 0):,}",
]
)
account_lines, rate_limit_lines = _usage_provider_lines(session)
if account_lines:
lines.extend(["", *account_lines])
if rate_limit_lines:
lines.extend(["", *rate_limit_lines])
return "\n".join(lines)


def _usage_provider_identity(session: dict) -> tuple[str, str, Any]:
"""Resolve quota inputs without persisting or returning credentials."""
agent = session.get("agent")
mirror = _metadata_mirror(session)
provider = str(getattr(agent, "provider", "") if agent else "").strip() or str(mirror.get("provider") or "").strip()
base_url = str(getattr(agent, "base_url", "") if agent else "").strip() or str(mirror.get("base_url") or "").strip()
api_key = getattr(agent, "api_key", None) if agent else None
if not provider or not base_url:
token = None
try:
if session.get("profile_home"):
token = set_hermes_home_override(Path(session["profile_home"]))
cfg = _load_cfg()
model_cfg = cfg.get("model") if isinstance(cfg, dict) else {}
model_cfg = model_cfg if isinstance(model_cfg, dict) else {}
configured_provider = str(model_cfg.get("provider") or "").strip()
if not provider:
provider = configured_provider
# Never combine a mirrored provider with another provider's URL.
if not base_url and provider == configured_provider:
base_url = str(model_cfg.get("base_url") or "").strip()
except Exception:
pass
finally:
if token is not None:
reset_hermes_home_override(token)
return provider, base_url, api_key


def _usage_provider_lines(session: dict) -> tuple[list[str], list[str]]:
"""Return bounded, fail-open provider account and rate-limit lines."""
account_lines: list[str] = []
rate_limit_lines: list[str] = []
provider, base_url, api_key = _usage_provider_identity(session)
if provider:
try:
from agent.account_usage import fetch_account_usage, render_account_usage_lines

def fetch():
token = None
try:
if session.get("profile_home"):
token = set_hermes_home_override(Path(session["profile_home"]))
return fetch_account_usage(provider, base_url=base_url or None, api_key=api_key)
finally:
if token is not None:
reset_hermes_home_override(token)

future = _account_usage_pool.submit(fetch)
try:
snapshot = future.result(timeout=_ACCOUNT_USAGE_TIMEOUT_SECONDS)
except concurrent.futures.TimeoutError:
future.cancel()
raise
account_lines = list(render_account_usage_lines(snapshot) or [])
except Exception:
account_lines = []
agent = session.get("agent")
if agent is not None:
try:
state = agent.get_rate_limit_state()
if state and getattr(state, "has_data", False):
from agent.rate_limit_tracker import format_rate_limit_display
rate_limit_lines = format_rate_limit_display(state).splitlines()
except Exception:
rate_limit_lines = []
return account_lines, rate_limit_lines


def _format_live_history_output(session: dict) -> str:
with session["history_lock"]:
history = list(session.get("history", []))
Expand Down Expand Up @@ -12665,6 +12749,8 @@ def _live_slash_command_output(sid: str, session: Optional[dict], name: str, arg
return "no active session for /compress"
return _mirror_slash_side_effects(sid, session, f"/compress {arg}".strip())
if name == "usage":
if arg.strip():
return None
if session is None:
return "(._.) No active agent -- send a message first."
return _format_live_usage_output(session)
Expand Down
18 changes: 18 additions & 0 deletions ui-tui/src/__tests__/usageCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,4 +121,22 @@ describe('/usage slash command', () => {
expect(body).toContain('free models only')
expect(body).toContain('/subscription')
})

it('renders provider account and rate-limit lines before balance and token usage', async () => {
const { panel, run, sys } = buildCtx({
'session.usage': baseUsage({
calls: 2,
account_lines: ['Account limits', 'Weekly: 80% remaining'],
rate_limit_lines: ['Requests: 50% remaining'],
usage: { available: true, status: 'free', plan_name: null }
})
})

await run('')

expect(printed(sys)).toContain('Weekly: 80% remaining')
expect(printed(sys)).toContain('Requests: 50% remaining')
expect(panel.mock.calls.map(call => call[0])).toEqual(['Balance', 'Usage'])
expect(sys.mock.invocationCallOrder[0]).toBeLessThan(panel.mock.invocationCallOrder[0])
})
})
11 changes: 11 additions & 0 deletions ui-tui/src/app/slash/commands/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -659,6 +659,17 @@ export const sessionCommands: SlashCommand[] = [
})
}

const accountLines = r?.account_lines ?? []
const rateLimitLines = r?.rate_limit_lines ?? []

if (accountLines.length) {
sys(accountLines.join('\n'))
}

if (rateLimitLines.length) {
sys(rateLimitLines.join('\n'))
}

// Nous balance block is agent-independent (a portal fetch), so it shows
// even with zero API calls or on a resumed session. Prefer the shared
// dollar usage model (two-bar view, dollars-only); fall back to the
Expand Down
2 changes: 2 additions & 0 deletions ui-tui/src/gatewayTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ export interface SessionUndoResponse {
}

export interface SessionUsageResponse {
account_lines?: string[]
active_subagents?: number
cache_read?: number
cache_write?: number
Expand All @@ -273,6 +274,7 @@ export interface SessionUsageResponse {
input?: number
model?: string
output?: number
rate_limit_lines?: string[]
total?: number
// Shared dollar usage model (two-bar view) so /usage renders the same bars
// as /subscription. Dollars only — never "credits".
Expand Down