From 7668d289d6807de8332ed065c2ef3e5ba9589631 Mon Sep 17 00:00:00 2001 From: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:35:58 +0530 Subject: [PATCH] Terminal-billing client hardening: shared wire types, wire-layer tests, dead RPC removal (#61067) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(shared): move terminal-billing wire types to @hermes/shared The billing/subscription wire shapes (plus UsageBarData/UsageModelData, which they reference) move verbatim from ui-tui/src/gatewayTypes.ts into apps/shared/src/billing-types.ts so the desktop app can share the same gateway contract. gatewayTypes.ts re-exports every moved name from the new @hermes/shared/billing subpath, so no ui-tui consumer changes. The subpath export keeps DOM-less ui-tui from pulling the barrel (whose WebSocket helpers need the DOM lib). ui-tui also now declares its @hermes/shared dependency explicitly instead of relying on workspace hoisting. * test(cli): pin nous_billing wire-layer status-to-exception mapping The HTTP layer's error handling had zero coverage through _request: only 2xx parsing and request shaping were tested, and the mapping cases in test_remote_spending_gate_contract.py hit _raise_for_error directly. Adds 19 tests driving _request via a monkeypatched urlopen: the 401-refresh-retry path (success, terminal plain/session_revoked, idempotency-key preservation, base re-resolution), 403 variants through the wire, 429/503 retry-after, non-JSON error bodies, 404/502 fallbacks, and URLError normalization. Two behaviors are pinned as findings rather than fixed: a JSON-body retryAfter hint is ignored unless the Retry-After header is present, and a bare socket.timeout propagates uncaught (real urllib wraps timeouts in URLError before this layer). * fix(cli): normalize read-phase timeouts to the typed billing error urlopen wraps connect-phase timeouts in URLError (already mapped to network_error), but a timeout during resp.read() raises a bare TimeoutError that escaped the typed-BillingError contract and reached callers as an unhandled exception. Catch it narrowly and normalize. The boundary test now asserts normalization instead of documenting the leak. * fix(shared): stop typing mutation success payloads as error payloads BillingMutationResponse.payload was declared BillingErrorPayload, but on ok:true the gateway passes through the raw NAS success body (rail, changeType, cancelAtPeriodEnd, ...). The TUI never reads it so nothing broke, but the shared contract now feeds the desktop app too — widen the field deliberately and document both shapes. * feat(shared): typed billing refusal and charge-failure unions - BillingRefusalCode covers every code the gateway serializes today, with a (string & {}) arm so unknown future codes (the NAS W3 card-health family) stay assignable — consumers keep their unknown-code fallback. - ChargeFailureReason models the four NAS terminal reasons plus the raw subscription_payment_intent_requires_action code NAS leaks pre-#711. - billing.state now carries the server-derived can_change_plan the gateway emits; capability comments updated (canChangePlan is capability-based, not an OWNER/ADMIN role gate). * feat(shared): closed Known* halves for the refusal and charge-failure unions - KnownBillingRefusalCode / KnownChargeFailureReason are closed literal sets, so classification tables, copy maps and tests can be Record-exhaustive and break at compile time when a code is added but not mapped. The wire types keep the (string & {}) open arm for unknown future codes. - Add network_error (client-originated transport code the gateway already serializes) to the known set. - Export the union types from the root barrel alongside the other billing names. * feat(shared): canonical billing refusal policy and charge-settlement driver - billing-policy.ts: one exhaustive Record classifying every known code (recovery kind, mid-poll ambiguity, idempotency-key reuse) with a documented unknown-code fallback. Surfaces keep their own copy; the behavior classification now has a single home that breaks the build when a new code goes unmapped. - charge-settlement.ts: the settlement poll state machine (2s cadence, 5-minute cap, bounded retry-after backoff, ambiguous-on-revocation) as a pure dependency-injected driver returning a discriminated outcome. - The TUI's pollCharge becomes a thin renderer over the shared driver — byte-identical output, and the desktop poller can now share the same machine instead of a drifting copy. --- apps/shared/package.json | 5 +- apps/shared/src/billing-policy.ts | 46 +++ apps/shared/src/billing-types.ts | 292 ++++++++++++++++ apps/shared/src/charge-settlement.ts | 89 +++++ apps/shared/src/index.ts | 34 ++ hermes_cli/nous_billing.py | 7 + package-lock.json | 1 + tests/hermes_cli/test_nous_billing_request.py | 312 ++++++++++++++++++ ui-tui/package.json | 1 + ui-tui/src/app/slash/commands/topup.ts | 148 ++++----- ui-tui/src/gatewayTypes.ts | 228 +------------ 11 files changed, 862 insertions(+), 301 deletions(-) create mode 100644 apps/shared/src/billing-policy.ts create mode 100644 apps/shared/src/billing-types.ts create mode 100644 apps/shared/src/charge-settlement.ts diff --git a/apps/shared/package.json b/apps/shared/package.json index 3d3c921e14dc..d678748333f4 100644 --- a/apps/shared/package.json +++ b/apps/shared/package.json @@ -4,7 +4,10 @@ "version": "0.0.0", "type": "module", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./billing": "./src/billing-types.ts", + "./billing-policy": "./src/billing-policy.ts", + "./charge-settlement": "./src/charge-settlement.ts" }, "types": "./src/index.ts", "scripts": { diff --git a/apps/shared/src/billing-policy.ts b/apps/shared/src/billing-policy.ts new file mode 100644 index 000000000000..359abe3a794f --- /dev/null +++ b/apps/shared/src/billing-policy.ts @@ -0,0 +1,46 @@ +import type { KnownBillingRefusalCode } from './billing-types.js' + +export type BillingRecovery = 'login' | 'none' | 'portal' | 'reconnect' | 'retry' | 'step_up' + +export interface BillingRefusalPolicy { + recovery: BillingRecovery + ambiguousMidPoll?: true + reuseIdempotencyKey?: true +} + +export const BILLING_REFUSAL_POLICY: Record = { + auto_top_up_disabled_failures: { recovery: 'portal' }, + cli_billing_disabled: { recovery: 'portal' }, + consent_required: { recovery: 'portal' }, + endpoint_unavailable: { recovery: 'retry', reuseIdempotencyKey: true }, + idempotency_conflict: { recovery: 'none' }, + idempotency_key_required: { recovery: 'none' }, + // Deliberate: losing scope mid-poll cannot undo an accepted charge, so its outcome is unknown. + insufficient_scope: { recovery: 'step_up', ambiguousMidPoll: true }, + internal_error: { recovery: 'retry' }, + invalid_charge_id: { recovery: 'none' }, + invalid_request: { recovery: 'none' }, + monthly_cap_exceeded: { recovery: 'portal' }, + network_error: { recovery: 'retry', reuseIdempotencyKey: true }, + no_payment_method: { recovery: 'portal' }, + org_access_denied: { recovery: 'portal' }, + preview_rejected: { recovery: 'none' }, + rate_limited: { recovery: 'retry', reuseIdempotencyKey: true }, + remote_spending_disabled: { recovery: 'portal' }, + remote_spending_revoked: { recovery: 'reconnect', ambiguousMidPoll: true }, + role_required: { recovery: 'portal' }, + session_revoked: { recovery: 'login', ambiguousMidPoll: true }, + stripe_unavailable: { recovery: 'retry', reuseIdempotencyKey: true }, + temporarily_unavailable: { recovery: 'retry', reuseIdempotencyKey: true }, + upgrade_cap_exceeded: { recovery: 'none' }, + validation_failed: { recovery: 'none' } +} + +export function refusalPolicy(code: string): BillingRefusalPolicy { + if (Object.hasOwn(BILLING_REFUSAL_POLICY, code)) { + return BILLING_REFUSAL_POLICY[code as KnownBillingRefusalCode] + } + + // Unknown codes must still show the server message; no special handling is the safe fallback. + return { recovery: 'none' } +} diff --git a/apps/shared/src/billing-types.ts b/apps/shared/src/billing-types.ts new file mode 100644 index 000000000000..d06e24bcba50 --- /dev/null +++ b/apps/shared/src/billing-types.ts @@ -0,0 +1,292 @@ +/** + * Shared terminal-billing wire contracts. + * + * These shapes round-trip between the Python tui_gateway and TypeScript clients + * such as the TUI and desktop app. Keep rendering state, client logic, and the + * gateway event union out of this runtime-free module. + */ + +// ── Terminal billing (Phase 2b) ────────────────────────────────────── + +/** One serialized usage bar (mirrors server `_serialize_usage_bar`). */ +export interface UsageBarData { + kind: 'plan' | 'topup' + remaining_display: string + total_display: string + spent_display: string + pct_used: null | number + fill_fraction: number +} + +/** The shared dollar usage model (mirrors server `_serialize_usage_model`). */ +export interface UsageModelData { + available: boolean + status?: string + plan_name?: null | string + renews_at?: null | string + renews_display?: null | string + subscription_remaining_display?: null | string + topup_remaining_display?: null | string + total_spendable_display?: null | string + has_topup?: boolean + plan_bar?: null | UsageBarData + topup_bar?: null | UsageBarData +} + +/** + * The closed set of refusal/error codes the gateway serializes today + * (`_serialize_billing_error` preserves the raw NAS code where one exists, + * plus the client-originated transport codes). Closed on purpose: an + * exhaustive `Record` (classification tables, + * copy maps, tests) gets a compile error when a code is added here but not + * mapped. + */ +export type KnownBillingRefusalCode = + | 'auto_top_up_disabled_failures' + | 'cli_billing_disabled' + | 'consent_required' + | 'endpoint_unavailable' + | 'idempotency_conflict' + | 'idempotency_key_required' + | 'insufficient_scope' + | 'internal_error' + | 'invalid_charge_id' + | 'invalid_request' + | 'monthly_cap_exceeded' + | 'network_error' + | 'no_payment_method' + | 'org_access_denied' + | 'preview_rejected' + | 'rate_limited' + | 'remote_spending_disabled' + | 'remote_spending_revoked' + | 'role_required' + | 'session_revoked' + | 'stripe_unavailable' + | 'temporarily_unavailable' + | 'upgrade_cap_exceeded' + | 'validation_failed' + +/** + * What the wire actually carries: a known code, or an unknown future one + * (e.g. the NAS W3 card-health family). The `(string & {})` arm keeps unknown + * codes assignable — consumers must keep an unknown-code fallback branch. + */ +export type BillingRefusalCode = KnownBillingRefusalCode | (string & {}) + +/** + * The closed set of terminal reasons a settled-poll charge can fail with (NAS + * `cli-charge-failure-reason.ts` — all four values), plus the raw Stripe code + * NAS pre-#711 leaks for SCA-on-upgrade. + */ +export type KnownChargeFailureReason = + | 'authentication_required' + | 'card_declined' + | 'payment_method_expired' + | 'processing_error' + | 'subscription_payment_intent_requires_action' + +/** Wire shape: a known reason or an unknown future one; degrade safely. */ +export type ChargeFailureReason = KnownChargeFailureReason | (string & {}) + +export interface BillingCardInfo { + brand: string + last4: string + masked: string + /** "Visa ····4242 — the card on your subscription" (= masked when provenance unknown). */ + display?: string + /** Raw card-resolution rung ("subPin" | "customerDefault" | "autoRefill") or null on older NAS. */ + resolved_via?: null | string +} + +export interface BillingMonthlyCap { + is_default_ceiling: boolean + limit_display: string + limit_usd: string | null + spent_display: string + spent_this_month_usd: string | null +} + +export interface BillingAutoReload { + card: + | { kind: 'canonical' } + | { + kind: 'distinct' + payment_method_id: string + brand: string | null + last4: string | null + } + | { kind: 'none' } + enabled: boolean + reload_to_display: string + reload_to_usd: string | null + threshold_display: string + threshold_usd: string | null +} + +export interface BillingStateResponse { + auto_reload: BillingAutoReload | null + balance_display: string + balance_usd: string | null + // NAS capability (canChangePlan) when the server sends it; legacy role fallback otherwise + can_change_plan?: boolean + can_charge: boolean + card: BillingCardInfo | null + charge_presets: string[] + charge_presets_display: string[] + cli_billing_enabled: boolean + error?: string | null + is_admin: boolean + logged_in: boolean + max_usd: string | null + min_usd: string | null + monthly_cap: BillingMonthlyCap | null + ok: boolean + org_name: string | null + portal_url: string | null + role: string | null + // Shared dollar usage model (two-bar view), embedded by the gateway so /topup + // renders the same bars as /usage and /subscription from this single fetch. + usage?: UsageModelData +} + +/** + * Raw error payload echoed from the server (`_serialize_billing_error`). Carries + * the extra fields a few error codes attach — notably `remainingUsd` on + * `monthly_cap_exceeded` — so the client can render the same detail the CLI does. + */ +export interface BillingErrorPayload { + isDefaultCeiling?: boolean + remainingUsd?: string +} + +export interface BillingChargeResponse { + actor?: string + charge_id?: string + code?: string + error?: BillingRefusalCode + idempotency_key?: string + message?: string + ok: boolean + payload?: BillingErrorPayload + portal_url?: string | null + recovery?: string + retry_after?: number | null +} + +export interface BillingChargeStatusResponse { + amount_usd?: string | null + error?: BillingRefusalCode + message?: string + ok: boolean + payload?: BillingErrorPayload + portal_url?: string | null + reason?: ChargeFailureReason | null + retry_after?: number | null + settled_at?: string | null + status?: string +} + +export interface BillingMutationResponse { + actor?: string + code?: string + error?: BillingRefusalCode + granted?: boolean + message?: string + ok: boolean + /** + * On ok:false, the structured error payload. On ok:true the gateway passes + * through the raw NAS success body (e.g. rail, changeType, cancelAtPeriodEnd + * for subscription mutations), which has no stable shape here. + */ + payload?: BillingErrorPayload | Record + portal_url?: string | null + recovery?: string + retry_after?: number | null +} + +export interface SubscriptionTierOption { + tier_id: string + name: string + tier_order: number // sorts the picker + upgrade/downgrade hint + dollars_per_month_display: string // pre-formatted ($X / $X.YY) + monthly_credits: string | null + is_current: boolean // the active plan: shown, not selectable + is_enabled: boolean // false = grandfathered current tier +} + +export interface SubscriptionStateResponse { + ok: boolean + logged_in: boolean + is_admin: boolean + can_change_plan: boolean // NAS capability (canChangePlan: OWNER/ADMIN/FINANCE_ADMIN); legacy role fallback when the server omits it + org_name: string | null + org_id: string | null // org.id from the NAS response + role: string | null + context: 'personal' | 'team' // personal account vs team/org terminal + current: { + tier_id: string | null // null = free (no active sub) + tier_name: string | null + monthly_credits: string | null + credits_remaining: string | null + cycle_ends_at: string | null // ISO + pending_downgrade_tier_name: string | null + pending_downgrade_at: string | null + pending_downgrade_display: string | null // formatted pending_downgrade_at + cancel_at_period_end: boolean // subscription scheduled to cancel at period end + cancellation_effective_at: string | null // ISO when cancellation takes effect + cancellation_effective_display: string | null // formatted cancellation_effective_at + } | null + tiers: SubscriptionTierOption[] // selectable catalog for the in-terminal picker + portal_url: string | null + error?: string | null + // Shared dollar usage model (two-bar view), embedded by the gateway so the + // overlay renders the same bars as /usage from this single fetch. + usage?: UsageModelData +} + +// A chargeless quote (POST /subscription/preview) of what a change would do. +// `effect` drives the confirm copy; a failed preview reuses the typed-error +// envelope fields (same as the mutations) so a 403 still triggers the step-up. +export interface SubscriptionPreviewResponse { + ok: boolean + effect?: 'charge_now' | 'scheduled' | 'no_op' | 'blocked' + reason?: string | null + current_tier_id?: string | null + current_tier_name?: string | null + target_tier_id?: string | null + target_tier_name?: string | null + monthly_credits_delta?: string | null + amount_due_now_cents?: number | null // the prorated upfront charge for an upgrade + effective_at?: string | null // ISO, when a scheduled change lands + // typed-error envelope (present when ok=false) + error?: BillingRefusalCode + message?: string + portal_url?: string | null + retry_after?: number | null + payload?: BillingErrorPayload + actor?: string + code?: string + recovery?: string +} + +// The single money route (POST /subscription/upgrade). `status` distinguishes a +// completed upgrade from an SCA/decline that must finish in the portal at +// `recovery_url`. `idempotency_key` is echoed so a retry reuses it. +export interface SubscriptionUpgradeResponse { + ok: boolean + status?: 'upgraded' | 'already_on_tier' | 'requires_action' | 'payment_failed' + target_tier_name?: string | null + recovery_url?: string | null + reason?: ChargeFailureReason | null + idempotency_key?: string + // typed-error envelope (present when ok=false) + error?: BillingRefusalCode + message?: string + portal_url?: string | null + retry_after?: number | null + payload?: BillingErrorPayload + actor?: string + code?: string + recovery?: string +} diff --git a/apps/shared/src/charge-settlement.ts b/apps/shared/src/charge-settlement.ts new file mode 100644 index 000000000000..6e23252a7189 --- /dev/null +++ b/apps/shared/src/charge-settlement.ts @@ -0,0 +1,89 @@ +import { refusalPolicy } from './billing-policy.js' +import type { BillingChargeStatusResponse } from './billing-types.js' + +export interface SettlementDeps { + fetchStatus(): Promise + sleep(ms: number): Promise + isCancelled(): boolean + now(): number +} + +export type SettlementOutcome = + | { kind: 'settled'; status: BillingChargeStatusResponse } + | { kind: 'failed'; status: BillingChargeStatusResponse } + | { kind: 'refused'; error: string; status: BillingChargeStatusResponse } + | { + kind: 'ambiguous' + error: string + status?: BillingChargeStatusResponse + cause?: unknown + } + | { kind: 'timed_out' } + | { kind: 'cancelled' } + +export const SETTLEMENT_POLL_INTERVAL_MS = 2000 +export const SETTLEMENT_POLL_CAP_MS = 5 * 60 * 1000 +export const SETTLEMENT_MAX_RETRY_AFTER_MS = 30000 + +export async function driveChargeSettlement(deps: SettlementDeps): Promise { + const start = deps.now() + const timedOut = (): boolean => deps.now() - start >= SETTLEMENT_POLL_CAP_MS + + while (true) { + if (deps.isCancelled()) { + return { kind: 'cancelled' } + } + + let status: BillingChargeStatusResponse + + try { + status = await deps.fetchStatus() + } catch (cause) { + return { kind: 'ambiguous', error: 'transport', cause } + } + + if (!status.ok) { + const error = status.error ?? '' + const policy = refusalPolicy(error) + + if (policy.ambiguousMidPoll) { + return { kind: 'ambiguous', error: error || 'unknown', status } + } + + if (policy.recovery === 'retry') { + if (timedOut()) { + return { kind: 'timed_out' } + } + + const wait = Math.min( + (status.retry_after ?? 5) * 1000, + SETTLEMENT_MAX_RETRY_AFTER_MS + ) + + await deps.sleep(wait) + + continue + } + + return { + kind: 'refused', + error: status.error ?? status.message ?? 'error', + status + } + } + + if (status.status === 'settled') { + return { kind: 'settled', status } + } + + if (status.status === 'failed') { + return { kind: 'failed', status } + } + + if (timedOut()) { + return { kind: 'timed_out' } + } + + await deps.sleep(SETTLEMENT_POLL_INTERVAL_MS) + } +} diff --git a/apps/shared/src/index.ts b/apps/shared/src/index.ts index 83f4fee7344b..3b9aff5d4578 100644 --- a/apps/shared/src/index.ts +++ b/apps/shared/src/index.ts @@ -19,3 +19,37 @@ export { type ResolveGatewayWsUrlDeps, type WebSocketAuthParam } from './websocket-url' +export type { + BillingCardInfo, + BillingMonthlyCap, + BillingAutoReload, + BillingRefusalCode, + BillingStateResponse, + BillingErrorPayload, + BillingChargeResponse, + BillingChargeStatusResponse, + BillingMutationResponse, + ChargeFailureReason, + KnownBillingRefusalCode, + KnownChargeFailureReason, + SubscriptionTierOption, + SubscriptionStateResponse, + SubscriptionPreviewResponse, + SubscriptionUpgradeResponse, + UsageBarData, + UsageModelData +} from './billing-types' +export { + BILLING_REFUSAL_POLICY, + type BillingRecovery, + type BillingRefusalPolicy, + refusalPolicy +} from './billing-policy' +export { + driveChargeSettlement, + SETTLEMENT_MAX_RETRY_AFTER_MS, + SETTLEMENT_POLL_CAP_MS, + SETTLEMENT_POLL_INTERVAL_MS, + type SettlementDeps, + type SettlementOutcome +} from './charge-settlement' diff --git a/hermes_cli/nous_billing.py b/hermes_cli/nous_billing.py index 83c65f70de74..a2c59a317f8d 100644 --- a/hermes_cli/nous_billing.py +++ b/hermes_cli/nous_billing.py @@ -462,6 +462,13 @@ def _request( raise BillingError( f"Could not reach Nous Portal: {exc.reason}", error="network_error" ) from exc + except TimeoutError as exc: + # urlopen() wraps CONNECT-phase timeouts in URLError, but a timeout + # during resp.read() surfaces as a bare TimeoutError — normalize it so + # transport failures always honor the typed-BillingError contract. + raise BillingError( + "Could not reach Nous Portal: timed out", error="network_error" + ) from exc # ============================================================================= diff --git a/package-lock.json b/package-lock.json index 7f4c1e5f2eb0..0b5dfdb169d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19508,6 +19508,7 @@ "version": "0.0.1", "dependencies": { "@hermes/ink": "file:./packages/hermes-ink", + "@hermes/shared": "file:../apps/shared", "@nanostores/react": "^1.1.0", "ink": "^6.8.0", "ink-text-input": "^6.0.0", diff --git a/tests/hermes_cli/test_nous_billing_request.py b/tests/hermes_cli/test_nous_billing_request.py index 92471ee2b131..6ea965dd9090 100644 --- a/tests/hermes_cli/test_nous_billing_request.py +++ b/tests/hermes_cli/test_nous_billing_request.py @@ -10,6 +10,7 @@ from __future__ import annotations import io import json +import socket from contextlib import contextmanager import pytest @@ -31,6 +32,47 @@ class _FakeResp(io.BytesIO): self.close() +def _http_error(status: int, body: bytes | dict[str, object] = b"{}", headers=None): + """Build the real urllib HTTPError object _request catches.""" + if isinstance(body, dict): + body = json.dumps(body).encode() + return nb.urllib.error.HTTPError( + "https://portal.example/api/billing/state", + status, + "HTTP Error", + headers or {}, + fp=io.BytesIO(body), + ) + + +def _sequence(monkeypatch, *outcomes, resolver=None): + """Stub urlopen with ordered outcomes and record each Request.""" + seen: list[dict[str, object]] = [] + monkeypatch.setattr(nb, "_token_cache", None, raising=False) + monkeypatch.setattr( + nb, + "_resolve_token_and_base", + resolver or (lambda **kw: ("tok", "https://portal.example")), + ) + + def _fake_urlopen(req, timeout=None): + seen.append( + { + "method": req.get_method(), + "url": req.full_url, + "data": json.loads(req.data.decode()) if req.data else None, + "headers": {k.lower(): v for k, v in req.header_items()}, + } + ) + outcome = outcomes[len(seen) - 1] + if isinstance(outcome, BaseException): + raise outcome + return outcome + + monkeypatch.setattr(nb.urllib.request, "urlopen", _fake_urlopen) + return seen + + @contextmanager def _stub(monkeypatch, body: bytes, status: int = 200): # Bypass auth/token resolution entirely — we only exercise response parsing. @@ -159,3 +201,273 @@ def test_post_subscription_upgrade_blank_key_raises(): subscription_type_id="nous-chat-plan-40", idempotency_key=" " ) assert getattr(ei.value, "error", None) == "idempotency_key_required" + + +# --------------------------------------------------------------------------- +# Wire-layer HTTP error mapping through _request. +# --------------------------------------------------------------------------- + + +def test_401_refreshes_token_and_retries_successfully(monkeypatch): + # A cached-token 401 is retried once with a freshly resolved token. + def _resolver(*, use_cache=True): + token = "tok-fresh" if not use_cache else "tok-stale" + return token, "https://portal.example" + + seen = _sequence( + monkeypatch, + _http_error(401), + _FakeResp(b'{"ok": true}'), + resolver=_resolver, + ) + + assert nb.get_billing_state() == {"ok": True} + assert len(seen) == 2 + assert seen[0]["headers"]["authorization"] == "Bearer tok-stale" + assert seen[1]["headers"]["authorization"] == "Bearer tok-fresh" + + +def test_401_retries_once_then_plain_401_is_terminal(monkeypatch): + # A second plain 401 maps to auth failure, not another recursive retry. + seen = _sequence(monkeypatch, _http_error(401), _http_error(401)) + + with pytest.raises(nb.BillingAuthError): + nb.get_billing_state() + + assert len(seen) == 2 + + +def test_401_retry_terminal_session_revoked_preserves_recovery(monkeypatch): + # session_revoked is only surfaced after the one refresh attempt is exhausted. + seen = _sequence( + monkeypatch, + _http_error(401), + _http_error(401, {"error": "session_revoked", "recovery": "login"}), + ) + + with pytest.raises(nb.BillingSessionRevoked) as ei: + nb.get_billing_state() + + assert len(seen) == 2 + assert ei.value.recovery == "login" + + +def test_post_charge_preserves_idempotency_key_across_401_retry(monkeypatch): + # Money requests must retry with the exact same Idempotency-Key. + seen = _sequence( + monkeypatch, + _http_error(401), + _FakeResp(b'{"chargeId": "ch_1"}', status=202), + ) + + assert nb.post_charge(amount_usd="10", idempotency_key="k1") == { + "chargeId": "ch_1" + } + assert len(seen) == 2 + assert seen[0]["headers"]["idempotency-key"] == "k1" + assert seen[1]["headers"]["idempotency-key"] == "k1" + + +def test_403_insufficient_scope_maps_through_request(monkeypatch): + # Wire 403 insufficient_scope drives the lazy billing:manage step-up path. + _sequence(monkeypatch, _http_error(403, {"error": "insufficient_scope"})) + + with pytest.raises(nb.BillingScopeRequired): + nb.get_billing_state() + + +def test_403_remote_spending_revoked_maps_through_request(monkeypatch): + # The wire discriminator keeps spend-revocation distinct from missing scope. + _sequence( + monkeypatch, + _http_error( + 403, + { + "error": "remote_spending_revoked", + "actor": "self", + "recovery": "reconnect", + }, + ), + ) + + with pytest.raises(nb.BillingRemoteSpendingRevoked) as ei: + nb.get_billing_state() + + assert ei.value.actor == "self" + assert ei.value.recovery == "reconnect" + + +def test_403_cli_billing_disabled_stays_generic_with_portal_url(monkeypatch): + # Business denials stay generic so surfaces can branch on code/recovery. + monkeypatch.delenv("HERMES_PORTAL_BASE_URL", raising=False) + monkeypatch.delenv("NOUS_PORTAL_BASE_URL", raising=False) + _sequence( + monkeypatch, + _http_error( + 403, + { + "error": "cli_billing_disabled", + "code": "remote_spending_disabled", + "recovery": "enable_account_toggle", + "portalUrl": "/billing", + }, + ), + ) + + with pytest.raises(nb.BillingError) as ei: + nb.get_billing_state() + + assert type(ei.value) is nb.BillingError + assert ei.value.code == "remote_spending_disabled" + assert ei.value.recovery == "enable_account_toggle" + assert ei.value.portal_url == "https://portal.nousresearch.com/billing" + + +def test_429_retry_after_header_maps_to_rate_limited(monkeypatch): + # 429 is rate-limited and reads Retry-After from headers. + _sequence(monkeypatch, _http_error(429, headers={"Retry-After": "15"})) + + with pytest.raises(nb.BillingRateLimited) as ei: + nb.get_billing_state() + + assert ei.value.retry_after == 15 + + +def test_429_body_retry_after_hint_is_ignored_without_header(monkeypatch): + # Pins current behavior: the client reads only the Retry-After header. + # Whether it should also honor retryAfter in JSON is an open product question. + _sequence( + monkeypatch, + _http_error(429, {"error": "rate_limited", "retryAfter": 20}), + ) + + with pytest.raises(nb.BillingRateLimited) as ei: + nb.get_billing_state() + + assert ei.value.retry_after is None + + +def test_503_without_headers_is_rate_limited_without_retry_after(monkeypatch): + # Missing headers must not crash retry-after parsing. + _sequence(monkeypatch, _http_error(503, headers=None)) + + with pytest.raises(nb.BillingRateLimited) as ei: + nb.get_billing_state() + + assert ei.value.retry_after is None + + +def test_non_json_403_body_maps_to_generic_billing_denied(monkeypatch): + # An unparseable 403 has no discriminator, so no subtype is selected. + _sequence(monkeypatch, _http_error(403, b"Forbidden")) + + with pytest.raises(nb.BillingError) as ei: + nb.get_billing_state() + + assert type(ei.value) is nb.BillingError + assert str(ei.value) == "Billing request denied." + + +def test_non_json_second_401_maps_to_auth_error_not_session_revoked(monkeypatch): + # The terminal 401 must not become session_revoked without a JSON discriminator. + _sequence( + monkeypatch, + _http_error(401), + _http_error(401, b"Unauthorized"), + ) + + with pytest.raises(nb.BillingAuthError) as ei: + nb.get_billing_state() + + assert not isinstance(ei.value, nb.BillingSessionRevoked) + + +def test_non_json_500_body_maps_to_generic_failure(monkeypatch): + # Generic server failures keep the status and default failure message. + _sequence(monkeypatch, _http_error(500, b"Oops")) + + with pytest.raises(nb.BillingError) as ei: + nb.get_billing_state() + + assert ei.value.status == 500 + assert str(ei.value) == "Billing request failed (500)." + + +def test_404_get_charge_status_maps_to_generic_billing_error(monkeypatch): + # get_charge_status should surface unexpected 404s as typed generic errors. + _sequence(monkeypatch, _http_error(404)) + + with pytest.raises(nb.BillingError) as ei: + nb.get_charge_status("ch_404") + + assert type(ei.value) is nb.BillingError + assert ei.value.status == 404 + + +def test_502_empty_json_body_maps_to_generic_error_without_error_code(monkeypatch): + # Empty JSON error bodies preserve the HTTP status and no error discriminator. + _sequence(monkeypatch, _http_error(502, {})) + + with pytest.raises(nb.BillingError) as ei: + nb.get_billing_state() + + assert type(ei.value) is nb.BillingError + assert ei.value.status == 502 + assert ei.value.error is None + + +def test_urlerror_dns_maps_to_network_error(monkeypatch): + # urllib transport failures are normalized to BillingError(network_error). + _sequence( + monkeypatch, + nb.urllib.error.URLError("Name or service not known"), + ) + + with pytest.raises(nb.BillingError) as ei: + nb.get_billing_state() + + assert ei.value.error == "network_error" + assert "Could not reach Nous Portal" in str(ei.value) + + +def test_urlerror_wrapped_timeout_maps_to_network_error(monkeypatch): + # Real urllib timeouts arrive wrapped in URLError at this layer. + _sequence(monkeypatch, nb.urllib.error.URLError(TimeoutError("timed out"))) + + with pytest.raises(nb.BillingError) as ei: + nb.get_billing_state() + + assert ei.value.error == "network_error" + assert "Could not reach Nous Portal" in str(ei.value) + + +def test_bare_socket_timeout_normalizes_to_network_error(monkeypatch): + # urlopen wraps connect-phase timeouts in URLError, but a read-phase timeout + # is a bare TimeoutError — it must still honor the typed-BillingError contract. + _sequence(monkeypatch, socket.timeout()) + + with pytest.raises(nb.BillingError) as ei: + nb.get_billing_state() + + assert ei.value.error == "network_error" + assert "timed out" in str(ei.value) + + +def test_401_retry_re_resolves_base_url(monkeypatch): + # The auth retry re-runs base resolution too, so preview/prod flips are honored. + def _resolver(*, use_cache=True): + if use_cache: + return "tok-stale", "https://old.example" + return "tok-fresh", "https://new.example" + + seen = _sequence( + monkeypatch, + _http_error(401), + _FakeResp(b'{"ok": true}'), + resolver=_resolver, + ) + + assert nb.get_billing_state() == {"ok": True} + assert len(seen) == 2 + assert seen[0]["url"] == "https://old.example/api/billing/state" + assert seen[1]["url"] == "https://new.example/api/billing/state" diff --git a/ui-tui/package.json b/ui-tui/package.json index 7c83ad71e6b3..34fa3c31db4f 100644 --- a/ui-tui/package.json +++ b/ui-tui/package.json @@ -19,6 +19,7 @@ }, "dependencies": { "@hermes/ink": "file:./packages/hermes-ink", + "@hermes/shared": "file:../apps/shared", "@nanostores/react": "^1.1.0", "ink": "^6.8.0", "ink-text-input": "^6.0.0", diff --git a/ui-tui/src/app/slash/commands/topup.ts b/ui-tui/src/app/slash/commands/topup.ts index 2f1fa6d1e9f0..7bbbad027134 100644 --- a/ui-tui/src/app/slash/commands/topup.ts +++ b/ui-tui/src/app/slash/commands/topup.ts @@ -1,3 +1,8 @@ +import { + driveChargeSettlement, + type SettlementOutcome +} from '@hermes/shared/charge-settlement' + import type { BillingChargeResponse, BillingChargeStatusResponse, @@ -10,10 +15,6 @@ import type { BillingChargeOutcome, BillingOverlayCtx } from '../../interfaces.j import { patchOverlayState } from '../../overlayStore.js' import type { SlashCommand, SlashRunCtx } from '../types.js' -// Poll cadence (plan §5, frozen): 2s interval, 5-minute cap. -const POLL_INTERVAL_MS = 2000 -const POLL_CAP_MS = 5 * 60 * 1000 - const UNCONFIRMED_CHARGE_MESSAGE = '🟡 Your last charge’s outcome is unconfirmed — check your balance/history before retrying.' @@ -174,97 +175,82 @@ const requestRemoteSpending = (ctx: SlashRunCtx): Promise => /** Poll a charge to a terminal state (settled/failed/timeout). Non-blocking. */ const pollCharge = (sys: Sys, ctx: SlashRunCtx, chargeId: string, portalUrl?: string | null): void => { - const start = Date.now() + const renderOutcome = (outcome: SettlementOutcome): void => { + switch (outcome.kind) { + case 'settled': + sys(`✅ ${outcome.status.amount_usd ? `$${outcome.status.amount_usd}` : 'Credits'} added.`) - // The 5-min cap, honored on EVERY non-terminal path (pending AND throttled) - // so a sustained 429/503 can't keep the poll alive forever. - const timedOut = (): boolean => { - if (Date.now() - start < POLL_CAP_MS) { - return false - } + return - sys( - '🟡 Still processing after 5 minutes — this is a timeout, not a failure. ' + 'Check /topup or the portal shortly.' - ) + case 'failed': + renderChargeFailed(sys, outcome.status.reason, portalUrl) - if (portalUrl) { - sys(`Portal: ${portalUrl}`) - } + return - return true - } + case 'refused': + sys(`🔴 Could not check the charge: ${outcome.status.message || outcome.status.error || 'error'}`) - const tick = (): void => { - if (ctx.stale()) { - return - } + return - ctx.gateway - .rpc('billing.charge_status', { charge_id: chargeId }) - .then( - ctx.guarded(r => { - if (!r.ok) { - // 429/503 while polling = retry-after, NOT a failure. Back off + continue. - if ( - r.error === 'rate_limited' || - r.error === 'temporarily_unavailable' || - r.error === 'stripe_unavailable' - ) { - if (timedOut()) { - return - } + case 'ambiguous': + if (outcome.status) { + renderBillingError(sys, ctx, outcome.status) + sys(UNCONFIRMED_CHARGE_MESSAGE) - const wait = (r.retry_after ?? 5) * 1000 - setTimeout(tick, Math.min(wait, 30000)) + return + } - return - } - - // CF-7 rule 4: a post-revoke 403 (or session loss) while polling means - // the prior charge's outcome is AMBIGUOUS — it may have settled. Do not - // call it failed; surface the revoke + tell the user to verify balance. - if (r.error === 'remote_spending_revoked' || r.error === 'session_revoked') { - renderBillingError(sys, ctx, r) - sys(UNCONFIRMED_CHARGE_MESSAGE) - - return - } - - sys(`🔴 Could not check the charge: ${r.message || r.error || 'error'}`) - - return - } - - if (r.status === 'settled') { - sys(`✅ ${r.amount_usd ? `$${r.amount_usd}` : 'Credits'} added.`) - - return - } - - if (r.status === 'failed') { - renderChargeFailed(sys, r.reason, portalUrl) - - return - } - - // pending → keep polling until the 5-min cap, then call it a timeout. - if (timedOut()) { - return - } - - setTimeout(tick, POLL_INTERVAL_MS) - }) - ) - .catch(e => { - ctx.guardedErr(e) + if ('cause' in outcome) { + ctx.guardedErr(outcome.cause) + } if (!ctx.stale()) { sys(UNCONFIRMED_CHARGE_MESSAGE) } - }) + + return + + case 'timed_out': + sys( + '🟡 Still processing after 5 minutes — this is a timeout, not a failure. ' + + 'Check /topup or the portal shortly.' + ) + + if (portalUrl) { + sys(`Portal: ${portalUrl}`) + } + + return + + case 'cancelled': + return + } } - tick() + void driveChargeSettlement({ + fetchStatus: async () => { + const status = await ctx.gateway.rpc('billing.charge_status', { + charge_id: chargeId + }) + + if (!status) { + throw new Error('billing.charge_status returned no response') + } + + return status + }, + isCancelled: () => ctx.stale(), + now: () => Date.now(), + sleep: ms => new Promise(resolve => setTimeout(resolve, ms)) + }).then(outcome => { + if (outcome.kind === 'ambiguous' && !outcome.status) { + renderOutcome(outcome) + + return + } + + ctx.guarded(renderOutcome)(outcome) + }) } const renderChargeFailed = (sys: Sys, reason?: string | null, portalUrl?: string | null): void => { diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index b79de83222d6..1b87eb696ac3 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -1,3 +1,4 @@ +import type { UsageModelData } from '@hermes/shared/billing' import type { SessionInfo, SlashCategory, SubagentStatus, Usage } from './types.js' export interface GatewaySkin { @@ -45,200 +46,14 @@ export interface SlashExecResponse { // ── Terminal billing (Phase 2b) ────────────────────────────────────── -export interface BillingCardInfo { - brand: string - last4: string - masked: string - /** "Visa ····4242 — the card on your subscription" (= masked when provenance unknown). */ - display?: string - /** Raw card-resolution rung ("subPin" | "customerDefault" | "autoRefill") or null on older NAS. */ - resolved_via?: null | string -} - -export interface BillingMonthlyCap { - is_default_ceiling: boolean - limit_display: string - limit_usd: string | null - spent_display: string - spent_this_month_usd: string | null -} - -export interface BillingAutoReload { - card: - | { kind: 'canonical' } - | { - kind: 'distinct' - payment_method_id: string - brand: string | null - last4: string | null - } - | { kind: 'none' } - enabled: boolean - reload_to_display: string - reload_to_usd: string | null - threshold_display: string - threshold_usd: string | null -} - -export interface BillingStateResponse { - auto_reload: BillingAutoReload | null - balance_display: string - balance_usd: string | null - can_charge: boolean - card: BillingCardInfo | null - charge_presets: string[] - charge_presets_display: string[] - cli_billing_enabled: boolean - error?: string | null - is_admin: boolean - logged_in: boolean - max_usd: string | null - min_usd: string | null - monthly_cap: BillingMonthlyCap | null - ok: boolean - org_name: string | null - portal_url: string | null - role: string | null - // Shared dollar usage model (two-bar view), embedded by the gateway so /topup - // renders the same bars as /usage and /subscription from this single fetch. - usage?: UsageModelData -} - -/** - * Raw error payload echoed from the server (`_serialize_billing_error`). Carries - * the extra fields a few error codes attach — notably `remainingUsd` on - * `monthly_cap_exceeded` — so the client can render the same detail the CLI does. - */ -export interface BillingErrorPayload { - isDefaultCeiling?: boolean - remainingUsd?: string -} - -export interface BillingChargeResponse { - actor?: string - charge_id?: string - code?: string - error?: string - idempotency_key?: string - message?: string - ok: boolean - payload?: BillingErrorPayload - portal_url?: string | null - recovery?: string - retry_after?: number | null -} - -export interface BillingChargeStatusResponse { - amount_usd?: string | null - error?: string - message?: string - ok: boolean - payload?: BillingErrorPayload - portal_url?: string | null - reason?: string | null - retry_after?: number | null - settled_at?: string | null - status?: string -} - -export interface BillingMutationResponse { - actor?: string - code?: string - error?: string - granted?: boolean - message?: string - ok: boolean - payload?: BillingErrorPayload - portal_url?: string | null - recovery?: string - retry_after?: number | null -} - -export interface SubscriptionTierOption { - tier_id: string - name: string - tier_order: number // sorts the picker + upgrade/downgrade hint - dollars_per_month_display: string // pre-formatted ($X / $X.YY) - monthly_credits: string | null - is_current: boolean // the active plan: shown, not selectable - is_enabled: boolean // false = grandfathered current tier -} - -export interface SubscriptionStateResponse { - ok: boolean - logged_in: boolean - is_admin: boolean - can_change_plan: boolean // role gate (ADMIN/OWNER), from NAS - org_name: string | null - org_id: string | null // org.id from the NAS response - role: string | null - context: 'personal' | 'team' // personal account vs team/org terminal - current: { - tier_id: string | null // null = free (no active sub) - tier_name: string | null - monthly_credits: string | null - credits_remaining: string | null - cycle_ends_at: string | null // ISO - pending_downgrade_tier_name: string | null - pending_downgrade_at: string | null - pending_downgrade_display: string | null // formatted pending_downgrade_at - cancel_at_period_end: boolean // subscription scheduled to cancel at period end - cancellation_effective_at: string | null // ISO when cancellation takes effect - cancellation_effective_display: string | null // formatted cancellation_effective_at - } | null - tiers: SubscriptionTierOption[] // selectable catalog for the in-terminal picker - portal_url: string | null - error?: string | null - // Shared dollar usage model (two-bar view), embedded by the gateway so the - // overlay renders the same bars as /usage from this single fetch. - usage?: UsageModelData -} - -// A chargeless quote (POST /subscription/preview) of what a change would do. -// `effect` drives the confirm copy; a failed preview reuses the typed-error -// envelope fields (same as the mutations) so a 403 still triggers the step-up. -export interface SubscriptionPreviewResponse { - ok: boolean - effect?: 'charge_now' | 'scheduled' | 'no_op' | 'blocked' - reason?: string | null - current_tier_id?: string | null - current_tier_name?: string | null - target_tier_id?: string | null - target_tier_name?: string | null - monthly_credits_delta?: string | null - amount_due_now_cents?: number | null // the prorated upfront charge for an upgrade - effective_at?: string | null // ISO, when a scheduled change lands - // typed-error envelope (present when ok=false) - error?: string - message?: string - portal_url?: string | null - retry_after?: number | null - payload?: BillingErrorPayload - actor?: string - code?: string - recovery?: string -} - -// The single money route (POST /subscription/upgrade). `status` distinguishes a -// completed upgrade from an SCA/decline that must finish in the portal at -// `recovery_url`. `idempotency_key` is echoed so a retry reuses it. -export interface SubscriptionUpgradeResponse { - ok: boolean - status?: 'upgraded' | 'already_on_tier' | 'requires_action' | 'payment_failed' - target_tier_name?: string | null - recovery_url?: string | null - reason?: string | null - idempotency_key?: string - // typed-error envelope (present when ok=false) - error?: string - message?: string - portal_url?: string | null - retry_after?: number | null - payload?: BillingErrorPayload - actor?: string - code?: string - recovery?: string -} +// Wire shapes now live in @hermes/shared for reuse by TypeScript clients. +export type { + BillingCardInfo, BillingMonthlyCap, BillingAutoReload, BillingStateResponse, + BillingErrorPayload, BillingChargeResponse, BillingChargeStatusResponse, + BillingMutationResponse, SubscriptionTierOption, SubscriptionStateResponse, + SubscriptionPreviewResponse, SubscriptionUpgradeResponse, + UsageBarData, UsageModelData, +} from '@hermes/shared/billing' export type CommandDispatchResponse = | { output?: string; type: 'exec' | 'plugin' } @@ -433,31 +248,6 @@ export interface SessionUsageResponse { usage?: UsageModelData } -/** One serialized usage bar (mirrors server `_serialize_usage_bar`). */ -export interface UsageBarData { - kind: 'plan' | 'topup' - remaining_display: string - total_display: string - spent_display: string - pct_used: null | number - fill_fraction: number -} - -/** The shared dollar usage model (mirrors server `_serialize_usage_model`). */ -export interface UsageModelData { - available: boolean - status?: string - plan_name?: null | string - renews_at?: null | string - renews_display?: null | string - subscription_remaining_display?: null | string - topup_remaining_display?: null | string - total_spendable_display?: null | string - has_topup?: boolean - plan_bar?: null | UsageBarData - topup_bar?: null | UsageBarData -} - export interface SessionStatusResponse { output?: string }