- 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
353 lines
15 KiB
Python
353 lines
15 KiB
Python
"""Surface-agnostic core for the ``/subscription`` TUI screen.
|
|
|
|
Companion to :mod:`agent.billing_view` — same fail-open philosophy (``logged_in=False``
|
|
when not logged in / portal unreachable; never crash) and decimal money end-to-end.
|
|
|
|
The TUI ``SubscriptionOverlay`` drives the plan change in-terminal: preview, then
|
|
schedule a downgrade / cancellation / resume (chargeless) or apply an upgrade
|
|
(charges the subscription card). The portal deep-link (``portal_url`` + ``org_id``)
|
|
remains the fallback for an upgrade that needs 3DS / was declined. Until the NAS
|
|
``GET /api/billing/subscription`` endpoint ships, 404s take the fail-open path.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
from dataclasses import dataclass
|
|
from decimal import Decimal
|
|
from typing import Any, Optional
|
|
|
|
from agent.billing_view import OrgRoleCapability, fetch_portal_state, format_money, parse_money, parse_org_fields
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ── Parsed sub-structures ────────────────────────────────────────────────────
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CurrentSubscription:
|
|
"""The user's active subscription. ``None`` (not this object) = no plan.
|
|
|
|
NAS guarantees a present ``current`` is fully populated: ``tier_id`` /
|
|
``tier_name`` / ``monthly_credits`` / ``cycle_ends_at`` are always set; only
|
|
``credits_remaining`` and the cancel/downgrade fields are optional.
|
|
"""
|
|
|
|
tier_id: Optional[str] = None
|
|
tier_name: Optional[str] = None
|
|
monthly_credits: Optional[Decimal] = None
|
|
credits_remaining: Optional[Decimal] = None
|
|
cycle_ends_at: Optional[str] = None # ISO
|
|
pending_downgrade_tier_name: Optional[str] = None
|
|
pending_downgrade_at: Optional[str] = None # ISO
|
|
cancel_at_period_end: bool = False
|
|
cancellation_effective_at: Optional[str] = None # ISO
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SubscriptionTier:
|
|
"""One row of the tier picker (mirrors NAS ``SubscriptionTierOption``).
|
|
|
|
``is_current`` = active plan (shown, not selectable); ``is_enabled=False`` = a
|
|
grandfathered tier the user is on but can no longer select. ``tier_order`` sorts
|
|
the picker and drives the upgrade-vs-downgrade hint.
|
|
"""
|
|
|
|
tier_id: str
|
|
name: str
|
|
tier_order: int = 0
|
|
dollars_per_month: Optional[Decimal] = None
|
|
monthly_credits: Optional[Decimal] = None
|
|
is_current: bool = False
|
|
is_enabled: bool = True
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SubscriptionChangePreview:
|
|
"""Parsed ``POST /api/billing/subscription/preview``.
|
|
|
|
``effect``: ``charge_now`` (upgrade; ``amount_due_now_cents`` is the prorated
|
|
charge) · ``scheduled`` (downgrade / same-price change at ``effective_at``) ·
|
|
``no_op`` (already on target) · ``blocked`` (commit refused; ``reason`` says why).
|
|
"""
|
|
|
|
effect: str
|
|
reason: Optional[str] = None
|
|
current_tier_id: Optional[str] = None
|
|
current_tier_name: Optional[str] = None
|
|
target_tier_id: Optional[str] = None
|
|
target_tier_name: Optional[str] = None
|
|
monthly_credits_delta: Optional[Decimal] = None
|
|
amount_due_now_cents: Optional[int] = None
|
|
effective_at: Optional[str] = None # ISO
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SubscriptionState(OrgRoleCapability):
|
|
"""Parsed ``GET /api/billing/subscription``. Fail-open: ``logged_in=False``
|
|
(empty fields) when not logged in or the portal is unreachable."""
|
|
|
|
logged_in: bool
|
|
org_name: Optional[str] = None
|
|
org_id: Optional[str] = None # org.id from the NAS response
|
|
role: Optional[str] = None # "OWNER" | "ADMIN" | "FINANCE_ADMIN" | "SECURITY_ADMIN" | "MEMBER"
|
|
can_change_plan_raw: Optional[bool] = None
|
|
context: str = "personal" # "personal" | "team"
|
|
current: Optional[CurrentSubscription] = None
|
|
tiers: tuple[SubscriptionTier, ...] = () # selectable catalog (picker)
|
|
portal_url: Optional[str] = None
|
|
error: Optional[str] = None # set when the fetch failed (vs cleanly not-logged-in)
|
|
|
|
|
|
# ── Payload parsing ──────────────────────────────────────────────────────────
|
|
|
|
|
|
def _parse_current(raw: Any) -> Optional[CurrentSubscription]:
|
|
# "No plan" is wire-represented as current:null; a present current is a real
|
|
# plan, so guard on a real tier id and return None otherwise.
|
|
if not isinstance(raw, dict):
|
|
return None
|
|
tier_id = raw.get("tierId") or raw.get("id")
|
|
if not tier_id:
|
|
return None
|
|
return CurrentSubscription(
|
|
tier_id=tier_id,
|
|
tier_name=raw.get("tierName") or raw.get("name"),
|
|
monthly_credits=parse_money(raw.get("monthlyCredits")),
|
|
credits_remaining=parse_money(raw.get("creditsRemaining")),
|
|
cycle_ends_at=raw.get("cycleEndsAt"),
|
|
pending_downgrade_tier_name=raw.get("pendingDowngradeTierName"),
|
|
pending_downgrade_at=raw.get("pendingDowngradeAt"),
|
|
cancel_at_period_end=bool(raw.get("cancelAtPeriodEnd")),
|
|
cancellation_effective_at=raw.get("cancellationEffectiveAt") or None,
|
|
)
|
|
|
|
|
|
def _coalesce(*vals: Any) -> Any:
|
|
"""First non-``None`` value. NAS sends ``0`` for the free tier's ``tierOrder`` /
|
|
``dollarsPerMonth``, which a plain ``x or default`` would drop."""
|
|
for v in vals:
|
|
if v is not None:
|
|
return v
|
|
return None
|
|
|
|
|
|
def _parse_tier(raw: Any) -> Optional[SubscriptionTier]:
|
|
"""Map one NAS ``SubscriptionTierOption`` dict into a :class:`SubscriptionTier`."""
|
|
if not isinstance(raw, dict):
|
|
return None
|
|
tier_id = raw.get("tierId") or raw.get("id")
|
|
if not tier_id:
|
|
return None
|
|
return SubscriptionTier(
|
|
tier_id=tier_id,
|
|
name=raw.get("name") or "",
|
|
tier_order=int(_coalesce(raw.get("tierOrder"), 0)),
|
|
dollars_per_month=parse_money(raw.get("dollarsPerMonthDisplay")),
|
|
monthly_credits=parse_money(raw.get("monthlyCredits")),
|
|
is_current=bool(raw.get("isCurrent")),
|
|
is_enabled=bool(_coalesce(raw.get("isEnabled"), True)),
|
|
)
|
|
|
|
|
|
def subscription_change_preview_from_payload(
|
|
payload: dict[str, Any],
|
|
) -> SubscriptionChangePreview:
|
|
"""Map a raw ``/subscription/preview`` JSON dict into :class:`SubscriptionChangePreview`."""
|
|
effect = payload.get("effect")
|
|
cents = payload.get("amountDueNowCents")
|
|
return SubscriptionChangePreview(
|
|
# Unrecognized/missing effect → ``blocked``: fail safe, never charge on a malformed quote.
|
|
effect=effect if isinstance(effect, str) else "blocked",
|
|
reason=payload.get("reason") or None,
|
|
current_tier_id=payload.get("currentTierId"),
|
|
current_tier_name=payload.get("currentTierName"),
|
|
target_tier_id=payload.get("targetTierId"),
|
|
target_tier_name=payload.get("targetTierName"),
|
|
monthly_credits_delta=parse_money(payload.get("monthlyCreditsDelta")),
|
|
amount_due_now_cents=int(cents) if isinstance(cents, (int, float)) else None,
|
|
effective_at=payload.get("effectiveAt") or None,
|
|
)
|
|
|
|
|
|
def subscription_state_from_payload(
|
|
payload: dict[str, Any], *, portal_url: Optional[str] = None
|
|
) -> SubscriptionState:
|
|
"""Map a raw ``/api/billing/subscription`` JSON dict into :class:`SubscriptionState`."""
|
|
org, can_change_plan_raw = parse_org_fields(payload)
|
|
raw_context = payload.get("context")
|
|
raw_tiers = payload.get("tiers")
|
|
tiers = (
|
|
tuple(t for t in (_parse_tier(x) for x in raw_tiers) if t is not None)
|
|
if isinstance(raw_tiers, list)
|
|
else ()
|
|
)
|
|
return SubscriptionState(
|
|
logged_in=True,
|
|
org_name=org.get("name"),
|
|
org_id=org.get("id") or None,
|
|
role=org.get("role"),
|
|
can_change_plan_raw=can_change_plan_raw,
|
|
context=raw_context if raw_context in ("personal", "team") else "personal",
|
|
current=_parse_current(payload.get("current")),
|
|
tiers=tiers,
|
|
portal_url=portal_url,
|
|
)
|
|
|
|
|
|
# ── Fail-open builders (the surface front doors) ─────────────────────────────
|
|
|
|
|
|
def build_subscription_state(*, timeout: float = 15.0) -> SubscriptionState:
|
|
"""Fetch + parse ``GET /api/billing/subscription``. Fail-open (see
|
|
:func:`agent.billing_view.fetch_portal_state`).
|
|
|
|
``HERMES_DEV_SUBSCRIPTION_FIXTURE`` short-circuits to a fixture so every
|
|
plan/cancel/downgrade/team/not-admin state is testable on CLI and TUI offline.
|
|
"""
|
|
fixture = dev_fixture_subscription_state()
|
|
if fixture is not None:
|
|
return fixture
|
|
return fetch_portal_state(
|
|
"get_subscription_state",
|
|
"subscription",
|
|
failed=lambda **kw: SubscriptionState(logged_in=False, **kw),
|
|
parse=lambda payload, portal_url: subscription_state_from_payload(payload, portal_url=portal_url),
|
|
portal_fallback=lambda base: base,
|
|
timeout=timeout,
|
|
log=logger,
|
|
)
|
|
|
|
|
|
def subscription_manage_url(
|
|
state: SubscriptionState, tier_id: Optional[str] = None
|
|
) -> Optional[str]:
|
|
"""Build ``{portal_origin}/manage-subscription?org_id=<id>[&plan=<tier_id>]``.
|
|
|
|
Mirrors the TUI's ``buildManageUrl``: the target is NAS's OWN ``/manage-subscription``
|
|
page (NOT the Stripe Billing Portal), which routes upgrade→Checkout /
|
|
downgrade→scheduled internally. ``org_id`` pins the right account in multi-org
|
|
situations. ``tier_id`` (the stable ``tiers[]`` id, never a name/slug) preselects
|
|
the picked plan; the portal ignores an unknown tier, so it's appended
|
|
unconditionally when picked. None when no portal URL is resolvable.
|
|
"""
|
|
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
|
|
if not state.portal_url:
|
|
return None
|
|
try:
|
|
parts = urlsplit(state.portal_url)
|
|
except Exception:
|
|
return None
|
|
if parts.scheme not in ("http", "https") or not parts.netloc:
|
|
return None
|
|
|
|
# Preserve unrelated portal query params; org_id / plan are contract-owned
|
|
# (org_id before plan — insertion order is the emitted query order).
|
|
params = dict(parse_qsl(parts.query, keep_blank_values=True))
|
|
params.pop("org_id", None)
|
|
params.pop("plan", None)
|
|
if state.org_id:
|
|
params["org_id"] = state.org_id
|
|
if tier_id:
|
|
params["plan"] = tier_id
|
|
return urlunsplit((parts.scheme, parts.netloc, "/manage-subscription", urlencode(params), ""))
|
|
|
|
|
|
# ── Shared plan-catalog helpers (CLI Free catalog + paid picker) ─────────────
|
|
|
|
|
|
def selectable_tiers(state: SubscriptionState) -> list[SubscriptionTier]:
|
|
"""Enabled paid tiers other than the current plan, cheapest first (``tier_order > 0``
|
|
— dropping to free is a cancellation, not a plan pick)."""
|
|
return sorted(
|
|
(t for t in (state.tiers or ()) if t.is_enabled and not t.is_current and (t.tier_order or 0) > 0),
|
|
key=lambda t: t.tier_order or 0,
|
|
)
|
|
|
|
|
|
def format_tier_row(tier: SubscriptionTier) -> str:
|
|
"""``name · $X/mo[ · $Y credits/mo]`` — thousands-grouped money (mirrors the TUI
|
|
Free rows); the credits suffix appears ONLY when monthly credits are present and > 0."""
|
|
row = f"{tier.name} · {format_money(tier.dollars_per_month, grouped=True)}/mo"
|
|
mc = tier.monthly_credits
|
|
if mc is not None and mc > 0:
|
|
row += f" · {format_money(mc, grouped=True)} credits/mo"
|
|
return row
|
|
|
|
|
|
def is_upgrade(state: SubscriptionState, tier_id: str) -> bool:
|
|
"""True when ``tier_id`` ranks above the current plan by ``tier_order``. Prefers the
|
|
active subscription's tier; falls back to the ``tiers[]`` ``is_current`` marker, else 0."""
|
|
orders = {t.tier_id: (t.tier_order or 0) for t in (state.tiers or ())}
|
|
cur_id = state.current.tier_id if state.current else None
|
|
if cur_id is not None and cur_id in orders:
|
|
cur_order = orders[cur_id]
|
|
else:
|
|
cur_order = next((t.tier_order or 0 for t in (state.tiers or ()) if t.is_current), 0)
|
|
return orders.get(tier_id, 0) > cur_order
|
|
|
|
|
|
# ── Dev fixtures (throwaway scaffolding — env-var driven, no live portal) ────
|
|
|
|
_DEV_FIXTURE_PORTAL = "https://portal.nousresearch.com/billing"
|
|
|
|
|
|
def _dev_current(**over: Any) -> CurrentSubscription:
|
|
base: dict[str, Any] = dict(
|
|
tier_id="plus", tier_name="Plus", monthly_credits=Decimal("1000"),
|
|
credits_remaining=Decimal("420"), cycle_ends_at="2026-07-01",
|
|
)
|
|
return CurrentSubscription(**{**base, **over})
|
|
|
|
|
|
def _dev_tiers(current_id: Optional[str]) -> tuple[SubscriptionTier, ...]:
|
|
"""A sample plan catalog for fixtures (marks ``current_id`` as the active tier)."""
|
|
specs = (("free", "Free", 0, "0", "0"), ("plus", "Plus", 1, "20", "1000"),
|
|
("super", "Super", 2, "40", "3000"), ("ultra", "Ultra", 3, "80", "7000"))
|
|
return tuple(
|
|
SubscriptionTier(
|
|
tier_id=tid, name=name, tier_order=order, dollars_per_month=parse_money(dpm),
|
|
monthly_credits=parse_money(mc), is_current=(tid == current_id), is_enabled=True,
|
|
)
|
|
for tid, name, order, dpm, mc in specs
|
|
)
|
|
|
|
|
|
def dev_fixture_subscription_state() -> Optional[SubscriptionState]:
|
|
"""``HERMES_DEV_SUBSCRIPTION_FIXTURE`` -> fixture :class:`SubscriptionState`; None when unset.
|
|
|
|
``free | mid | top | not-admin | downgrade | cancel | team | logged-out``. Unknown
|
|
name → logged-out with ``error`` so the misconfiguration is visible.
|
|
"""
|
|
name = (os.getenv("HERMES_DEV_SUBSCRIPTION_FIXTURE") or "").strip().lower()
|
|
if not name:
|
|
return None
|
|
name = {"logged_out": "logged-out", "loggedout": "logged-out", "mid-tier": "mid",
|
|
"top-tier": "top", "member": "not-admin"}.get(name, name)
|
|
if name == "logged-out":
|
|
return SubscriptionState(logged_in=False)
|
|
|
|
common = dict(logged_in=True, org_name="Acme Inc", org_id="org_acme", role="OWNER", portal_url=_DEV_FIXTURE_PORTAL)
|
|
plus = dict(current=_dev_current(), tiers=_dev_tiers("plus"))
|
|
states: dict[str, dict[str, Any]] = {
|
|
"free": dict(current=None, tiers=_dev_tiers(None)),
|
|
"mid": plus,
|
|
"top": dict(
|
|
current=_dev_current(tier_id="ultra", tier_name="Ultra", monthly_credits=Decimal("7000"), credits_remaining=Decimal("5000")),
|
|
tiers=_dev_tiers("ultra"),
|
|
),
|
|
"not-admin": {**plus, "role": "MEMBER"},
|
|
"downgrade": dict(
|
|
current=_dev_current(tier_id="super", tier_name="Super", monthly_credits=Decimal("3000"), credits_remaining=Decimal("1500"), pending_downgrade_tier_name="Plus", pending_downgrade_at="2026-07-15"),
|
|
tiers=_dev_tiers("super"),
|
|
),
|
|
"cancel": dict(current=_dev_current(cancel_at_period_end=True, cancellation_effective_at="2026-07-01"), tiers=_dev_tiers("plus")),
|
|
"team": dict(context="team", current=None, org_name="Acme Engineering", org_id="org_eng"),
|
|
}
|
|
if name not in states:
|
|
return SubscriptionState(logged_in=False, error=f"unknown HERMES_DEV_SUBSCRIPTION_FIXTURE: {name}")
|
|
return SubscriptionState(**{**common, **states[name]})
|