mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-14 09:11:54 +00:00
* feat(billing): /usage → portal top-up browser handoff
Add the terminal side of the billing slice (phase 2a): start a top-up by
throwing the user to the portal billing page with the top-up modal open. The
terminal does not confirm, poll, or track payment — checkout completes in the
browser and the next /usage shows the new balance.
- nous_account.py: parse organisation.slug/name from /api/oauth/account into
NousPortalAccountInfo; add nous_portal_topup_url() building the org-pinned
{base}/orgs/{slug}/billing?topup=open with a null-slug fallback to the legacy
{base}/billing?topup=open (never /orgs/None/...).
- portal_cli.py: 'hermes portal topup' — fresh account fetch, identity line
(Topping up as <email> / org <name>), browser open with printed-URL fallback,
no-wait closing copy. No polling/confirmation (deferred to 2b).
- account_usage.py: the shared /usage credits block now links the org-pinned
top-up URL (auto-opens the modal) + points to the command.
Depends on NAS #409 (organisation.slug/name + ?topup=open). Do not merge until
that is live on the target env; until then /api/oauth/account returns
organisation: { id } only and the URL falls back to legacy.
* feat(billing): /credits command for balance + top-up handoff
Replace the standalone `hermes portal topup` subcommand with an in-session
/credits slash command — a focused money surface (balance in, top-up out) that
works in the CLI, TUI, and every messaging platform from one registry entry.
- commands.py: register /credits (Info category). Slack is at its 50-slash cap,
so /credits is routed via /hermes credits on Slack only (new
_SLACK_VIA_HERMES_ONLY set) to avoid clamping a canonical command off the
native list and breaking Telegram parity; native everywhere else.
- account_usage.py: build_credits_view() — one portal fetch → balance lines +
identity line + org-pinned top-up URL + depleted flag, consumed by all
surfaces. Reuses the same snapshot/URL builder as /usage so numbers match.
- cli.py: _show_credits() — balance block + identity line + 3-button panel
(Open top-up / Copy link / Cancel) via the existing prompt_toolkit modal.
ASK, never auto-launch; headless falls back to printing the URL.
- gateway/slash_commands.py: _handle_credits_command() — renders the block +
tappable top-up URL + no-wait copy; works on button and plain-text platforms.
- /usage credits line now points to /credits.
- Retire `hermes portal topup` (portal_cli.py back to baseline); the engine
(slug/name parse + nous_portal_topup_url) stays as the shared core.
No polling, no payment confirmation (billing phase 2a). Depends on NAS #409.
* fix(credits): /credits works in the TUI slash-worker (non-interactive)
In the TUI, /credits runs in the slash-worker subprocess where there is no
live prompt_toolkit app and stdin is the JSON-RPC pipe. _show_credits called
the 3-button modal unconditionally, which fell back to reading stdin →
exception → slash.exec rejected → the command produced no output (only the
pre-existing 'Credit access paused' banner showed).
- _show_credits: when self._app is None (TUI worker / piped / non-interactive),
render the text variant — balance block + tappable top-up URL + no-wait line,
same affordance as the messaging surfaces — and skip the modal entirely. The
3-button panel still renders in the interactive CLI.
- Depleted banner copy: 'run /usage for balance' → 'run /credits to top up'
now that /credits is the dedicated money surface (+ tests).
- Regression tests: _show_credits with self._app=None renders text and never
invokes the modal; logged-out path.
* feat(tui): credits.view RPC for the /credits tappable top-up button
Add a credits.view JSON-RPC method returning the structured CreditsView
(logged_in, balance_lines, identity_line, topup_url, depleted) so the TUI can
render a clickable <Link> top-up button instead of plain text. Account-
independent (portal fetch gated on a logged-in Nous account), fail-open to
{logged_in: false} on any hiccup. Mirrors session.usage's credits-block pattern.
Frontend (TUI-local /credits command + Ink component) lands separately.
* feat(tui): /credits command with keyboard-driven top-up confirm
TUI-local /credits: fetches the structured balance via the credits.view RPC,
prints the balance + identity + top-up URL, then arms the EXISTING confirm
overlay (Enter = open top-up in browser via openExternalUrl, Esc = cancel).
Reuses ConfirmReq — no new overlay component/state/input handler. Headless
(openExternalUrl returns false) falls back to printing the URL.
- gatewayTypes.ts: CreditsViewResponse.
- commands/credits.ts: the command (mirrors /status's rpc+guarded pattern).
- registry.ts: register creditsCommands.
- test: balance+overlay armed, headless fallback, no-url, logged-out (4 cases).
Matches the CLI /credits 'Enter to open' affordance. Phase 2a: no polling.
638 lines
24 KiB
Python
638 lines
24 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
import math
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
from typing import TYPE_CHECKING, Any, Optional
|
|
|
|
import httpx
|
|
|
|
from agent.anthropic_adapter import _is_oauth_token, resolve_anthropic_token
|
|
from hermes_cli.auth import _read_codex_tokens, resolve_codex_runtime_credentials
|
|
from hermes_cli.runtime_provider import resolve_runtime_provider
|
|
|
|
if TYPE_CHECKING:
|
|
from typing import TypeGuard
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _utc_now() -> datetime:
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AccountUsageWindow:
|
|
label: str
|
|
used_percent: Optional[float] = None
|
|
reset_at: Optional[datetime] = None
|
|
detail: Optional[str] = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AccountUsageSnapshot:
|
|
provider: str
|
|
source: str
|
|
fetched_at: datetime
|
|
title: str = "Account limits"
|
|
plan: Optional[str] = None
|
|
windows: tuple[AccountUsageWindow, ...] = ()
|
|
details: tuple[str, ...] = ()
|
|
unavailable_reason: Optional[str] = None
|
|
|
|
@property
|
|
def available(self) -> bool:
|
|
return bool(self.windows or self.details) and not self.unavailable_reason
|
|
|
|
|
|
def _title_case_slug(value: Optional[str]) -> Optional[str]:
|
|
cleaned = str(value or "").strip()
|
|
if not cleaned:
|
|
return None
|
|
return cleaned.replace("_", " ").replace("-", " ").title()
|
|
|
|
|
|
def _parse_dt(value: Any) -> Optional[datetime]:
|
|
if value in {None, ""}:
|
|
return None
|
|
if isinstance(value, (int, float)):
|
|
return datetime.fromtimestamp(float(value), tz=timezone.utc)
|
|
if isinstance(value, str):
|
|
text = value.strip()
|
|
if not text:
|
|
return None
|
|
if text.endswith("Z"):
|
|
text = text[:-1] + "+00:00"
|
|
try:
|
|
dt = datetime.fromisoformat(text)
|
|
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
|
except ValueError:
|
|
return None
|
|
return None
|
|
|
|
|
|
def _format_reset(dt: Optional[datetime]) -> str:
|
|
if not dt:
|
|
return "unknown"
|
|
local_dt = dt.astimezone()
|
|
delta = dt - _utc_now()
|
|
total_seconds = int(delta.total_seconds())
|
|
if total_seconds <= 0:
|
|
return f"now ({local_dt.strftime('%Y-%m-%d %H:%M %Z')})"
|
|
hours, rem = divmod(total_seconds, 3600)
|
|
minutes = rem // 60
|
|
if hours >= 24:
|
|
days, hours = divmod(hours, 24)
|
|
rel = f"in {days}d {hours}h"
|
|
elif hours > 0:
|
|
rel = f"in {hours}h {minutes}m"
|
|
else:
|
|
rel = f"in {minutes}m"
|
|
return f"{rel} ({local_dt.strftime('%Y-%m-%d %H:%M %Z')})"
|
|
|
|
|
|
def render_account_usage_lines(snapshot: Optional[AccountUsageSnapshot], *, markdown: bool = False) -> list[str]:
|
|
if not snapshot:
|
|
return []
|
|
header = f"📈 {'**' if markdown else ''}{snapshot.title}{'**' if markdown else ''}"
|
|
lines = [header]
|
|
if snapshot.plan:
|
|
lines.append(f"Provider: {snapshot.provider} ({snapshot.plan})")
|
|
else:
|
|
lines.append(f"Provider: {snapshot.provider}")
|
|
for window in snapshot.windows:
|
|
if window.used_percent is None:
|
|
base = f"{window.label}: unavailable"
|
|
else:
|
|
remaining = max(0, round(100 - float(window.used_percent)))
|
|
used = max(0, round(float(window.used_percent)))
|
|
base = f"{window.label}: {remaining}% remaining ({used}% used)"
|
|
if window.reset_at:
|
|
base += f" • resets {_format_reset(window.reset_at)}"
|
|
elif window.detail:
|
|
base += f" • {window.detail}"
|
|
lines.append(base)
|
|
for detail in snapshot.details:
|
|
lines.append(detail)
|
|
if snapshot.unavailable_reason:
|
|
lines.append(f"Unavailable: {snapshot.unavailable_reason}")
|
|
return lines
|
|
|
|
|
|
def _fmt_usd(d: float) -> str:
|
|
return f"${d:,.2f}"
|
|
|
|
|
|
def _is_finite_num(v: Any) -> TypeGuard[float]:
|
|
"""True iff v is a real numeric value (int or float, not bool, not NaN/Inf).
|
|
|
|
Typed as a ``TypeGuard[float]`` so the type checker narrows ``v`` to a real
|
|
number in the positive branch — callers can then do arithmetic / pass it to
|
|
``_fmt_usd`` without a None-operand warning.
|
|
"""
|
|
return isinstance(v, (int, float)) and not isinstance(v, bool) and math.isfinite(v)
|
|
|
|
|
|
def build_nous_credits_snapshot(account_info) -> Optional[AccountUsageSnapshot]:
|
|
"""Map a NousPortalAccountInfo into an AccountUsageSnapshot for /usage.
|
|
|
|
Shows dollar magnitudes (subscription / top-up / total) + renewal date + a
|
|
portal CTA. When the portal supplies a subscription denominator
|
|
(``monthly_credits``), also emits a subscription-usage window so the renderer
|
|
shows a real ``% used`` gauge; when it's absent (older portals) the view
|
|
gracefully degrades to magnitudes-only. Returns None when there's no usable
|
|
account info to show (fail-open: caller just shows nothing).
|
|
"""
|
|
try:
|
|
from hermes_cli.nous_account import nous_portal_topup_url
|
|
|
|
if account_info is None or not getattr(account_info, "logged_in", False):
|
|
return None
|
|
|
|
access = getattr(account_info, "paid_service_access_info", None)
|
|
sub = getattr(account_info, "subscription", None)
|
|
|
|
windows: list[AccountUsageWindow] = []
|
|
details: list[str] = []
|
|
|
|
# Subscription usage gauge — only when the portal supplies a positive
|
|
# monthly_credits denominator AND a finite remaining balance that does
|
|
# not exceed the cap. Money math is on float dollars (allowed: numeric
|
|
# account fields, NOT a server-provided *_usd string). used = cap -
|
|
# remaining; clamp [0,100] so a debt balance (remaining < 0) reads 100%.
|
|
# Excluded on purpose:
|
|
# - non-finite values (NaN/Infinity slip past isinstance and json.loads
|
|
# parses bare NaN/Infinity by default) → would render "$nan"/"$inf"
|
|
# and a falsely-confident gauge;
|
|
# - remaining > cap (rollover balance spanning the period) → monthly_credits
|
|
# is no longer a meaningful denominator, and "$X of $Y left" with X>Y
|
|
# reads as a contradiction. Both fall back to the magnitudes lines.
|
|
if sub is not None:
|
|
monthly_credits = getattr(sub, "monthly_credits", None)
|
|
sub_remaining = getattr(sub, "credits_remaining", None)
|
|
if (
|
|
_is_finite_num(monthly_credits)
|
|
and monthly_credits > 0
|
|
and _is_finite_num(sub_remaining)
|
|
and sub_remaining <= monthly_credits
|
|
):
|
|
used = monthly_credits - sub_remaining
|
|
used_pct = max(0.0, min(100.0, used / monthly_credits * 100.0))
|
|
windows.append(
|
|
AccountUsageWindow(
|
|
label="Subscription",
|
|
used_percent=used_pct,
|
|
detail=f"{_fmt_usd(sub_remaining)} of {_fmt_usd(monthly_credits)} left",
|
|
)
|
|
)
|
|
|
|
if access is not None:
|
|
sub_credits = getattr(access, "subscription_credits_remaining", None)
|
|
if _is_finite_num(sub_credits):
|
|
details.append(f"Subscription credits: {_fmt_usd(sub_credits)}")
|
|
purchased = getattr(access, "purchased_credits_remaining", None)
|
|
if _is_finite_num(purchased):
|
|
details.append(f"Top-up credits: {_fmt_usd(purchased)}")
|
|
total_usable = getattr(access, "total_usable_credits", None)
|
|
if _is_finite_num(total_usable):
|
|
details.append(f"Total usable: {_fmt_usd(total_usable)}")
|
|
|
|
if sub is not None:
|
|
rollover = getattr(sub, "rollover_credits", None)
|
|
if _is_finite_num(rollover) and rollover > 0:
|
|
details.append(f"Rollover: {_fmt_usd(rollover)}")
|
|
period_end = getattr(sub, "current_period_end", None)
|
|
if period_end:
|
|
details.append(f"Renews: {period_end}")
|
|
|
|
paid = getattr(account_info, "paid_service_access", None)
|
|
if paid is False:
|
|
details.append("Status: access depleted — top up to restore")
|
|
|
|
if not windows and not details:
|
|
return None
|
|
|
|
details.append(f"Top up: {nous_portal_topup_url(account_info)}")
|
|
details.append("(or run /credits)")
|
|
|
|
plan = getattr(sub, "plan", None) if sub is not None else None
|
|
return AccountUsageSnapshot(
|
|
provider="nous",
|
|
source="portal-account",
|
|
fetched_at=_utc_now(),
|
|
title="Nous credits",
|
|
plan=plan,
|
|
windows=tuple(windows),
|
|
details=tuple(details),
|
|
)
|
|
except (AttributeError, TypeError):
|
|
return None
|
|
|
|
|
|
def nous_credits_lines(*, markdown: bool = False, timeout: float = 10.0) -> list[str]:
|
|
"""Return rendered Nous-credits /usage lines, or [] when there's nothing to show.
|
|
|
|
Account-independent of any live agent: gated on "a Nous account is logged in"
|
|
(a cheap local auth-state check), then a wall-clock-bounded portal fetch. Shared
|
|
by the CLI ``_show_usage`` and the TUI ``session.usage`` RPC so both surfaces show
|
|
the same block regardless of session API-call count or resume state. Fail-open:
|
|
any auth/portal hiccup or timeout returns [] (the caller shows nothing).
|
|
|
|
Dev override: when HERMES_DEV_CREDITS_FIXTURE selects a fixture state, /usage
|
|
renders from that fixture instead of the real portal (so the block + gauge are
|
|
testable without a live account). Throwaway scaffolding.
|
|
"""
|
|
# Dev fixture short-circuit — render /usage from the injected state, no portal.
|
|
try:
|
|
from agent.credits_tracker import dev_fixture_credits_state
|
|
|
|
fixture = dev_fixture_credits_state()
|
|
except Exception:
|
|
fixture = None
|
|
if fixture is not None:
|
|
snapshot = _snapshot_from_credits_state(fixture)
|
|
return render_account_usage_lines(snapshot, markdown=markdown)
|
|
|
|
try:
|
|
from hermes_cli.auth import get_provider_auth_state
|
|
|
|
tok = (get_provider_auth_state("nous") or {}).get("access_token")
|
|
if not (isinstance(tok, str) and tok.strip()):
|
|
return []
|
|
except Exception:
|
|
return []
|
|
try:
|
|
import concurrent.futures
|
|
|
|
from hermes_cli.nous_account import get_nous_portal_account_info
|
|
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
|
account = pool.submit(
|
|
get_nous_portal_account_info, force_fresh=True
|
|
).result(timeout=timeout)
|
|
snapshot = build_nous_credits_snapshot(account)
|
|
return render_account_usage_lines(snapshot, markdown=markdown)
|
|
except Exception:
|
|
# Fail-open (caller shows nothing), but leave a breadcrumb so a dead
|
|
# /usage credits block is diagnosable in agent.log without a dev flag.
|
|
logger.debug("credits ▸ /usage portal fetch/render failed (fail-open)", exc_info=True)
|
|
return []
|
|
|
|
|
|
def _snapshot_from_credits_state(state) -> Optional[AccountUsageSnapshot]:
|
|
"""Map a header-shaped CreditsState (e.g. a dev fixture) to the /usage snapshot.
|
|
|
|
Renders the same magnitudes + monthly-grant % window the portal path produces,
|
|
so HERMES_DEV_CREDITS_FIXTURE can exercise /usage without a live account. The
|
|
*_usd strings are mock display values here (not server balance to compute on);
|
|
the % comes from CreditsState.used_fraction (micros math). Fail-open → None.
|
|
"""
|
|
try:
|
|
if state is None:
|
|
return None
|
|
|
|
windows: list[AccountUsageWindow] = []
|
|
details: list[str] = []
|
|
|
|
uf = getattr(state, "used_fraction", None)
|
|
if isinstance(uf, (int, float)) and math.isfinite(uf):
|
|
cap_usd = getattr(state, "subscription_limit_usd", None)
|
|
sub_usd = getattr(state, "subscription_usd", None)
|
|
detail = None
|
|
if sub_usd and cap_usd:
|
|
detail = f"${sub_usd} of ${cap_usd} left"
|
|
windows.append(
|
|
AccountUsageWindow(
|
|
label="Subscription",
|
|
used_percent=max(0.0, min(100.0, uf * 100.0)),
|
|
detail=detail,
|
|
)
|
|
)
|
|
|
|
sub_usd = getattr(state, "subscription_usd", None)
|
|
if sub_usd:
|
|
details.append(f"Subscription credits: ${sub_usd}")
|
|
purchased_usd = getattr(state, "purchased_usd", None)
|
|
if purchased_usd:
|
|
details.append(f"Top-up credits: ${purchased_usd}")
|
|
remaining_usd = getattr(state, "remaining_usd", None)
|
|
if remaining_usd:
|
|
details.append(f"Total usable: ${remaining_usd}")
|
|
if getattr(state, "paid_access", True) is False:
|
|
details.append("Status: access depleted — top up to restore")
|
|
|
|
if not windows and not details:
|
|
return None
|
|
|
|
details.append("(dev fixture — HERMES_DEV_CREDITS_FIXTURE)")
|
|
return AccountUsageSnapshot(
|
|
provider="nous",
|
|
source="dev-fixture",
|
|
fetched_at=_utc_now(),
|
|
title="Nous credits",
|
|
windows=tuple(windows),
|
|
details=tuple(details),
|
|
)
|
|
except (AttributeError, TypeError):
|
|
return None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CreditsView:
|
|
"""Surface-agnostic data for the ``/credits`` command.
|
|
|
|
One portal fetch, one parse — consumed identically by the CLI panel, the
|
|
gateway button, and any other money surface. Fail-open: when not logged in
|
|
or the portal is unreachable, ``logged_in`` is False / ``topup_url`` is None
|
|
and callers degrade gracefully.
|
|
"""
|
|
|
|
logged_in: bool
|
|
balance_lines: tuple[str, ...] = ()
|
|
identity_line: Optional[str] = None
|
|
topup_url: Optional[str] = None
|
|
depleted: bool = False
|
|
|
|
|
|
def build_credits_view(*, markdown: bool = False, timeout: float = 10.0) -> CreditsView:
|
|
"""Build the /credits view: balance block + identity line + top-up URL.
|
|
|
|
Reuses the same account fetch + snapshot + URL builder as the /usage credits
|
|
block, so the numbers always match. The balance block is the rendered
|
|
snapshot MINUS its trailing top-up/command-hint lines (the /credits surface
|
|
supplies its own affordance). Fail-open → ``CreditsView(logged_in=False)``.
|
|
"""
|
|
not_logged_in = CreditsView(logged_in=False)
|
|
try:
|
|
from hermes_cli.auth import get_provider_auth_state
|
|
|
|
tok = (get_provider_auth_state("nous") or {}).get("access_token")
|
|
if not (isinstance(tok, str) and tok.strip()):
|
|
return not_logged_in
|
|
except Exception:
|
|
return not_logged_in
|
|
|
|
try:
|
|
import concurrent.futures
|
|
|
|
from hermes_cli.nous_account import (
|
|
get_nous_portal_account_info,
|
|
nous_portal_topup_url,
|
|
)
|
|
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
|
account = pool.submit(get_nous_portal_account_info, force_fresh=True).result(
|
|
timeout=timeout
|
|
)
|
|
except Exception:
|
|
logger.debug("credits ▸ /credits portal fetch failed (fail-open)", exc_info=True)
|
|
return not_logged_in
|
|
|
|
if account is None or not getattr(account, "logged_in", False):
|
|
return not_logged_in
|
|
|
|
snapshot = build_nous_credits_snapshot(account)
|
|
# Balance lines = the snapshot block minus the two trailing affordance lines
|
|
# ("Top up: <url>" + "(or run /credits)") that build_nous_credits_snapshot
|
|
# appends for the /usage surface. /credits renders its own button/panel.
|
|
balance_lines: list[str] = []
|
|
if snapshot is not None:
|
|
rendered = render_account_usage_lines(snapshot, markdown=markdown)
|
|
balance_lines = [
|
|
line
|
|
for line in rendered
|
|
if not line.lstrip().startswith("Top up:")
|
|
and not line.lstrip().startswith("(or run")
|
|
]
|
|
|
|
# Identity line — shown before any open (roadmap §4.4).
|
|
email = getattr(account, "email", None)
|
|
org_name = getattr(account, "org_name", None)
|
|
who: list[str] = []
|
|
if email:
|
|
who.append(str(email))
|
|
if org_name:
|
|
who.append(f"org {org_name}")
|
|
identity_line = ("Topping up as " + " / ".join(who)) if who else None
|
|
|
|
return CreditsView(
|
|
logged_in=True,
|
|
balance_lines=tuple(balance_lines),
|
|
identity_line=identity_line,
|
|
topup_url=nous_portal_topup_url(account),
|
|
depleted=getattr(account, "paid_service_access", None) is False,
|
|
)
|
|
|
|
|
|
def _resolve_codex_usage_url(base_url: str) -> str:
|
|
normalized = (base_url or "").strip().rstrip("/")
|
|
if not normalized:
|
|
normalized = "https://chatgpt.com/backend-api/codex"
|
|
if normalized.endswith("/codex"):
|
|
normalized = normalized[: -len("/codex")]
|
|
if "/backend-api" in normalized:
|
|
return normalized + "/wham/usage"
|
|
return normalized + "/api/codex/usage"
|
|
|
|
|
|
def _fetch_codex_account_usage() -> Optional[AccountUsageSnapshot]:
|
|
creds = resolve_codex_runtime_credentials(refresh_if_expiring=True)
|
|
token_data = _read_codex_tokens()
|
|
tokens = token_data.get("tokens") or {}
|
|
account_id = str(tokens.get("account_id", "") or "").strip() or None
|
|
headers = {
|
|
"Authorization": f"Bearer {creds['api_key']}",
|
|
"Accept": "application/json",
|
|
"User-Agent": "codex-cli",
|
|
}
|
|
if account_id:
|
|
headers["ChatGPT-Account-Id"] = account_id
|
|
with httpx.Client(timeout=15.0) as client:
|
|
response = client.get(_resolve_codex_usage_url(creds.get("base_url", "")), headers=headers)
|
|
response.raise_for_status()
|
|
payload = response.json() or {}
|
|
rate_limit = payload.get("rate_limit") or {}
|
|
windows: list[AccountUsageWindow] = []
|
|
for key, label in (("primary_window", "Session"), ("secondary_window", "Weekly")):
|
|
window = rate_limit.get(key) or {}
|
|
used = window.get("used_percent")
|
|
if used is None:
|
|
continue
|
|
windows.append(
|
|
AccountUsageWindow(
|
|
label=label,
|
|
used_percent=float(used),
|
|
reset_at=_parse_dt(window.get("reset_at")),
|
|
)
|
|
)
|
|
details: list[str] = []
|
|
credits = payload.get("credits") or {}
|
|
if credits.get("has_credits"):
|
|
balance = credits.get("balance")
|
|
if isinstance(balance, (int, float)):
|
|
details.append(f"Credits balance: ${float(balance):.2f}")
|
|
elif credits.get("unlimited"):
|
|
details.append("Credits balance: unlimited")
|
|
return AccountUsageSnapshot(
|
|
provider="openai-codex",
|
|
source="usage_api",
|
|
fetched_at=_utc_now(),
|
|
plan=_title_case_slug(payload.get("plan_type")),
|
|
windows=tuple(windows),
|
|
details=tuple(details),
|
|
)
|
|
|
|
|
|
def _fetch_anthropic_account_usage() -> Optional[AccountUsageSnapshot]:
|
|
token = (resolve_anthropic_token() or "").strip()
|
|
if not token:
|
|
return None
|
|
if not _is_oauth_token(token):
|
|
return AccountUsageSnapshot(
|
|
provider="anthropic",
|
|
source="oauth_usage_api",
|
|
fetched_at=_utc_now(),
|
|
unavailable_reason="Anthropic account limits are only available for OAuth-backed Claude accounts.",
|
|
)
|
|
headers = {
|
|
"Authorization": f"Bearer {token}",
|
|
"Accept": "application/json",
|
|
"Content-Type": "application/json",
|
|
"anthropic-beta": "oauth-2025-04-20",
|
|
"User-Agent": "claude-code/2.1.0",
|
|
}
|
|
with httpx.Client(timeout=15.0) as client:
|
|
response = client.get("https://api.anthropic.com/api/oauth/usage", headers=headers)
|
|
response.raise_for_status()
|
|
payload = response.json() or {}
|
|
windows: list[AccountUsageWindow] = []
|
|
mapping = (
|
|
("five_hour", "Current session"),
|
|
("seven_day", "Current week"),
|
|
("seven_day_opus", "Opus week"),
|
|
("seven_day_sonnet", "Sonnet week"),
|
|
)
|
|
for key, label in mapping:
|
|
window = payload.get(key) or {}
|
|
util = window.get("utilization")
|
|
if util is None:
|
|
continue
|
|
used = float(util) * 100 if float(util) <= 1 else float(util)
|
|
windows.append(
|
|
AccountUsageWindow(
|
|
label=label,
|
|
used_percent=used,
|
|
reset_at=_parse_dt(window.get("resets_at")),
|
|
)
|
|
)
|
|
details: list[str] = []
|
|
extra = payload.get("extra_usage") or {}
|
|
if extra.get("is_enabled"):
|
|
used_credits = extra.get("used_credits")
|
|
monthly_limit = extra.get("monthly_limit")
|
|
currency = extra.get("currency") or "USD"
|
|
if isinstance(used_credits, (int, float)) and isinstance(monthly_limit, (int, float)):
|
|
details.append(
|
|
f"Extra usage: {used_credits:.2f} / {monthly_limit:.2f} {currency}"
|
|
)
|
|
return AccountUsageSnapshot(
|
|
provider="anthropic",
|
|
source="oauth_usage_api",
|
|
fetched_at=_utc_now(),
|
|
windows=tuple(windows),
|
|
details=tuple(details),
|
|
)
|
|
|
|
|
|
def _fetch_openrouter_account_usage(base_url: Optional[str], api_key: Optional[str]) -> Optional[AccountUsageSnapshot]:
|
|
runtime = resolve_runtime_provider(
|
|
requested="openrouter",
|
|
explicit_base_url=base_url,
|
|
explicit_api_key=api_key,
|
|
)
|
|
token = str(runtime.get("api_key", "") or "").strip()
|
|
if not token:
|
|
return None
|
|
normalized = str(runtime.get("base_url", "") or "").rstrip("/")
|
|
credits_url = f"{normalized}/credits"
|
|
key_url = f"{normalized}/key"
|
|
headers = {
|
|
"Authorization": f"Bearer {token}",
|
|
"Accept": "application/json",
|
|
}
|
|
with httpx.Client(timeout=10.0) as client:
|
|
credits_resp = client.get(credits_url, headers=headers)
|
|
credits_resp.raise_for_status()
|
|
credits = (credits_resp.json() or {}).get("data") or {}
|
|
try:
|
|
key_resp = client.get(key_url, headers=headers)
|
|
key_resp.raise_for_status()
|
|
key_data = (key_resp.json() or {}).get("data") or {}
|
|
except Exception:
|
|
key_data = {}
|
|
total_credits = float(credits.get("total_credits") or 0.0)
|
|
total_usage = float(credits.get("total_usage") or 0.0)
|
|
details = [f"Credits balance: ${max(0.0, total_credits - total_usage):.2f}"]
|
|
windows: list[AccountUsageWindow] = []
|
|
limit = key_data.get("limit")
|
|
limit_remaining = key_data.get("limit_remaining")
|
|
limit_reset = str(key_data.get("limit_reset") or "").strip()
|
|
usage = key_data.get("usage")
|
|
if (
|
|
isinstance(limit, (int, float))
|
|
and float(limit) > 0
|
|
and isinstance(limit_remaining, (int, float))
|
|
and 0 <= float(limit_remaining) <= float(limit)
|
|
):
|
|
limit_value = float(limit)
|
|
remaining_value = float(limit_remaining)
|
|
used_percent = ((limit_value - remaining_value) / limit_value) * 100
|
|
detail_parts = [f"${remaining_value:.2f} of ${limit_value:.2f} remaining"]
|
|
if limit_reset:
|
|
detail_parts.append(f"resets {limit_reset}")
|
|
windows.append(
|
|
AccountUsageWindow(
|
|
label="API key quota",
|
|
used_percent=used_percent,
|
|
detail=" • ".join(detail_parts),
|
|
)
|
|
)
|
|
if isinstance(usage, (int, float)):
|
|
usage_parts = [f"API key usage: ${float(usage):.2f} total"]
|
|
for value, label in (
|
|
(key_data.get("usage_daily"), "today"),
|
|
(key_data.get("usage_weekly"), "this week"),
|
|
(key_data.get("usage_monthly"), "this month"),
|
|
):
|
|
if isinstance(value, (int, float)) and float(value) > 0:
|
|
usage_parts.append(f"${float(value):.2f} {label}")
|
|
details.append(" • ".join(usage_parts))
|
|
return AccountUsageSnapshot(
|
|
provider="openrouter",
|
|
source="credits_api",
|
|
fetched_at=_utc_now(),
|
|
windows=tuple(windows),
|
|
details=tuple(details),
|
|
)
|
|
|
|
|
|
def fetch_account_usage(
|
|
provider: Optional[str],
|
|
*,
|
|
base_url: Optional[str] = None,
|
|
api_key: Optional[str] = None,
|
|
) -> Optional[AccountUsageSnapshot]:
|
|
normalized = str(provider or "").strip().lower()
|
|
if normalized in {"", "auto", "custom"}:
|
|
return None
|
|
try:
|
|
if normalized == "openai-codex":
|
|
return _fetch_codex_account_usage()
|
|
if normalized == "anthropic":
|
|
return _fetch_anthropic_account_usage()
|
|
if normalized == "openrouter":
|
|
return _fetch_openrouter_account_usage(base_url, api_key)
|
|
except Exception:
|
|
return None
|
|
return None
|