mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-21 10:22:18 +00:00
* 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
* feat(billing): billing:manage scope + lazy step-up re-auth (phase 2b)
- NOUS_BILLING_MANAGE_SCOPE constant.
- nous_token_has_billing_scope(): split-based scope check (no false-positive
substring match).
- step_up_nous_billing_scope(): re-runs the device flow requesting
billing:manage, reusing the held credential's portal/inference URLs + client_id
(so a preview stays a preview), persists like _login_nous but WITHOUT the model
picker. Returns True iff the minted token carries the scope (False when NAS
silently downscopes a non-admin / unticked grant).
Lazy step-up (plan D-A): normal login path unchanged; 403 insufficient_scope
from a billing call triggers this. 7 unit tests.
* feat(billing): billing JSON-RPC methods for the TUI (phase 2b)
billing.state / charge / charge_status / auto_reload / step_up in
tui_gateway/server.py. Return STRUCTURED success envelopes (result.ok +
result.error=<code>) rather than JSON-RPC-level errors, so the Ink rpc() promise
always resolves and the TUI branches on the typed billing error code
(insufficient_scope, rate_limited, no_payment_method, …) to render the right
affordance. Money serialized as decimal STRINGS + display strings. charge mints
+ echoes an idempotency_key for retry reuse. 16 unit tests.
* feat(billing): /billing CLI handler + command registry (phase 2b)
- CommandDef("billing", subcommands=buy|auto-reload|limit), added to
_SLACK_VIA_HERMES_ONLY so it routes via /hermes on Slack (keeps the 50-cap
parity test green, same as /credits).
- cli.py::_show_billing + screen helpers: all 5 screens (overview, buy→confirm→
poll, auto-reload, monthly-limit read-only). Reuses _prompt_text_input_modal /
_prompt_text_input (D-C). Non-interactive (_app is None) renders text + portal
deep-link, never prompts (R7). Decimal money end-to-end. 2s/5-min cancellable
poll loop; 429/503 = retry not failure; settled = ledger truth. Lazy step-up on
403 insufficient_scope. no_payment_method treated as mainline funnel-to-portal.
- 6 CLI tests; 156 command tests (incl. Slack/Telegram parity) green.
* feat(billing): /billing Ink TUI screens + tests (phase 2b)
- ui-tui/src/app/slash/commands/billing.ts: /billing TUI command covering all 5
screens — overview (text), buy <amt> → ConfirmReq → charge → non-blocking 2s/
5-min poll loop → settled/failed/timeout branches, auto-reload <below> <to> →
ConfirmReq → PATCH, limit (read-only). Reuses the existing ConfirmReq overlay
(D-C) — no bespoke component. Typed-error envelope branching: insufficient_scope
arms the lazy step-up confirm; no_payment_method/rate_limited/cap funnel to
portal. Client-side amount validation mirrors the server (bounds + 2dp).
- gatewayTypes.ts: Billing* response interfaces.
- registry.ts: register billingCommands.
- billingCommand.test.ts: 12 vitest cases (overview/gating/buy-confirm-poll-
settled/no_payment_method/step-up/limit/auto-reload/validation).
TUI build green; 12/12 vitest pass; slash tests pass once @hermes/ink is built.
* docs(billing): scrub private cross-repo references
NAS is a private repo — remove all references to it from the public PR:
- drop the cross-repo planning doc (planning scaffolding, not a deliverable;
the PR description documents the design)
- replace 'NAS' / 'PR #412 preview' mentions in code + test comments with
generic 'the server' / 'a preview deployment'
* docs(billing): scrub final NAS reference in step-up docstring
* docs(billing): drop dangling plan-doc refs
The phase-2b plan doc was removed in the cross-repo scrub (300afcc0b)
but two module docstrings still pointed at it. Drop the dead refs.
* feat(billing): interactive /billing overlay + step-up UX, portal-URL & token fixes
Adds the interactive /billing TUI overlay and hardens the terminal-billing
client across CLI and TUI.
- TUI: full /billing overlay state machine (overview to buy to confirm,
auto-reload, read-only monthly limit) reusing the existing confirm overlay.
- Step-up: surface the verification link in-transcript and open the browser
via the TUI's own opener (the device flow runs in the headless gateway, so a
printed URL was being dropped); run the step-up handler off the main loop and
emit the link as an out-of-band event so the gateway stays responsive.
- Step-up copy is scope-accurate ("Billing permission granted") and re-checks
/state so it never claims "enabled" when the org kill-switch is still off.
- Portal deep-links resolve to absolute URLs against the active portal base
(the server emits them relative) - fixes a bare "/billing?topup=open" link.
- Billing calls refresh an expired access token via the stored refresh token
instead of reporting a false "not logged in".
- Optimistic funnel: advise "set up a saved card on the portal" up front when
no card is on file (advisory, not a hard gate).
- Token resolution is cached briefly so the 2s charge poll loop stops
re-locking + re-reading the auth store on every tick; 401 re-resolves fresh.
- Remove the temporary demo-mode shims.
Validation: 87 Python billing tests, 88 TS tests (billing command + gateway
event handler), tsc clean, ink + ui-tui builds green.
* docs(billing): add /billing TUI screenshots for PR
* fix(cli): guard _last_invalidate on bare instances; update stale prompt-fallback test
The UI-invalidate throttle read self._last_invalidate unconditionally, which
raised AttributeError on HermesCLI instances built without __init__ (the
thread-safety test's object.__new__ shell). Guard the read with getattr.
The off-main-thread branch of _prompt_text_input was changed (#23185) to cancel
cleanly to None instead of falling back to a bare input() that would hang on the
slash-worker thread; the test still asserted the old direct-input fallback.
Update it to assert the current intended behavior: returns None, calls neither
run_in_terminal nor input(), and does not hang.
295 lines
11 KiB
Python
295 lines
11 KiB
Python
"""Surface-agnostic core for the Phase 2b terminal-billing 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. Mirrors the proven
|
|
``agent/account_usage.py::build_credits_view`` pattern: parse the server payload
|
|
into a frozen dataclass; **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 uuid
|
|
from dataclasses import dataclass, field
|
|
from decimal import Decimal, InvalidOperation
|
|
from typing import Any, Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# =============================================================================
|
|
# Decimal money helpers
|
|
# =============================================================================
|
|
|
|
|
|
def parse_money(value: Any) -> Optional[Decimal]:
|
|
"""Parse a server money value (decimal string) into :class:`Decimal`.
|
|
|
|
Returns None for missing/invalid input. Never raises. Accepts str/int (and,
|
|
defensively, float — though the server always sends strings).
|
|
"""
|
|
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]) -> str:
|
|
"""Format a Decimal as ``$X`` / ``$X.YY`` for display.
|
|
|
|
Whole dollars show no decimals; any fractional amount shows exactly 2dp:
|
|
``Decimal("142.5")`` → ``"$142.50"``, ``Decimal("100")`` → ``"$100"``,
|
|
``Decimal("0.01")`` → ``"$0.01"``.
|
|
"""
|
|
if value is None:
|
|
return "—"
|
|
if value == value.to_integral_value():
|
|
# Whole dollars — no decimal point. format(..., "f") avoids 1E+3 for 1000.
|
|
return f"${format(value.to_integral_value(), 'f')}"
|
|
# Fractional — always show 2dp.
|
|
return f"${format(value.quantize(Decimal('0.01')), 'f')}"
|
|
|
|
|
|
# =============================================================================
|
|
# Parsed sub-structures
|
|
# =============================================================================
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CardInfo:
|
|
brand: str
|
|
last4: str
|
|
|
|
@property
|
|
def masked(self) -> str:
|
|
return f"{self.brand} ····{self.last4}"
|
|
|
|
|
|
@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 AutoReload:
|
|
enabled: bool = False
|
|
threshold_usd: Optional[Decimal] = None
|
|
reload_to_usd: Optional[Decimal] = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class BillingState:
|
|
"""Parsed ``GET /api/billing/state`` — the overview screen's data.
|
|
|
|
Fail-open: ``logged_in=False`` (and 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" | "MEMBER"
|
|
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
|
|
monthly_cap: Optional[MonthlyCap] = None
|
|
auto_reload: Optional[AutoReload] = None
|
|
portal_url: Optional[str] = None
|
|
# When the fetch failed (vs cleanly not-logged-in), the message for the surface.
|
|
error: Optional[str] = None
|
|
|
|
@property
|
|
def is_admin(self) -> bool:
|
|
"""True for OWNER/ADMIN — the roles that can manage billing."""
|
|
return (self.role or "").upper() in ("OWNER", "ADMIN")
|
|
|
|
@property
|
|
def can_charge(self) -> bool:
|
|
"""True when the UI should offer charge/auto-reload actions.
|
|
|
|
Admin role AND the per-org kill-switch on. (The server still enforces;
|
|
this is just for graying out actions the user can't take.)
|
|
"""
|
|
return self.is_admin 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 isinstance(brand, str) and isinstance(last4, str):
|
|
return CardInfo(brand=brand, last4=last4)
|
|
return None
|
|
|
|
|
|
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")),
|
|
)
|
|
|
|
|
|
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`."""
|
|
raw_org = payload.get("org")
|
|
org: dict[str, Any] = raw_org if isinstance(raw_org, dict) else {}
|
|
raw_bounds = payload.get("bounds")
|
|
bounds: dict[str, Any] = raw_bounds if isinstance(raw_bounds, dict) else {}
|
|
|
|
presets: list[Decimal] = []
|
|
for item in payload.get("chargePresets") or ():
|
|
parsed = parse_money(item)
|
|
if parsed is not None:
|
|
presets.append(parsed)
|
|
|
|
return BillingState(
|
|
logged_in=True,
|
|
org_id=org.get("id"),
|
|
org_slug=org.get("slug"),
|
|
org_name=org.get("name"),
|
|
role=org.get("role"),
|
|
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")),
|
|
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 build_billing_state(*, timeout: float = 15.0) -> BillingState:
|
|
"""Fetch + parse ``/api/billing/state``. Fail-open.
|
|
|
|
Returns ``BillingState(logged_in=False)`` when not logged in. On a portal/HTTP
|
|
failure, returns ``logged_in=False`` with ``error`` set so the surface can show
|
|
a clear message rather than crashing.
|
|
"""
|
|
try:
|
|
from hermes_cli.nous_billing import (
|
|
BillingAuthError,
|
|
BillingError,
|
|
_absolutize_portal_url,
|
|
get_billing_state,
|
|
resolve_portal_base_url,
|
|
)
|
|
except Exception:
|
|
return BillingState(logged_in=False, error="billing client unavailable")
|
|
|
|
try:
|
|
payload = get_billing_state(timeout=timeout)
|
|
except BillingAuthError:
|
|
return BillingState(logged_in=False)
|
|
except BillingError as exc:
|
|
logger.debug("billing ▸ /state fetch failed (fail-open)", exc_info=True)
|
|
return BillingState(logged_in=False, error=str(exc))
|
|
except Exception:
|
|
logger.debug("billing ▸ /state unexpected error (fail-open)", exc_info=True)
|
|
return BillingState(logged_in=False, error="could not load billing state")
|
|
|
|
# Prefer a server-supplied portalUrl if present (resolved to absolute in case
|
|
# it's relative); else build the standard one.
|
|
raw_portal = payload.get("portalUrl") if isinstance(payload, dict) else None
|
|
portal_url = _absolutize_portal_url(raw_portal) if raw_portal else None
|
|
if not portal_url:
|
|
try:
|
|
portal_url = _fallback_portal_url(resolve_portal_base_url())
|
|
except Exception:
|
|
portal_url = None
|
|
|
|
return billing_state_from_payload(payload, portal_url=portal_url)
|
|
|
|
|
|
def _fallback_portal_url(base: str) -> str:
|
|
"""Standard billing deep-link when the server omits ``portalUrl``."""
|
|
return f"{base.rstrip('/')}/billing?topup=open"
|
|
|
|
|
|
# =============================================================================
|
|
# Idempotency
|
|
# =============================================================================
|
|
|
|
|
|
def new_idempotency_key() -> str:
|
|
"""Fresh UUID for a user-confirmed purchase (reuse on retry of the SAME buy).
|
|
|
|
The ``Idempotency-Key`` header is mandatory on ``POST /charge``; generate one
|
|
per confirmed purchase and reuse it across retries so a double-submit collapses
|
|
to a single charge. Never reuse a key across different amounts (the server
|
|
returns 409 idempotency_conflict).
|
|
"""
|
|
return str(uuid.uuid4())
|
|
|
|
|
|
# =============================================================================
|
|
# Amount validation (Screen 3 custom 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:
|
|
"""Validate a custom charge amount against bounds + 2dp (multipleOf 0.01).
|
|
|
|
Mirrors the server's accept/reject so the UI can give instant feedback rather
|
|
than round-tripping a sure-to-fail charge. The server is still authoritative.
|
|
"""
|
|
cleaned = (raw or "").strip().lstrip("$").strip()
|
|
amount = parse_money(cleaned)
|
|
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")
|
|
# multipleOf 0.01 — reject sub-cent precision.
|
|
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)
|