Files
hermes-agent/agent/billing_usage.py
Teknium 77743eac8a refactor(agent/models): compact pricing snapshot, billing/subscription views, reasoning helpers
- usage_pricing: _snap() builder for official-docs pricing entries (table values identical, verified by dump), shared source/version dicts, drop dead DEFAULT_PRICING
- models_dev: _registry_models/_iter_model_entries/_extract_limit helpers replace repeated registry walking; drop dead ModelInfo.format_cost
- billing_view/subscription_view: OrgRoleCapability mixin replaces duplicated is_admin/can_change_plan; shared fetch_portal_state/parse_org_fields
- reasoning_effort/timeouts/summaries, thinking_timeout_guidance, portal_tags: dispatch tables and compacted comment essays; drop dead CODEX_RESPONSES_EFFORTS alias and _match_any
2026-09-02 13:52:51 -07:00

264 lines
10 KiB
Python

"""Shared dollar-denominated usage model for the ``/usage`` and ``/subscription`` bars.
Terminal surfaces show **dollars**, never "credits"; the monthly subscription
allowance and separately-purchased top-up dollars must stay distinctly visible.
Data source: ``NousPortalAccountInfo.paid_service_access_info`` (USD floats despite
the legacy ``*_credits`` names): ``subscription_credits_remaining`` (plan $ left),
``purchased_credits_remaining`` (top-up $, rolls over), ``total_usable_credits``;
plus ``subscription.monthly_credits`` (plan bar denominator) and ``current_period_end``.
Design: two SEPARATE bars rather than one three-segment bar — at terminal widths
three same-glyph density segments are unreadable. Plan bar = spent vs allowance
(carries % used); top-up bar = purchased money that doesn't expire. Fail-open:
missing/non-finite fields degrade to fewer bars; logged-out / unreachable portal
yields ``available=False`` and the surface shows nothing.
"""
from __future__ import annotations
import logging
import math
import os
from dataclasses import dataclass
from typing import Any, Optional
logger = logging.getLogger(__name__)
# Below this TOTAL spendable ($) a paid account is flagged "low" — the alert state
# that nudges top-up/upgrade before a mid-run cutoff (product: "below $5 is an alert").
LOW_BALANCE_THRESHOLD_USD = 5.0
def _finite(value: Any) -> Optional[float]:
"""Return value as a float iff it's a real finite number (not bool/NaN/Inf).
NaN/Infinity slip past isinstance (json.loads parses bare NaN by default) and
would otherwise render as ``$nan`` with a falsely-confident gauge.
"""
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
f = float(value)
return f if math.isfinite(f) else None
def _fmt_usd(value: Optional[float]) -> str:
"""``$X,XXX.YY`` for display. ``None`` -> ``$0.00`` (callers gate on presence)."""
return f"${(value or 0.0):,.2f}"
def nous_logged_in() -> bool:
"""Cheap local auth-state check: a Nous access token is present. Fail-closed."""
try:
from hermes_cli.auth import get_provider_auth_state
tok = (get_provider_auth_state("nous") or {}).get("access_token")
return isinstance(tok, str) and bool(tok.strip())
except Exception:
return False
def fetch_nous_account(timeout: float):
"""Wall-clock-bounded fresh portal account fetch. Raises on failure/timeout."""
import concurrent.futures
from hermes_cli.nous_account import get_nous_portal_account_info
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
return pool.submit(get_nous_portal_account_info, force_fresh=True).result(timeout=timeout)
def format_renews(value: Optional[str]) -> Optional[str]:
"""ISO date/timestamp -> ``Jul 24, 2026``; unparseable input is returned unchanged."""
if not value:
return None
from datetime import datetime
text = str(value).strip()
if not text:
return None
iso = text[:-1] + "+00:00" if text.endswith("Z") else text
try:
dt = datetime.fromisoformat(iso)
except ValueError:
try:
dt = datetime.strptime(text[:10], "%Y-%m-%d")
except ValueError:
return text
# %-d isn't portable to Windows; build the day without a leading zero.
return f"{dt.strftime('%b')} {dt.day}, {dt.year}"
@dataclass(frozen=True)
class UsageBar:
"""One full-resolution bar: ``spent`` of ``total``, plus a remaining figure.
``kind`` is ``"plan"`` (monthly allowance, shows % used) or ``"topup"`` (no
denominator — ``spent`` is 0 and ``total == remaining`` so it renders full).
"""
kind: str # "plan" | "topup"
remaining_usd: float
total_usd: float
spent_usd: float = 0.0
@property
def pct_used(self) -> Optional[int]:
if self.kind != "plan" or self.total_usd <= 0:
return None
return max(0, min(100, round(self.spent_usd / self.total_usd * 100)))
@property
def fill_fraction(self) -> float:
"""Fraction of the bar that should read as 'remaining' (filled)."""
if self.total_usd <= 0:
return 0.0
return max(0.0, min(1.0, self.remaining_usd / self.total_usd))
@dataclass(frozen=True)
class UsageModel:
"""Surface-agnostic dollar usage model shared by /usage and /subscription.
``status``: ``free`` (no paid access / no plan), ``low`` (paid, spendable < $5,
ALERT), ``healthy`` (paid, spendable >= $5), ``depleted`` (paid access lost).
"""
available: bool
status: str = "free"
plan_name: Optional[str] = None
renews_at: Optional[str] = None
renews_display: Optional[str] = None
subscription_remaining_usd: Optional[float] = None
topup_remaining_usd: Optional[float] = None
total_spendable_usd: Optional[float] = None
plan_bar: Optional[UsageBar] = None
topup_bar: Optional[UsageBar] = None
@property
def has_topup(self) -> bool:
return bool(self.topup_remaining_usd and self.topup_remaining_usd > 0)
def usage_model_from_account(account_info: Any) -> UsageModel:
"""Build a :class:`UsageModel` from a ``NousPortalAccountInfo``. Never raises."""
try:
if account_info is None or not getattr(account_info, "logged_in", False):
return UsageModel(available=False)
access = getattr(account_info, "paid_service_access_info", None)
sub = getattr(account_info, "subscription", None)
paid = getattr(account_info, "paid_service_access", None)
sub_remaining = _finite(getattr(access, "subscription_credits_remaining", None)) if access else None
topup_remaining = _finite(getattr(access, "purchased_credits_remaining", None)) if access else None
total_usable = _finite(getattr(access, "total_usable_credits", None)) if access else None
plan_name = getattr(sub, "plan", None) if sub is not None else None
renews_at = getattr(sub, "current_period_end", None) if sub is not None else None
monthly = _finite(getattr(sub, "monthly_credits", None)) if sub is not None else None
has_subscription = bool(plan_name) or (monthly is not None and monthly > 0)
has_topup = bool(topup_remaining and topup_remaining > 0)
# Prefer the server's total; else sum the parts we have.
if total_usable is not None:
total_spendable = total_usable
else:
parts = [v for v in (sub_remaining, topup_remaining) if v is not None]
total_spendable = sum(parts) if parts else None
if paid is False:
status = "depleted"
elif not has_subscription and not has_topup:
status = "free" # no plan and no purchased balance -> free-models-only
elif total_spendable is not None and total_spendable < LOW_BALANCE_THRESHOLD_USD:
status = "low"
else:
status = "healthy"
# Plan bar needs a positive allowance AND a remaining to place on it; spent is
# clamped so a debt/over-cap balance reads fully spent, not negative.
plan_bar: Optional[UsageBar] = None
if monthly is not None and monthly > 0 and sub_remaining is not None:
plan_bar = UsageBar(
kind="plan",
remaining_usd=max(0.0, min(monthly, sub_remaining)),
total_usd=monthly,
spent_usd=max(0.0, monthly - sub_remaining),
)
# Top-up has no monthly cap, so the bar renders full = balance.
topup_bar: Optional[UsageBar] = None
if topup_remaining is not None and topup_remaining > 0:
topup_bar = UsageBar(kind="topup", remaining_usd=topup_remaining, total_usd=topup_remaining, spent_usd=0.0)
return UsageModel(
available=True,
status=status,
plan_name=plan_name,
renews_at=renews_at,
renews_display=format_renews(renews_at),
subscription_remaining_usd=sub_remaining,
topup_remaining_usd=topup_remaining,
total_spendable_usd=total_spendable,
plan_bar=plan_bar,
topup_bar=topup_bar,
)
except Exception:
logger.debug("usage ▸ model build failed (fail-open)", exc_info=True)
return UsageModel(available=False)
def build_usage_model(*, timeout: float = 10.0) -> UsageModel:
"""Fetch account-info and build the shared usage model. Fail-open.
``HERMES_DEV_CREDITS_FIXTURE`` short-circuits to a fixture so every usage state
is testable without a live account.
"""
fixture = _dev_fixture_usage_model()
if fixture is not None:
return fixture
if not nous_logged_in():
return UsageModel(available=False)
try:
return usage_model_from_account(fetch_nous_account(timeout))
except Exception:
logger.debug("usage ▸ portal fetch failed (fail-open)", exc_info=True)
return UsageModel(available=False)
# ── Dev fixtures (throwaway scaffolding — env-var driven, no live portal) ────
def _plan_bar(remaining: float, spent: float) -> UsageBar:
return UsageBar(kind="plan", remaining_usd=remaining, total_usd=20.0, spent_usd=spent)
def _dev_fixture_usage_model() -> Optional[UsageModel]:
"""``HERMES_DEV_CREDITS_FIXTURE`` -> fixture model (``free|healthy|low|topup|depleted``), else None."""
name = (os.getenv("HERMES_DEV_CREDITS_FIXTURE") or "").strip().lower()
name = {"mid": "healthy", "top-up": "topup"}.get(name, name)
plus = dict(available=True, plan_name="Plus", renews_at="2026-07-01")
specs: dict[str, dict] = {
"free": dict(available=True, status="free", plan_name=None),
"healthy": dict(
**plus, status="healthy", subscription_remaining_usd=14.0, total_spendable_usd=14.0,
plan_bar=_plan_bar(14.0, 6.0),
),
"topup": dict(
**plus, status="healthy", subscription_remaining_usd=14.0, topup_remaining_usd=12.0,
total_spendable_usd=26.0, plan_bar=_plan_bar(14.0, 6.0),
topup_bar=UsageBar(kind="topup", remaining_usd=12.0, total_usd=12.0, spent_usd=0.0),
),
"low": dict(
**plus, status="low", subscription_remaining_usd=3.4, total_spendable_usd=3.4,
plan_bar=_plan_bar(3.4, 16.6),
),
"depleted": dict(
**plus, status="depleted", subscription_remaining_usd=0.0, total_spendable_usd=0.0,
plan_bar=_plan_bar(0.0, 20.0),
),
}
spec = specs.get(name)
return UsageModel(**spec) if spec else None