Files
hermes-agent/agent/billing_view.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

448 lines
17 KiB
Python

"""Surface-agnostic core for the Remote Spending screens.
One fetch/parse per concern, consumed identically by the CLI handler
(``cli.py::_show_billing``), the TUI JSON-RPC methods (``tui_gateway/server.py``),
and any other surface. Parse the server payload into a frozen dataclass and
**fail open**: when not logged in or the portal is unreachable, return a struct
with ``logged_in=False`` and let the surface degrade gracefully (never crash).
Money discipline: the server emits decimal STRINGS (``"142.5"``, not fixed 2dp).
We keep them as :class:`decimal.Decimal` end-to-end and only format for display.
"""
from __future__ import annotations
import logging
import os
import uuid
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from typing import Any, Callable, Optional
logger = logging.getLogger(__name__)
# ── Decimal money helpers ────────────────────────────────────────────────────
def parse_money(value: Any) -> Optional[Decimal]:
"""Server money value (decimal string; defensively int/float) -> Decimal, or None. Never raises."""
if value is None:
return None
try:
# Decimal(str(...)) avoids binary-float artifacts if a float ever sneaks in.
return Decimal(str(value).strip())
except (InvalidOperation, ValueError, TypeError):
return None
def format_money(value: Optional[Decimal], *, grouped: bool = False) -> str:
"""``$X`` for whole dollars, ``$X.YY`` (exactly 2dp) otherwise; ``None`` -> ``—``.
``grouped=True`` adds thousands separators (``$1,234.50``) to mirror the TUI's
``toLocaleString('en-US')`` on plan-catalog rows; the default is intentionally
ungrouped and asserted so across the other surfaces.
"""
if value is None:
return "—"
spec = ",f" if grouped else "f"
if value == value.to_integral_value():
# format(..., "f") avoids 1E+3 for 1000.
return f"${format(value.to_integral_value(), spec)}"
return f"${format(value.quantize(Decimal('0.01')), spec)}"
def _optional_str(raw: dict, key: str) -> Optional[str]:
value = raw.get(key)
return value if isinstance(value, str) else None
# ── Parsed sub-structures ────────────────────────────────────────────────────
# resolvedVia (server card-on-file ladder rung) → "why THIS card?". Unknown/absent
# rungs render no label so older servers degrade cleanly.
_CARD_PROVENANCE_LABELS = {
"subPin": "the card on your subscription",
"customerDefault": "your default card saved on the portal",
"autoRefill": "your auto-reload card",
}
@dataclass(frozen=True)
class CardInfo:
brand: str
last4: str
resolved_via: Optional[str] = None # ladder rung; None on pre-resolver payloads
@property
def masked(self) -> str:
# A Link payment method has no card number (last4 = "") — brand alone, not "Link ····".
if not self.last4:
return self.brand
return f"{self.brand} ····{self.last4}"
@property
def provenance(self) -> Optional[str]:
"""Human label for why this card was picked, or None (unknown rung / old server)."""
if self.resolved_via is None:
return None
return _CARD_PROVENANCE_LABELS.get(self.resolved_via)
@property
def display(self) -> str:
"""``Visa ····4242 — the card on your subscription`` (masked only when provenance unknown)."""
label = self.provenance
return f"{self.masked} — {label}" if label else self.masked
@dataclass(frozen=True)
class PaymentMethodInfo:
"""Payment method on file. ``kind`` is "card", "link", or "unknown" — anything
else is normalised to "unknown" at parse time so consumers only see fields
that belong to the kind they are looking at."""
kind: str
brand: Optional[str] = None
last4: Optional[str] = None
wallet: Optional[str] = None
email: Optional[str] = None
resolved_via: Optional[str] = None
raw_kind: Optional[str] = None # what the server called an unrecognised kind
@dataclass(frozen=True)
class MonthlyCap:
limit_usd: Optional[Decimal] = None
spent_this_month_usd: Optional[Decimal] = None
is_default_ceiling: bool = False
@dataclass(frozen=True)
class AutoReloadCard:
kind: str # "canonical" | "distinct" | "none"
payment_method_id: Optional[str] = None
brand: Optional[str] = None
last4: Optional[str] = None
@dataclass(frozen=True)
class AutoReload:
enabled: bool = False
threshold_usd: Optional[Decimal] = None
reload_to_usd: Optional[Decimal] = None
card: Optional[AutoReloadCard] = None
class OrgRoleCapability:
"""``is_admin`` / ``can_change_plan`` shared by the billing and subscription states."""
role: Optional[str]
can_change_plan_raw: Optional[bool]
@property
def is_admin(self) -> bool:
"""Deprecated/display only — legacy OWNER/ADMIN check, NOT a capability check
(use :attr:`can_change_plan` to gate plan-change actions)."""
return (self.role or "").upper() in ("OWNER", "ADMIN")
@property
def can_change_plan(self) -> bool:
"""Server capability when supplied; otherwise the legacy role fallback."""
if self.can_change_plan_raw is not None:
return self.can_change_plan_raw
return self.is_admin
@dataclass(frozen=True)
class BillingState(OrgRoleCapability):
"""Parsed ``GET /api/billing/state``. Fail-open: ``logged_in=False`` (empty
fields) when not logged in or the portal is unreachable."""
logged_in: bool
org_id: Optional[str] = None
org_slug: Optional[str] = None
org_name: Optional[str] = None
role: Optional[str] = None # "OWNER" | "ADMIN" | "FINANCE_ADMIN" | "SECURITY_ADMIN" | "MEMBER"
can_change_plan_raw: Optional[bool] = None
balance_usd: Optional[Decimal] = None
cli_billing_enabled: bool = False
charge_presets: tuple[Decimal, ...] = ()
min_usd: Optional[Decimal] = None
max_usd: Optional[Decimal] = None
card: Optional[CardInfo] = None
payment_method: Optional[PaymentMethodInfo] = None
monthly_cap: Optional[MonthlyCap] = None
auto_reload: Optional[AutoReload] = None
portal_url: Optional[str] = None
error: Optional[str] = None # set when the fetch failed (vs cleanly not-logged-in)
@property
def can_charge(self) -> bool:
"""Offer charge/auto-reload actions: server-granted ``can_change_plan`` (so
e.g. FINANCE_ADMIN can be granted via ``canChangePlan``) AND the per-org
kill-switch. Display gating only — the server still enforces."""
return self.can_change_plan and self.cli_billing_enabled
def _parse_card(raw: Any) -> Optional[CardInfo]:
if not isinstance(raw, dict):
return None
brand = raw.get("brand")
last4 = raw.get("last4")
if not (isinstance(brand, str) and isinstance(last4, str)):
return None
return CardInfo(brand=brand, last4=last4, resolved_via=_optional_str(raw, "resolvedVia"))
def _parse_payment_method(raw: Any) -> Optional[PaymentMethodInfo]:
if not isinstance(raw, dict):
return None
kind = raw.get("kind")
if not isinstance(kind, str):
return None
resolved_via = _optional_str(raw, "resolvedVia")
brand = _optional_str(raw, "brand")
last4 = _optional_str(raw, "last4")
# Settle the kind here (like _parse_card) so nothing downstream re-checks fields.
if kind == "card" and brand and last4:
return PaymentMethodInfo(
kind="card", brand=brand, last4=last4, wallet=_optional_str(raw, "wallet"), resolved_via=resolved_via
)
if kind == "link":
return PaymentMethodInfo(kind="link", email=_optional_str(raw, "email"), resolved_via=resolved_via)
return PaymentMethodInfo(kind="unknown", raw_kind=kind, resolved_via=resolved_via)
def _parse_monthly_cap(raw: Any) -> Optional[MonthlyCap]:
if not isinstance(raw, dict):
return None
return MonthlyCap(
limit_usd=parse_money(raw.get("limitUsd")),
spent_this_month_usd=parse_money(raw.get("spentThisMonthUsd")),
is_default_ceiling=bool(raw.get("isDefaultCeiling")),
)
def _parse_auto_reload(raw: Any) -> Optional[AutoReload]:
if not isinstance(raw, dict):
return None
return AutoReload(
enabled=bool(raw.get("enabled")),
threshold_usd=parse_money(raw.get("thresholdUsd")),
reload_to_usd=parse_money(raw.get("reloadToUsd")),
card=_parse_auto_reload_card(raw.get("card")),
)
def _parse_auto_reload_card(raw: Any) -> Optional[AutoReloadCard]:
if not isinstance(raw, dict):
return None
kind = raw.get("kind")
if kind not in ("canonical", "distinct", "none"):
return None
if kind in ("canonical", "none"):
return AutoReloadCard(kind=kind)
return AutoReloadCard(
kind=kind,
payment_method_id=_optional_str(raw, "paymentMethodId"),
brand=_optional_str(raw, "brand"),
last4=_optional_str(raw, "last4"),
)
def parse_org_fields(payload: dict[str, Any]) -> tuple[dict[str, Any], Optional[bool]]:
"""``(org dict or {}, canChangePlan if bool else None)`` — shared by both state parsers."""
raw_org = payload.get("org")
ccp = payload.get("canChangePlan")
return (raw_org if isinstance(raw_org, dict) else {}), (ccp if isinstance(ccp, bool) else None)
def billing_state_from_payload(
payload: dict[str, Any], *, portal_url: Optional[str] = None
) -> BillingState:
"""Map a raw ``/api/billing/state`` JSON dict into :class:`BillingState`."""
org, can_change_plan_raw = parse_org_fields(payload)
raw_bounds = payload.get("bounds")
bounds: dict[str, Any] = raw_bounds if isinstance(raw_bounds, dict) else {}
presets = [p for p in map(parse_money, payload.get("chargePresets") or ()) if p is not None]
return BillingState(
logged_in=True,
org_id=org.get("id"),
org_slug=org.get("slug"),
org_name=org.get("name"),
role=org.get("role"),
can_change_plan_raw=can_change_plan_raw,
balance_usd=parse_money(payload.get("balanceUsd")),
cli_billing_enabled=bool(payload.get("cliBillingEnabled")),
charge_presets=tuple(presets),
min_usd=parse_money(bounds.get("minUsd")),
max_usd=parse_money(bounds.get("maxUsd")),
card=_parse_card(payload.get("card")),
payment_method=_parse_payment_method(payload.get("paymentMethod")),
monthly_cap=_parse_monthly_cap(payload.get("monthlyCap")),
auto_reload=_parse_auto_reload(payload.get("autoReload")),
portal_url=portal_url,
)
# ── Fail-open builders (the surface front doors) ─────────────────────────────
def fetch_portal_state(
endpoint: str,
label: str,
*,
failed: Callable[..., Any],
parse: Callable[[dict, Optional[str]], Any],
portal_fallback: Callable[[str], str],
timeout: float,
log: logging.Logger,
):
"""Shared fail-open fetch+parse for the billing/subscription overview builders.
``failed(**kw)`` builds the ``logged_in=False`` struct: bare on auth failure,
with ``error`` set on a portal/HTTP failure so the surface can show a clear
message. Prefers a server-supplied ``portalUrl`` (absolutized); else
``portal_fallback(portal_base_url)``.
"""
try:
import hermes_cli.nous_billing as nb
except Exception:
return failed(error="billing client unavailable")
try:
payload = getattr(nb, endpoint)(timeout=timeout)
except nb.BillingAuthError:
return failed()
except nb.BillingError as exc:
log.debug("%s ▸ /state fetch failed (fail-open)", label, exc_info=True)
return failed(error=str(exc))
except Exception:
log.debug("%s ▸ /state unexpected error (fail-open)", label, exc_info=True)
return failed(error=f"could not load {label} state")
raw_portal = payload.get("portalUrl") if isinstance(payload, dict) else None
portal_url = nb._absolutize_portal_url(raw_portal) if raw_portal else None
if not portal_url:
try:
portal_url = portal_fallback(nb.resolve_portal_base_url())
except Exception:
portal_url = None
return parse(payload, portal_url)
def build_billing_state(*, timeout: float = 15.0) -> BillingState:
"""Fetch + parse ``/api/billing/state``. Fail-open (see :func:`fetch_portal_state`).
``HERMES_DEV_BILLING_FIXTURE`` short-circuits to a fixture so card-on-file /
admin / scope states are testable offline.
"""
fixture = _dev_fixture_billing_state()
if fixture is not None:
return fixture
return fetch_portal_state(
"get_billing_state",
"billing",
failed=lambda **kw: BillingState(logged_in=False, **kw),
parse=lambda payload, portal_url: billing_state_from_payload(payload, portal_url=portal_url),
portal_fallback=lambda base: f"{base.rstrip('/')}/billing?topup=open",
timeout=timeout,
log=logger,
)
# ── Dev fixtures (throwaway scaffolding — env-var driven, no live portal) ────
def _dev_fixture_billing_state() -> Optional[BillingState]:
"""``HERMES_DEV_BILLING_FIXTURE`` -> :class:`BillingState` for offline UX; None when unset.
nocard · card · card-sub (provenance label) · card-autoreload · notadmin (MEMBER)
· billing-off (per-org kill-switch) · logged-out. Unknown name → logged-out with
``error`` so the misconfiguration is visible. Pair with ``HERMES_DEV_CREDITS_FIXTURE``
for the usage bar.
"""
name = (os.getenv("HERMES_DEV_BILLING_FIXTURE") or "").strip().lower()
if not name:
return None
aliases = {
"logged_out": "logged-out", "loggedout": "logged-out",
"card_sub": "card-sub",
"card_autoreload": "card-autoreload", "autoreload": "card-autoreload",
"not-admin": "notadmin", "member": "notadmin",
"billing_off": "billing-off", "off": "billing-off",
}
name = aliases.get(name, name)
if name == "logged-out":
return BillingState(logged_in=False)
# Prod portal host (matches subscription_view._DEV_FIXTURE_PORTAL) + the /topup deep-link suffix.
common: dict[str, Any] = dict(
logged_in=True,
org_id="org_acme",
org_slug="acme",
org_name="Acme Inc",
role="OWNER",
balance_usd=Decimal("3.40"),
cli_billing_enabled=True,
charge_presets=(Decimal("10"), Decimal("25"), Decimal("50")),
min_usd=Decimal("5"),
max_usd=Decimal("500"),
portal_url="https://portal.nousresearch.com/billing?topup=open",
)
card = CardInfo(brand="Visa", last4="4242")
overrides: dict[str, dict[str, Any]] = {
"nocard": dict(card=None),
"card": dict(card=card),
"card-sub": dict(card=CardInfo(brand="Visa", last4="4242", resolved_via="subPin")),
"card-autoreload": dict(
card=card, auto_reload=AutoReload(enabled=True, threshold_usd=Decimal("5"), reload_to_usd=Decimal("25"))
),
"notadmin": dict(card=card, role="MEMBER"),
"billing-off": dict(card=None, cli_billing_enabled=False),
}
if name not in overrides:
return BillingState(logged_in=False, error=f"unknown HERMES_DEV_BILLING_FIXTURE: {name}")
return BillingState(**{**common, **overrides[name]})
# ── Idempotency ──────────────────────────────────────────────────────────────
def new_idempotency_key() -> str:
"""Fresh UUID for a user-confirmed purchase. ``Idempotency-Key`` is mandatory on
``POST /charge``: reuse the key across retries of the SAME buy so a double-submit
collapses to one charge; never reuse across amounts (server 409 idempotency_conflict)."""
return str(uuid.uuid4())
# ── Amount validation (custom charge input) ──────────────────────────────────
@dataclass(frozen=True)
class AmountValidation:
ok: bool
amount: Optional[Decimal] = None
error: Optional[str] = None
def validate_charge_amount(
raw: str, *, min_usd: Optional[Decimal], max_usd: Optional[Decimal]
) -> AmountValidation:
"""Mirror the server's accept/reject (bounds + multipleOf 0.01) for instant UI
feedback; the server is still authoritative."""
amount = parse_money((raw or "").strip().lstrip("$").strip())
if amount is None:
return AmountValidation(ok=False, error="Enter a dollar amount, e.g. 100")
if amount <= 0:
return AmountValidation(ok=False, error="Amount must be greater than $0")
if amount != amount.quantize(Decimal("0.01")):
return AmountValidation(ok=False, error="Amount can't be smaller than a cent")
if min_usd is not None and amount < min_usd:
return AmountValidation(ok=False, error=f"Minimum is {format_money(min_usd)}")
if max_usd is not None and amount > max_usd:
return AmountValidation(ok=False, error=f"Maximum is {format_money(max_usd)}")
return AmountValidation(ok=True, amount=amount)