hermes-agent/hermes_cli/nous_billing.py
alt-glitch d869bde319 feat(billing): nous_billing http client + BillingState core (phase 2b)
Phase 2b terminal-billing client foundation:
- hermes_cli/nous_billing.py: typed client for the 4 /api/billing/* endpoints
  (state/charge/poll/auto-top-up). Raises typed errors (BillingScopeRequired,
  BillingRateLimited, BillingAuthError) mapped from the live-verified contract;
  fail-open is the caller's job. Idempotency-Key enforced client-side.
- agent/billing_view.py: surface-agnostic BillingState core + Decimal money
  parsing (server emits decimal strings, not 2dp), fail-open builder,
  idempotency-key gen, custom-amount validation.
- 51 unit tests (decimal parse/format, payload tiering, error->exception
  matrix, fail-open, amount validation).

Plan: docs/plans/2026-06-13-001-phase-2b-terminal-billing-tui-plan.md
2026-06-13 11:52:02 +05:30

319 lines
12 KiB
Python

"""Nous Portal terminal-billing HTTP client (Phase 2b).
Thin, fail-loud client for the four ``/api/billing/*`` endpoints the terminal
billing screens drive. Companion to ``hermes_cli/nous_account.py`` (which owns
read-only entitlement/balance) — this module owns the *write* side: buy credits,
poll a charge, configure auto-reload.
Design rules (see docs/plans/2026-06-13-001-phase-2b-terminal-billing-tui-plan.md):
- **Money is decimal, never float.** The server emits decimal STRINGS
(``"142.5"`` — not fixed 2dp). We parse with :class:`decimal.Decimal` and never
round-trip through float.
- **This client raises typed exceptions; it does NOT fail open.** Fail-open is the
*caller's* job (the ``agent/billing_view.py`` builders) so each surface can
decide how to degrade. A raw network/HTTP error here surfaces as
:class:`BillingError` (or a subclass) carrying the parsed server ``error`` code,
HTTP status, ``portalUrl`` deep-link, and ``retry_after``.
- **Auth** = the OAuth bearer JWT Hermes already holds for inference
(``get_provider_auth_state("nous")["access_token"]``). No API-key auth on these.
- **Portal base URL** resolves with the same precedence as the device-flow login
(``auth.py``): ``HERMES_PORTAL_BASE_URL`` → ``NOUS_PORTAL_BASE_URL`` → the
stored auth-state ``portal_base_url`` → the registry default. This is how the
E2E run points the client at the PR #412 preview with zero code change.
"""
from __future__ import annotations
import json
import os
import urllib.error
import urllib.parse
import urllib.request
from typing import Any, Optional
DEFAULT_PORTAL_BASE_URL = "https://portal.nousresearch.com"
# Default HTTP timeout (seconds). Charge/poll calls are quick; keep this tight so
# a hung portal doesn't freeze the TUI.
DEFAULT_TIMEOUT = 15.0
# Scope the privileged billing endpoints require. Mirrored from
# hermes_cli.auth.NOUS_BILLING_MANAGE_SCOPE (kept here too so this module has no
# import-time dependency on the much heavier auth module).
BILLING_MANAGE_SCOPE = "billing:manage"
# =============================================================================
# Typed errors
# =============================================================================
class BillingError(Exception):
"""A billing HTTP call failed.
Carries everything a surface needs to render the right message + affordance:
the server ``error`` code, HTTP ``status``, an optional human ``message``, the
``portalUrl`` deep-link (present on every gate denial), and ``retry_after``
seconds (429/503). ``payload`` is the full parsed JSON body when available.
"""
def __init__(
self,
message: str,
*,
status: Optional[int] = None,
error: Optional[str] = None,
portal_url: Optional[str] = None,
retry_after: Optional[int] = None,
payload: Optional[dict[str, Any]] = None,
) -> None:
super().__init__(message)
self.status = status
self.error = error
self.portal_url = portal_url
self.retry_after = retry_after
self.payload = payload or {}
class BillingScopeRequired(BillingError):
"""``403 insufficient_scope`` — the held token lacks ``billing:manage``.
The lazy step-up trigger: catching this kicks off a fresh device-connect that
requests ``billing:manage`` (and tells the user an ADMIN must tick "Allow
terminal billing"). Also fires mid-session if the scope is stripped on refresh
after the user loses ADMIN.
"""
class BillingRateLimited(BillingError):
"""``429 rate_limited`` or ``503 temporarily_unavailable``.
NOT a payment failure. Carries ``retry_after`` (seconds) — back off and tell
the user "try again in N min"; never auto-retry-spam (the limiter is
5/org/hr + 5/token/hr and easy to dig deeper into).
"""
class BillingAuthError(BillingError):
"""``401`` — missing/invalid bearer token (not logged in / expired)."""
# =============================================================================
# Base-URL + auth resolution
# =============================================================================
def resolve_portal_base_url(state: Optional[dict[str, Any]] = None) -> str:
"""Resolve the portal base URL with login-time precedence.
``HERMES_PORTAL_BASE_URL`` → ``NOUS_PORTAL_BASE_URL`` → stored auth-state
``portal_base_url`` → registry default. Trailing slash stripped.
"""
env = os.getenv("HERMES_PORTAL_BASE_URL") or os.getenv("NOUS_PORTAL_BASE_URL")
if env and env.strip():
return env.strip().rstrip("/")
if state:
stored = state.get("portal_base_url")
if isinstance(stored, str) and stored.strip():
return stored.strip().rstrip("/")
return DEFAULT_PORTAL_BASE_URL
def _resolve_token_and_base() -> tuple[str, str]:
"""Return ``(access_token, portal_base_url)`` for billing calls.
Raises :class:`BillingAuthError` when no Nous access token is held.
"""
try:
from hermes_cli.auth import get_provider_auth_state
state = get_provider_auth_state("nous") or {}
except Exception:
state = {}
token = state.get("access_token")
if not (isinstance(token, str) and token.strip()):
raise BillingAuthError(
"Not logged into Nous Portal — run `hermes portal` to log in.",
status=401,
error="invalid_token",
)
return token.strip(), resolve_portal_base_url(state)
# =============================================================================
# HTTP plumbing
# =============================================================================
def _retry_after_seconds(headers: Any) -> Optional[int]:
"""Parse a ``Retry-After`` header (integer seconds) — None if absent/bad."""
if headers is None:
return None
try:
raw = headers.get("Retry-After")
except Exception:
raw = None
if raw is None:
return None
try:
return int(str(raw).strip())
except (TypeError, ValueError):
return None
def _raise_for_error(
status: int, payload: dict[str, Any], headers: Any = None
) -> None:
"""Map an HTTP error response to the right typed :class:`BillingError`."""
error = payload.get("error") if isinstance(payload, dict) else None
message = payload.get("message") if isinstance(payload, dict) else None
portal_url = payload.get("portalUrl") if isinstance(payload, dict) else None
retry_after = _retry_after_seconds(headers)
common = {
"status": status,
"error": error,
"portal_url": portal_url,
"retry_after": retry_after,
"payload": payload if isinstance(payload, dict) else None,
}
if status == 401:
raise BillingAuthError(message or "Authentication required.", **common)
if status == 403 and error == "insufficient_scope":
raise BillingScopeRequired(
message or "This action needs the billing:manage scope.", **common
)
if status in (429, 503):
raise BillingRateLimited(
message or "Rate limited — try again shortly.", **common
)
raise BillingError(message or error or f"Billing request failed ({status}).", **common)
def _request(
method: str,
path: str,
*,
body: Optional[dict[str, Any]] = None,
extra_headers: Optional[dict[str, str]] = None,
timeout: float = DEFAULT_TIMEOUT,
) -> dict[str, Any]:
"""Make an authenticated billing request; return the parsed JSON dict.
Raises a typed :class:`BillingError` on any non-2xx response (or transport
failure). 2xx with an empty body returns ``{}``.
"""
token, base = _resolve_token_and_base()
url = f"{base}{path}"
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/json",
}
if body is not None:
headers["Content-Type"] = "application/json"
if extra_headers:
headers.update(extra_headers)
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8")
return json.loads(raw) if raw.strip() else {}
except urllib.error.HTTPError as exc:
raw = ""
try:
raw = exc.read().decode("utf-8")
except Exception:
raw = ""
try:
payload = json.loads(raw) if raw.strip() else {}
except json.JSONDecodeError:
payload = {}
_raise_for_error(exc.code, payload, getattr(exc, "headers", None))
raise # unreachable; _raise_for_error always raises
except urllib.error.URLError as exc:
raise BillingError(
f"Could not reach Nous Portal: {exc.reason}", error="network_error"
) from exc
# =============================================================================
# The four endpoints
# =============================================================================
def get_billing_state(*, timeout: float = DEFAULT_TIMEOUT) -> dict[str, Any]:
"""``GET /api/billing/state`` — role-tiered overview (no scope required)."""
return _request("GET", "/api/billing/state", timeout=timeout)
def patch_auto_top_up(
*,
enabled: bool,
threshold: float | str,
top_up_amount: float | str,
timeout: float = DEFAULT_TIMEOUT,
) -> dict[str, Any]:
"""``PATCH /api/billing/auto-top-up`` — configure auto-reload (scope required).
Body is strict server-side: extra keys (``maxMonthlySpend``, a payment method)
are rejected with 400. Numbers are sent as JSON numbers per the contract.
"""
return _request(
"PATCH",
"/api/billing/auto-top-up",
body={
"enabled": bool(enabled),
"threshold": float(threshold),
"topUpAmount": float(top_up_amount),
},
timeout=timeout,
)
def post_charge(
*,
amount_usd: float | str,
idempotency_key: str,
timeout: float = DEFAULT_TIMEOUT,
) -> dict[str, Any]:
"""``POST /api/billing/charge`` — buy credits (scope required).
``Idempotency-Key`` header is MANDATORY (a missing header is a server 400, not
a default): generate a UUID per user-confirmed purchase and reuse it on retry.
Returns ``202 {chargeId}`` — money is NOT confirmed yet; poll with
:func:`get_charge_status`.
"""
if not (isinstance(idempotency_key, str) and idempotency_key.strip()):
raise BillingError(
"Idempotency-Key is required for a charge.",
error="idempotency_key_required",
)
return _request(
"POST",
"/api/billing/charge",
body={"amountUsd": float(amount_usd)},
extra_headers={"Idempotency-Key": idempotency_key.strip()},
timeout=timeout,
)
def get_charge_status(
charge_id: str, *, timeout: float = DEFAULT_TIMEOUT
) -> dict[str, Any]:
"""``GET /api/billing/charge/{id}`` — poll a charge (scope required).
Returns ``{status: "pending"|"settled"|"failed", ...}``. An unknown or foreign
id returns ``{status:"pending"}`` (never 404, never another org's data) — so a
``pending`` that never resolves past the 5-min cap is a *timeout*, not an error.
"""
if not (isinstance(charge_id, str) and charge_id.strip()):
raise BillingError("A charge id is required.", error="invalid_charge_id")
# urllib does not need manual quoting for the opaque ids the server mints, but
# guard against a stray slash that would change the path shape.
safe_id = urllib.parse.quote(charge_id.strip(), safe="")
return _request("GET", f"/api/billing/charge/{safe_id}", timeout=timeout)