diff --git a/agent/billing_usage.py b/agent/billing_usage.py index c3ab6203e3..2722429fc2 100644 --- a/agent/billing_usage.py +++ b/agent/billing_usage.py @@ -1,32 +1,18 @@ -"""Shared dollar-denominated usage model for the billing/subscription surfaces. +"""Shared dollar-denominated usage model for the ``/usage`` and ``/subscription`` bars. -The single source of truth behind the ``/usage`` and ``/subscription`` usage -bars (TUI + CLI). User feedback (Jun 2026): the terminal surfaces show -**dollars**, never "credits", and every usage bar must make the monthly -subscription allowance and separately-purchased top-up dollars distinctly -visible. +Terminal surfaces show **dollars**, never "credits"; the monthly subscription +allowance and separately-purchased top-up dollars must stay distinctly visible. -Data source: the NAS account-info fetch (``NousPortalAccountInfo``), whose -``paid_service_access_info`` carries the three dollar magnitudes we render -(despite the legacy ``*_credits`` field names, these are USD floats): +Data source: ``NousPortalAccountInfo.paid_service_access_info`` (USD floats despite +the legacy ``*_credits`` names): ``subscription_credits_remaining`` (plan $ left), +``purchased_credits_remaining`` (top-up $, rolls over), ``total_usable_credits``; +plus ``subscription.monthly_credits`` (plan bar denominator) and ``current_period_end``. - - ``subscription_credits_remaining`` -> plan dollars left this month - - ``purchased_credits_remaining`` -> top-up dollars left (rolls over) - - ``total_usable_credits`` -> total spendable - -plus ``subscription.monthly_credits`` (the plan's monthly $ allowance, the -denominator for the "% used" plan bar) and ``current_period_end`` (renewal). - -Design: two SEPARATE bars (decided with the user) rather than one crammed -three-segment bar — at terminal widths three same-glyph density segments are -unreadable. The plan bar is "spent vs allowance this month" (carries % used); -the top-up bar is "money you bought, doesn't expire". Each gets full -resolution and a single fill glyph, so the bar is never ambiguous and never -relies on color. - -Fail-open everywhere: any missing/non-finite field degrades to fewer bars or a -magnitudes-only view; a logged-out / unreachable portal yields -``available=False`` and the surface shows nothing. +Design: two SEPARATE bars rather than one three-segment bar — at terminal widths +three same-glyph density segments are unreadable. Plan bar = spent vs allowance +(carries % used); top-up bar = purchased money that doesn't expire. Fail-open: +missing/non-finite fields degrade to fewer bars; logged-out / unreachable portal +yields ``available=False`` and the surface shows nothing. """ from __future__ import annotations @@ -39,14 +25,17 @@ from typing import Any, Optional logger = logging.getLogger(__name__) -# Below this TOTAL spendable ($), a paid account is flagged "low" — the alert -# state that nudges top-up/upgrade before a mid-run cutoff. Product threshold -# (user feedback): "any amount below $5 should be an alert status." +# Below this TOTAL spendable ($) a paid account is flagged "low" — the alert state +# that nudges top-up/upgrade before a mid-run cutoff (product: "below $5 is an alert"). LOW_BALANCE_THRESHOLD_USD = 5.0 def _finite(value: Any) -> Optional[float]: - """Return value as a float iff it's a real finite number (not bool/NaN/Inf).""" + """Return value as a float iff it's a real finite number (not bool/NaN/Inf). + + NaN/Infinity slip past isinstance (json.loads parses bare NaN by default) and + would otherwise render as ``$nan`` with a falsely-confident gauge. + """ if isinstance(value, bool) or not isinstance(value, (int, float)): return None f = float(value) @@ -54,17 +43,33 @@ def _finite(value: Any) -> Optional[float]: def _fmt_usd(value: Optional[float]) -> str: - """``$X.YY`` for display. ``None`` -> ``$0.00`` (callers gate on presence).""" + """``$X,XXX.YY`` for display. ``None`` -> ``$0.00`` (callers gate on presence).""" return f"${(value or 0.0):,.2f}" -def format_renews(value: Optional[str]) -> Optional[str]: - """Format an ISO date/timestamp as a human date, e.g. ``Jul 24, 2026``. +def nous_logged_in() -> bool: + """Cheap local auth-state check: a Nous access token is present. Fail-closed.""" + try: + from hermes_cli.auth import get_provider_auth_state - Accepts ``2026-07-24``, ``2026-07-24T11:05:01.000Z``, etc. Returns the raw - string unchanged if it can't be parsed (never raises), and ``None`` for - empty input. - """ + tok = (get_provider_auth_state("nous") or {}).get("access_token") + return isinstance(tok, str) and bool(tok.strip()) + except Exception: + return False + + +def fetch_nous_account(timeout: float): + """Wall-clock-bounded fresh portal account fetch. Raises on failure/timeout.""" + import concurrent.futures + + from hermes_cli.nous_account import get_nous_portal_account_info + + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: + return pool.submit(get_nous_portal_account_info, force_fresh=True).result(timeout=timeout) + + +def format_renews(value: Optional[str]) -> Optional[str]: + """ISO date/timestamp -> ``Jul 24, 2026``; unparseable input is returned unchanged.""" if not value: return None from datetime import datetime @@ -76,7 +81,6 @@ def format_renews(value: Optional[str]) -> Optional[str]: try: dt = datetime.fromisoformat(iso) except ValueError: - # Fall back to a bare date prefix (YYYY-MM-DD) if present. try: dt = datetime.strptime(text[:10], "%Y-%m-%d") except ValueError: @@ -89,9 +93,8 @@ def format_renews(value: Optional[str]) -> Optional[str]: class UsageBar: """One full-resolution bar: ``spent`` of ``total``, plus a remaining figure. - ``kind`` is ``"plan"`` (monthly allowance, shows % used) or ``"topup"`` - (purchased dollars, no denominator — ``spent`` is 0 and ``total`` == - ``remaining`` so it renders as a full bar of available balance). + ``kind`` is ``"plan"`` (monthly allowance, shows % used) or ``"topup"`` (no + denominator — ``spent`` is 0 and ``total == remaining`` so it renders full). """ kind: str # "plan" | "topup" @@ -117,11 +120,8 @@ class UsageBar: class UsageModel: """Surface-agnostic dollar usage model shared by /usage and /subscription. - ``status`` classifies the account for copy selection: - - ``"free"`` : no paid access / no subscription (free models only) - - ``"low"`` : paid, but total spendable < $5 (ALERT) - - ``"healthy"`` : paid, total spendable >= $5 - - ``"depleted"`` : paid access lost (balance exhausted) + ``status``: ``free`` (no paid access / no plan), ``low`` (paid, spendable < $5, + ALERT), ``healthy`` (paid, spendable >= $5), ``depleted`` (paid access lost). """ available: bool @@ -141,11 +141,7 @@ class UsageModel: def usage_model_from_account(account_info: Any) -> UsageModel: - """Build a :class:`UsageModel` from a ``NousPortalAccountInfo``. Fail-open. - - Returns ``UsageModel(available=False)`` when there's no usable account info - (logged out, no entitlement block). Never raises. - """ + """Build a :class:`UsageModel` from a ``NousPortalAccountInfo``. Never raises.""" try: if account_info is None or not getattr(account_info, "logged_in", False): return UsageModel(available=False) @@ -163,48 +159,39 @@ def usage_model_from_account(account_info: Any) -> UsageModel: monthly = _finite(getattr(sub, "monthly_credits", None)) if sub is not None else None has_subscription = bool(plan_name) or (monthly is not None and monthly > 0) + has_topup = bool(topup_remaining and topup_remaining > 0) - # Total spendable: prefer the server's total; else sum the parts we have. + # Prefer the server's total; else sum the parts we have. if total_usable is not None: total_spendable = total_usable else: parts = [v for v in (sub_remaining, topup_remaining) if v is not None] total_spendable = sum(parts) if parts else None - # Status classification. if paid is False: status = "depleted" - elif not has_subscription and not (topup_remaining and topup_remaining > 0): - # No plan and no purchased balance -> free-models-only. - status = "free" + elif not has_subscription and not has_topup: + status = "free" # no plan and no purchased balance -> free-models-only elif total_spendable is not None and total_spendable < LOW_BALANCE_THRESHOLD_USD: status = "low" else: status = "healthy" - # Plan bar — only with a positive monthly allowance AND a remaining we - # can place on it. spent = cap - remaining, clamped (a debt/over-cap - # balance reads as fully spent rather than a nonsensical negative). + # Plan bar needs a positive allowance AND a remaining to place on it; spent is + # clamped so a debt/over-cap balance reads fully spent, not negative. plan_bar: Optional[UsageBar] = None if monthly is not None and monthly > 0 and sub_remaining is not None: - remaining = max(0.0, min(monthly, sub_remaining)) plan_bar = UsageBar( kind="plan", - remaining_usd=remaining, + remaining_usd=max(0.0, min(monthly, sub_remaining)), total_usd=monthly, spent_usd=max(0.0, monthly - sub_remaining), ) - # Top-up bar — only when there are purchased dollars to show. No - # denominator (top-up has no monthly cap), so it renders full = balance. + # Top-up has no monthly cap, so the bar renders full = balance. topup_bar: Optional[UsageBar] = None if topup_remaining is not None and topup_remaining > 0: - topup_bar = UsageBar( - kind="topup", - remaining_usd=topup_remaining, - total_usd=topup_remaining, - spent_usd=0.0, - ) + topup_bar = UsageBar(kind="topup", remaining_usd=topup_remaining, total_usd=topup_remaining, spent_usd=0.0) return UsageModel( available=True, @@ -226,98 +213,51 @@ def usage_model_from_account(account_info: Any) -> UsageModel: def build_usage_model(*, timeout: float = 10.0) -> UsageModel: """Fetch account-info and build the shared usage model. Fail-open. - Dev override: ``HERMES_DEV_CREDITS_FIXTURE`` short-circuits to a fixture so - every usage state is testable without a live account (mirrors the existing - ``/usage`` credits-block fixture path). + ``HERMES_DEV_CREDITS_FIXTURE`` short-circuits to a fixture so every usage state + is testable without a live account. """ fixture = _dev_fixture_usage_model() if fixture is not None: return fixture - - try: - from hermes_cli.auth import get_provider_auth_state - - tok = (get_provider_auth_state("nous") or {}).get("access_token") - if not (isinstance(tok, str) and tok.strip()): - return UsageModel(available=False) - except Exception: + if not nous_logged_in(): return UsageModel(available=False) - try: - import concurrent.futures - - from hermes_cli.nous_account import get_nous_portal_account_info - - with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: - account = pool.submit(get_nous_portal_account_info, force_fresh=True).result(timeout=timeout) - return usage_model_from_account(account) + return usage_model_from_account(fetch_nous_account(timeout)) except Exception: logger.debug("usage ▸ portal fetch failed (fail-open)", exc_info=True) return UsageModel(available=False) -# ============================================================================= -# Dev fixtures (throwaway scaffolding — env-var driven, no live portal) -# ============================================================================= +# ── Dev fixtures (throwaway scaffolding — env-var driven, no live portal) ──── + +def _plan_bar(remaining: float, spent: float) -> UsageBar: + return UsageBar(kind="plan", remaining_usd=remaining, total_usd=20.0, spent_usd=spent) def _dev_fixture_usage_model() -> Optional[UsageModel]: - """Map ``HERMES_DEV_CREDITS_FIXTURE`` to a usage model for offline UX work. - - Recognized names: ``free | healthy | low | topup | depleted``. Returns - ``None`` when the env var is unset (real portal path runs). - """ + """``HERMES_DEV_CREDITS_FIXTURE`` -> fixture model (``free|healthy|low|topup|depleted``), else None.""" name = (os.getenv("HERMES_DEV_CREDITS_FIXTURE") or "").strip().lower() - if not name: - return None - - if name == "free": - return UsageModel(available=True, status="free", plan_name=None) - - if name in ("healthy", "mid"): - return UsageModel( - available=True, - status="healthy", - plan_name="Plus", - renews_at="2026-07-01", - subscription_remaining_usd=14.0, - total_spendable_usd=14.0, - plan_bar=UsageBar(kind="plan", remaining_usd=14.0, total_usd=20.0, spent_usd=6.0), - ) - - if name in ("topup", "top-up"): - return UsageModel( - available=True, - status="healthy", - plan_name="Plus", - renews_at="2026-07-01", - subscription_remaining_usd=14.0, - topup_remaining_usd=12.0, - total_spendable_usd=26.0, - plan_bar=UsageBar(kind="plan", remaining_usd=14.0, total_usd=20.0, spent_usd=6.0), + name = {"mid": "healthy", "top-up": "topup"}.get(name, name) + plus = dict(available=True, plan_name="Plus", renews_at="2026-07-01") + specs: dict[str, dict] = { + "free": dict(available=True, status="free", plan_name=None), + "healthy": dict( + **plus, status="healthy", subscription_remaining_usd=14.0, total_spendable_usd=14.0, + plan_bar=_plan_bar(14.0, 6.0), + ), + "topup": dict( + **plus, status="healthy", subscription_remaining_usd=14.0, topup_remaining_usd=12.0, + total_spendable_usd=26.0, plan_bar=_plan_bar(14.0, 6.0), topup_bar=UsageBar(kind="topup", remaining_usd=12.0, total_usd=12.0, spent_usd=0.0), - ) - - if name == "low": - return UsageModel( - available=True, - status="low", - plan_name="Plus", - renews_at="2026-07-01", - subscription_remaining_usd=3.4, - total_spendable_usd=3.4, - plan_bar=UsageBar(kind="plan", remaining_usd=3.4, total_usd=20.0, spent_usd=16.6), - ) - - if name == "depleted": - return UsageModel( - available=True, - status="depleted", - plan_name="Plus", - renews_at="2026-07-01", - subscription_remaining_usd=0.0, - total_spendable_usd=0.0, - plan_bar=UsageBar(kind="plan", remaining_usd=0.0, total_usd=20.0, spent_usd=20.0), - ) - - return None + ), + "low": dict( + **plus, status="low", subscription_remaining_usd=3.4, total_spendable_usd=3.4, + plan_bar=_plan_bar(3.4, 16.6), + ), + "depleted": dict( + **plus, status="depleted", subscription_remaining_usd=0.0, total_spendable_usd=0.0, + plan_bar=_plan_bar(0.0, 20.0), + ), + } + spec = specs.get(name) + return UsageModel(**spec) if spec else None diff --git a/agent/billing_view.py b/agent/billing_view.py index 01a804e65f..f2c0ef0fdf 100644 --- a/agent/billing_view.py +++ b/agent/billing_view.py @@ -1,12 +1,10 @@ -"""Surface-agnostic core for the Phase 2b Remote Spending screens. +"""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. 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). +(``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. @@ -19,22 +17,16 @@ import os import uuid from dataclasses import dataclass from decimal import Decimal, InvalidOperation -from typing import Any, Optional +from typing import Any, Callable, Optional logger = logging.getLogger(__name__) -# ============================================================================= -# Decimal money helpers -# ============================================================================= +# ── 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). - """ + """Server money value (decimal string; defensively int/float) -> Decimal, or None. Never raises.""" if value is None: return None try: @@ -44,30 +36,32 @@ def parse_money(value: Any) -> Optional[Decimal]: return None -def format_money(value: Optional[Decimal]) -> str: - """Format a Decimal as ``$X`` / ``$X.YY`` for display. +def format_money(value: Optional[Decimal], *, grouped: bool = False) -> str: + """``$X`` for whole dollars, ``$X.YY`` (exactly 2dp) otherwise; ``None`` -> ``—``. - Whole dollars show no decimals; any fractional amount shows exactly 2dp: - ``Decimal("142.5")`` → ``"$142.50"``, ``Decimal("100")`` → ``"$100"``, - ``Decimal("0.01")`` → ``"$0.01"``. + ``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(): - # 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')}" + # format(..., "f") avoids 1E+3 for 1000. + return f"${format(value.to_integral_value(), spec)}" + return f"${format(value.quantize(Decimal('0.01')), spec)}" -# ============================================================================= -# Parsed sub-structures -# ============================================================================= +def _optional_str(raw: dict, key: str) -> Optional[str]: + value = raw.get(key) + return value if isinstance(value, str) else None -# resolvedVia → the human answer to "why THIS card?". Keys are the server's card -# resolution rungs (NAS card-on-file ladder); absent/unknown rungs render no label -# so the display degrades cleanly on servers that don't send resolvedVia yet. +# ── 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", @@ -79,39 +73,34 @@ _CARD_PROVENANCE_LABELS = { class CardInfo: brand: str last4: str - # NAS card-on-file field (post card-resolver): which ladder rung found the - # card. Defaults off so pre-resolver payloads parse unchanged. - resolved_via: Optional[str] = None + 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 = "") — render the - # brand alone, not "Link ····". + # 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 / - server too old to say).""" + """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: - """The one-line card display: ``Visa ····4242 — the card on your - subscription`` (or just the masked card when provenance is unknown).""" + """``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: - """The payment method on file. `kind` is "card", "link", or "unknown" - — anything else is normalised to "unknown" at parse time, so consumers - only ever see fields that belong to the kind they are looking at.""" + """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 @@ -119,8 +108,7 @@ class PaymentMethodInfo: wallet: Optional[str] = None email: Optional[str] = None resolved_via: Optional[str] = None - #: What the server called it, when we did not recognise the kind. - raw_kind: Optional[str] = None + raw_kind: Optional[str] = None # what the server called an unrecognised kind @dataclass(frozen=True) @@ -146,13 +134,30 @@ class AutoReload: card: Optional[AutoReloadCard] = None -@dataclass(frozen=True) -class BillingState: - """Parsed ``GET /api/billing/state`` — the overview screen's data. +class OrgRoleCapability: + """``is_admin`` / ``can_change_plan`` shared by the billing and subscription states.""" - Fail-open: ``logged_in=False`` (and empty fields) when not logged in or the - portal is unreachable. - """ + 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 @@ -170,37 +175,13 @@ class BillingState: 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: - """Deprecated/display only — a legacy OWNER/ADMIN check. - - NOT a capability check; use :attr:`can_change_plan` for gating billing - 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 + error: Optional[str] = None # set when the fetch failed (vs cleanly not-logged-in) @property def can_charge(self) -> bool: - """True when the UI should offer charge/auto-reload actions. - - Uses the server-granted plan-change capability (``can_change_plan``, - which itself falls back to the legacy OWNER/ADMIN role check when the - server omits ``canChangePlan``) AND the per-org kill-switch. This lets - the server grant charge capability to non-OWNER/ADMIN roles (e.g. - FINANCE_ADMIN) via ``canChangePlan``, instead of hard-coding the - deprecated 3-role admin check. (The server still enforces; this is - just for graying out actions the user can't take.) - """ + """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 @@ -211,11 +192,7 @@ def _parse_card(raw: Any) -> Optional[CardInfo]: last4 = raw.get("last4") if not (isinstance(brand, str) and isinstance(last4, str)): return None - # Post-resolver fields — all optional so both payload generations parse. - resolved_via = raw.get("resolvedVia") - if not isinstance(resolved_via, str): - resolved_via = None - return CardInfo(brand=brand, last4=last4, resolved_via=resolved_via) + return CardInfo(brand=brand, last4=last4, resolved_via=_optional_str(raw, "resolvedVia")) def _parse_payment_method(raw: Any) -> Optional[PaymentMethodInfo]: @@ -224,33 +201,17 @@ def _parse_payment_method(raw: Any) -> Optional[PaymentMethodInfo]: kind = raw.get("kind") if not isinstance(kind, str): return None - - def _optional_string(key: str) -> Optional[str]: - value = raw.get(key) - return value if isinstance(value, str) else None - - resolved_via = _optional_string("resolvedVia") - brand = _optional_string("brand") - last4 = _optional_string("last4") - # Settle the kind here, the way _parse_card settles a card, so nothing - # downstream has to re-check which fields this kind is allowed to have. + 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_string("wallet"), - resolved_via=resolved_via, + kind="card", brand=brand, last4=last4, wallet=_optional_str(raw, "wallet"), resolved_via=resolved_via ) if kind == "link": - return PaymentMethodInfo( - kind="link", - email=_optional_string("email"), - resolved_via=resolved_via, - ) - return PaymentMethodInfo( - kind="unknown", raw_kind=kind, resolved_via=resolved_via - ) + 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]: @@ -282,32 +243,29 @@ def _parse_auto_reload_card(raw: Any) -> Optional[AutoReloadCard]: return None if kind in ("canonical", "none"): return AutoReloadCard(kind=kind) - - payment_method_id = raw.get("paymentMethodId") - brand = raw.get("brand") - last4 = raw.get("last4") return AutoReloadCard( kind=kind, - payment_method_id=payment_method_id if isinstance(payment_method_id, str) else None, - brand=brand if isinstance(brand, str) else None, - last4=last4 if isinstance(last4, str) else None, + 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`.""" - raw_org = payload.get("org") - org: dict[str, Any] = raw_org if isinstance(raw_org, dict) else {} + 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: list[Decimal] = [] - for item in payload.get("chargePresets") or (): - parsed = parse_money(item) - if parsed is not None: - presets.append(parsed) + presets = [p for p in map(parse_money, payload.get("chargePresets") or ()) if p is not None] return BillingState( logged_in=True, @@ -315,11 +273,7 @@ def billing_state_from_payload( org_slug=org.get("slug"), org_name=org.get("name"), role=org.get("role"), - can_change_plan_raw=( - payload.get("canChangePlan") - if isinstance(payload.get("canChangePlan"), bool) - else None - ), + 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), @@ -333,95 +287,100 @@ def billing_state_from_payload( ) -# ============================================================================= -# Fail-open builders (the surface front doors) -# ============================================================================= +# ── 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. + """Fetch + parse ``/api/billing/state``. Fail-open (see :func:`fetch_portal_state`). - 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. - - Dev override: ``HERMES_DEV_BILLING_FIXTURE`` short-circuits to a fixture so the - card-on-file / admin / scope states are testable offline (mirrors - ``HERMES_DEV_CREDITS_FIXTURE`` for the usage model). + ``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 - - 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) + 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, + ) -def _fallback_portal_url(base: str) -> str: - """Standard billing deep-link when the server omits ``portalUrl``.""" - return f"{base.rstrip('/')}/billing?topup=open" - - -# ============================================================================= -# Dev fixtures (throwaway scaffolding — env-var driven, no live portal) -# ============================================================================= +# ── Dev fixtures (throwaway scaffolding — env-var driven, no live portal) ──── def _dev_fixture_billing_state() -> Optional[BillingState]: - """Map ``HERMES_DEV_BILLING_FIXTURE`` to a :class:`BillingState` for offline UX. + """``HERMES_DEV_BILLING_FIXTURE`` -> :class:`BillingState` for offline UX; None when unset. - Recognized names:: - - nocard logged in · billing on · admin · NO card on file - card card on file · auto-reload off - card-autoreload card on file · auto-reload on - notadmin logged in · MEMBER role (billing actions disabled) - billing-off logged in · admin · per-org kill-switch OFF - logged-out not logged in - - Returns ``None`` when the env var is unset (the real portal path runs). - Mirrors ``HERMES_DEV_CREDITS_FIXTURE``; the usage *bar* still comes from - ``HERMES_DEV_CREDITS_FIXTURE`` (set both to pair a bar with a billing state). + 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) - # Shared fixture portal host (matches subscription_view._DEV_FIXTURE_PORTAL — - # prod host, not staging; the ?topup=open suffix is the /topup deep-link). - portal = "https://portal.nousresearch.com/billing?topup=open" + # 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", @@ -431,53 +390,35 @@ def _dev_fixture_billing_state() -> Optional[BillingState]: charge_presets=(Decimal("10"), Decimal("25"), Decimal("50")), min_usd=Decimal("5"), max_usd=Decimal("500"), - portal_url=portal, + portal_url="https://portal.nousresearch.com/billing?topup=open", ) card = CardInfo(brand="Visa", last4="4242") - autoreload_on = AutoReload(enabled=True, threshold_usd=Decimal("5"), reload_to_usd=Decimal("25")) - - if name in ("logged-out", "logged_out", "loggedout"): - return BillingState(logged_in=False) - if name == "nocard": - return BillingState(logged_in=True, card=None, **common) - if name == "card": - return BillingState(logged_in=True, card=card, **common) - if name in ("card-sub", "card_sub"): - # Post-resolver: the card came from the subscription (provenance label). - _sub_card = CardInfo(brand="Visa", last4="4242", resolved_via="subPin") - return BillingState(logged_in=True, card=_sub_card, **common) - if name in ("card-autoreload", "card_autoreload", "autoreload"): - return BillingState(logged_in=True, card=card, auto_reload=autoreload_on, **common) - if name in ("notadmin", "not-admin", "member"): - opts = {**common, "role": "MEMBER"} - return BillingState(logged_in=True, card=card, **opts) - if name in ("billing-off", "billing_off", "off"): - opts = {**common, "cli_billing_enabled": False} - return BillingState(logged_in=True, card=None, **opts) - - # Unknown name → logged-out so the misconfiguration is visible. - return BillingState(logged_in=False, error=f"unknown HERMES_DEV_BILLING_FIXTURE: {name}") + 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 -# ============================================================================= +# ── 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). - """ + """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 (Screen 3 custom input) -# ============================================================================= +# ── Amount validation (custom charge input) ────────────────────────────────── @dataclass(frozen=True) @@ -490,18 +431,13 @@ class AmountValidation: 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) + """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") - # 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: diff --git a/agent/iteration_budget.py b/agent/iteration_budget.py index 7d50026c17..0bb517bfcf 100644 --- a/agent/iteration_budget.py +++ b/agent/iteration_budget.py @@ -1,12 +1,10 @@ """Per-agent iteration budget — thread-safe consume/refund counter. -Extracted from ``run_agent.py``. Each ``AIAgent`` instance (parent or -subagent) holds an :class:`IterationBudget`; the parent's cap comes from -``max_iterations`` (default 500), each subagent's cap comes from -``delegation.max_iterations`` (default 50). - -``run_agent`` re-exports ``IterationBudget`` so existing -``from run_agent import IterationBudget`` imports keep working unchanged. +Each ``AIAgent`` (parent or subagent) holds its own :class:`IterationBudget`: +the parent's cap is ``max_iterations`` (default 500), each subagent's is +``delegation.max_iterations`` (default 50), so total iterations across parent ++ subagents can exceed the parent's cap. ``run_agent`` re-exports the class so +``from run_agent import IterationBudget`` keeps working. """ from __future__ import annotations @@ -15,19 +13,9 @@ import threading class IterationBudget: - """Thread-safe iteration counter for an agent. - - Each agent (parent or subagent) gets its own ``IterationBudget``. - The parent's budget is capped at ``max_iterations`` (default 500). - Each subagent gets an independent budget capped at - ``delegation.max_iterations`` (default 50) — this means total - iterations across parent + subagents can exceed the parent's cap. - Users control the per-subagent limit via ``delegation.max_iterations`` - in config.yaml. - - ``execute_code`` (programmatic tool calling) iterations are refunded via - :meth:`refund` so they don't eat into the budget. - """ + """Thread-safe iteration counter. ``execute_code`` (programmatic tool + calling) iterations are refunded via :meth:`refund` so they don't eat + into the budget.""" def __init__(self, max_total: int): self.max_total = max_total diff --git a/agent/lmstudio_reasoning.py b/agent/lmstudio_reasoning.py index f9ba588df5..799e8dd538 100644 --- a/agent/lmstudio_reasoning.py +++ b/agent/lmstudio_reasoning.py @@ -1,34 +1,27 @@ """LM Studio reasoning-effort resolution shared by the chat-completions transport and run_agent's iteration-limit summary path. -LM Studio publishes per-model ``capabilities.reasoning.allowed_options`` (e.g. -``["off","on"]`` for toggle-style models, ``["off","minimal","low"]`` for -graduated models). We map the user's ``reasoning_config`` onto LM Studio's -OpenAI-compatible vocabulary, then clamp against the model's allowed set so -the server doesn't 400 on an unsupported effort. +LM Studio publishes per-model ``capabilities.reasoning.allowed_options`` +(``["off","on"]`` for toggle models, ``["off","minimal","low"]`` for graduated +ones). We map the user's ``reasoning_config`` onto LM Studio's OpenAI-compatible +vocabulary, then clamp against the model's allowed set so the server doesn't 400. """ from __future__ import annotations from typing import List, Optional -# LM Studio accepts these top-level reasoning_effort values via its -# OpenAI-compatible chat.completions endpoint. +# Top-level reasoning_effort values LM Studio's OpenAI-compatible endpoint accepts. _LM_VALID_EFFORTS = {"none", "minimal", "low", "medium", "high", "xhigh"} -# Toggle-style models publish allowed_options as ["off","on"] in /api/v1/models. -# Map them onto the OpenAI-compatible request vocabulary. +# Toggle-style models publish allowed_options as ["off","on"]; map onto the +# request vocabulary. Also applied to the published allowed_options themselves. _LM_EFFORT_ALIASES = {"off": "none", "on": "medium"} -# Hermes' generic effort ladder grew past LM Studio's vocabulary ("max", -# "ultra"). Clamp the stronger generic levels onto LM Studio's ceiling: left -# alone they miss _LM_VALID_EFFORTS, keep the initialized "medium" default and -# are thereby conflated with unparseable input, so asking for more reasoning -# yields less than "xhigh". Mirrors the ceiling clamp every other provider -# applies (see agent/transports/codex.py). -# -# Deliberately separate from _LM_EFFORT_ALIASES: that mapping is also applied -# to the model's published allowed_options, which must not be rewritten. +# Hermes' ladder grew past LM Studio's vocabulary ("max", "ultra"). Without this +# ceiling clamp they miss _LM_VALID_EFFORTS, keep the "medium" default and are +# conflated with unparseable input — asking for more yields less than "xhigh". +# Kept separate from _LM_EFFORT_ALIASES, which must not rewrite allowed_options. _LM_EFFORT_CLAMP = {"max": "xhigh", "ultra": "xhigh"} @@ -36,12 +29,12 @@ def resolve_lmstudio_effort( reasoning_config: Optional[dict], allowed_options: Optional[List[str]], ) -> Optional[str]: - """Return the ``reasoning_effort`` string to send to LM Studio, or ``None``. + """Return the ``reasoning_effort`` to send to LM Studio, or ``None``. ``None`` means "omit the field": the user picked a level the model can't - honor, so let LM Studio fall back to the model's declared default rather - than silently substituting a different effort. When ``allowed_options`` is - falsy (probe failed), skip clamping and send the resolved effort anyway. + honor, so LM Studio falls back to the model's declared default rather than + a silently substituted effort. Falsy ``allowed_options`` (probe failed) + skips clamping and sends the resolved effort anyway. """ effort = "medium" if reasoning_config and isinstance(reasoning_config, dict): diff --git a/agent/models_dev.py b/agent/models_dev.py index fca9201eb6..7d197ec1db 100644 --- a/agent/models_dev.py +++ b/agent/models_dev.py @@ -1,39 +1,24 @@ """Models.dev registry integration — primary database for providers and models. -Fetches from https://models.dev/api.json — a community-maintained database -of 4000+ models across 109+ providers. Provides: +Fetches https://models.dev/api.json (4000+ models, 100+ providers): provider +metadata (name, base URL, env vars, docs) and model metadata (context window, +max output, cost/M tokens, capabilities, modalities, knowledge cutoff, +open-weights flag, family, deprecation status). -- **Provider metadata**: name, base URL, env vars, documentation link -- **Model metadata**: context window, max output, cost/M tokens, capabilities - (reasoning, tools, vision, PDF, audio), modalities, knowledge cutoff, - open-weights flag, family grouping, deprecation status +Resolution order: in-memory cache (fresh, or stale served immediately while +one background daemon thread refreshes) → disk cache +(~/.hermes/models_dev_cache.json, any age) → network, only when no cache +exists at all. Failed refreshes back off for 5 minutes process-wide. -Data resolution order: - 1. In-memory cache (fresh, or stale served immediately while a single - background daemon thread refreshes) - 2. Disk cache (~/.hermes/models_dev_cache.json — any age; stale data is - served rather than blocking callers on the network) - 3. Network fetch (https://models.dev/api.json) — only when no cache - exists at all; failed refreshes back off for 5 minutes process-wide - -Network hardening: - -- **ETag conditional GET**: network refreshes send ``If-None-Match`` - with the last-known ETag whenever a servable registry is held (memory, - hydrated from disk on cold force-refresh). A 304 Not Modified response - is a no-op — the existing cache is re-confirmed fresh without - re-downloading the full registry (≈2 MB). The ETag is persisted - atomically alongside the cache file. -- **No-network-on-hot-paths invariant**: resolution, picker, and resume - paths NEVER perform network I/O. ``allow_network=False`` is threaded - through every query function, and hot-path callers (vision routing, - image routing, cost guard, context-length lookup) pass it explicitly. +Invariants: +- **ETag conditional GET**: refreshes send ``If-None-Match`` whenever a + servable registry is held; a 304 re-confirms the cache without + re-downloading ~2 MB. The ETag sidecar is persisted with the cache body. +- **No network on hot paths**: resolution, picker, and resume paths pass + ``allow_network=False`` and never perform network I/O. - **Corrupt-cache rejection**: a disk cache that fails to parse, is not a - dict, or is empty is ignored with a warning rather than served as - ``{}`` and silently breaking provider/model resolution. -- **Mirror URL override**: ``models_dev.url`` in config.yaml lets - deployments point at a mirror (e.g. a self-hosted copy) without code - changes. + dict, or is empty is quarantined with a warning rather than served as ``{}``. +- **Mirror URL override**: ``models_dev.url`` in config.yaml. Other modules should import the dataclasses and query functions from here rather than parsing the raw JSON themselves. @@ -41,6 +26,7 @@ rather than parsing the raw JSON themselves. import json import logging +import re import threading import time from dataclasses import dataclass @@ -67,7 +53,7 @@ _models_dev_refresh_in_flight = False # --------------------------------------------------------------------------- -# Dataclasses — rich metadata for providers and models +# Dataclasses # --------------------------------------------------------------------------- @dataclass @@ -120,32 +106,18 @@ class ModelInfo: def supports_audio_input(self) -> bool: return "audio" in self.input_modalities - def format_cost(self) -> str: - """Human-readable cost string, e.g. '$3.00/M in, $15.00/M out'.""" - if not self.has_cost_data(): - return "unknown" - parts = [f"${self.cost_input:.2f}/M in", f"${self.cost_output:.2f}/M out"] - if self.cost_cache_read is not None: - parts.append(f"cache read ${self.cost_cache_read:.2f}/M") - return ", ".join(parts) - def format_capabilities(self) -> str: """Human-readable capabilities, e.g. 'reasoning, tools, vision, PDF'.""" - caps = [] - if self.reasoning: - caps.append("reasoning") - if self.tool_call: - caps.append("tools") - if self.supports_vision(): - caps.append("vision") - if self.supports_pdf(): - caps.append("PDF") - if self.supports_audio_input(): - caps.append("audio") - if self.structured_output: - caps.append("structured output") - if self.open_weights: - caps.append("open weights") + flags = ( + (self.reasoning, "reasoning"), + (self.tool_call, "tools"), + (self.supports_vision(), "vision"), + (self.supports_pdf(), "PDF"), + (self.supports_audio_input(), "audio"), + (self.structured_output, "structured output"), + (self.open_weights, "open weights"), + ) + caps = [label for on, label in flags if on] return ", ".join(caps) if caps else "basic" @@ -161,6 +133,18 @@ class ProviderInfo: model_count: int = 0 +@dataclass +class ModelCapabilities: + """Structured capability metadata for a model from models.dev.""" + + supports_tools: bool = True + supports_vision: bool = False + supports_reasoning: bool = False + context_window: int = 200000 + max_output_tokens: int = 8192 + model_family: str = "" + + # --------------------------------------------------------------------------- # Provider ID mapping: Hermes ↔ models.dev # --------------------------------------------------------------------------- @@ -194,16 +178,12 @@ PROVIDER_TO_MODELS_DEV: Dict[str, str] = { "gemini": "google", "google": "google", "xai": "xai", - # xAI OAuth is an authentication/transport path for the same xAI model - # catalog, so model metadata should resolve through the xAI provider. - "xai-oauth": "xai", + "xai-oauth": "xai", # OAuth is a transport path for the same xAI catalog "xiaomi": "xiaomi", "nvidia": "nvidia", - # Meta Model API (Muse Spark family, api.meta.ai). models.dev keys these - # under the "meta" provider id; Hermes' provider is "meta-ai" (and the - # api.meta.ai host reverse-maps to "meta-ai"), so without both aliases the - # context/pricing lookup misses and muse-spark-* falls back to the generic - # 256K default instead of its true 1M window. + # Meta Model API (Muse Spark, api.meta.ai): models.dev keys it "meta", the + # Hermes provider is "meta-ai"; both aliases are needed or muse-spark-* + # falls back to the generic 256K default instead of its true 1M window. "meta-ai": "meta", "meta": "meta", "groq": "groq", @@ -214,8 +194,7 @@ PROVIDER_TO_MODELS_DEV: Dict[str, str] = { "ollama-cloud": "ollama-cloud", } -# Reverse mapping: models.dev id → Hermes ids (built lazily; many-to-one, -# e.g. both "meta" and "meta-ai" may map to the same models.dev id). +# Reverse mapping: models.dev id → Hermes ids (built lazily; many-to-one). _MODELS_DEV_TO_PROVIDER: Optional[Dict[str, List[str]]] = None @@ -230,21 +209,22 @@ def _models_dev_to_hermes_ids(mdev_id: str) -> List[str]: return _MODELS_DEV_TO_PROVIDER.get(mdev_id, []) +# --------------------------------------------------------------------------- +# Disk cache + ETag sidecar +# --------------------------------------------------------------------------- def _get_cache_path() -> Path: - """Return path to disk cache file.""" from hermes_constants import get_hermes_home return get_hermes_home() / "models_dev_cache.json" def _get_etag_path() -> Path: - """Return path to the ETag sidecar file for conditional GET.""" from hermes_constants import get_hermes_home return get_hermes_home() / "models_dev_cache.etag" def _load_etag() -> str: - """Load the last-known ETag from disk, or empty string if missing.""" + """Last-known ETag from disk, or "" if missing.""" try: etag_path = _get_etag_path() if etag_path.exists(): @@ -255,7 +235,6 @@ def _load_etag() -> str: def _save_etag(etag: str) -> None: - """Persist an ETag to the sidecar file atomically.""" try: from utils import atomic_write_text @@ -269,9 +248,9 @@ def _save_etag(etag: str) -> None: def _clear_etag() -> None: """Delete the ETag sidecar so the next fetch is unconditional. - Called when the cached registry the ETag vouches for is gone or - unusable — sending If-None-Match without a servable cache invites a - 304 that would leave the process with no data at all. + Called when the registry the ETag vouches for is gone or unusable: an + If-None-Match without a servable cache invites a 304 that leaves the + process with no data at all. """ try: _get_etag_path().unlink(missing_ok=True) @@ -280,12 +259,8 @@ def _clear_etag() -> None: def _get_models_dev_url() -> str: - """Resolve the models.dev API URL, honoring a config.yaml override. - - The ``models_dev.url`` config key lets deployments point at a mirror - (e.g. a self-hosted copy behind a corporate proxy) without code changes. - Falls back to the default public URL when unset or empty. - """ + """The models.dev API URL, honoring the ``models_dev.url`` config override + (mirrors / self-hosted copies).""" try: from hermes_cli.config import cfg_get, load_config_readonly cfg = load_config_readonly() @@ -294,23 +269,19 @@ def _get_models_dev_url() -> str: return url.strip() except Exception: pass - # Fall back to the module global (not the constant) so existing - # code/tests that patch MODELS_DEV_URL keep working. + # Module global (not a captured constant) so code/tests that patch + # MODELS_DEV_URL keep working. return MODELS_DEV_URL def _validate_registry(data: Any) -> bool: - """Return True if *data* is a non-empty dict suitable for serving.""" + """True if *data* is a non-empty dict suitable for serving.""" return isinstance(data, dict) and len(data) > 0 def _load_disk_cache() -> Dict[str, Any]: - """Load models.dev data from disk cache. - - A corrupt cache (invalid JSON, not a dict, or empty) is rejected with - a warning so it doesn't silently masquerade as ``{}`` and break - provider/model resolution for every caller. - """ + """Load the disk cache; a corrupt/empty one is quarantined with a warning + so it never masquerades as ``{}`` and breaks provider/model resolution.""" try: cache_path = _get_cache_path() if cache_path.exists(): @@ -336,14 +307,12 @@ def _load_disk_cache() -> Dict[str, Any]: def _quarantine_corrupt_cache(cache_path: Path) -> None: - """Move a rejected cache aside and drop its ETag sidecar. + """Rename a rejected cache aside and drop its ETag sidecar. - Renaming (rather than leaving the file in place) makes the rejection - a one-time event: without it, every hot-path call that finds the - in-memory cache empty re-reads and re-parses the corrupt file and - re-emits the warning until a network fetch succeeds. The sidecar is - cleared because it vouches for a registry we no longer hold — a 304 - against a missing cache would leave the process with no data at all. + Renaming makes the rejection a one-time event — otherwise every hot-path + call that finds the in-memory cache empty re-parses the corrupt file and + re-warns until a network fetch succeeds. The sidecar vouches for a registry + we no longer hold, so it goes too. """ try: cache_path.rename(cache_path.with_suffix(".json.corrupt")) @@ -353,46 +322,34 @@ def _quarantine_corrupt_cache(cache_path: Path) -> None: def _disk_cache_age_seconds() -> Optional[float]: - """Return age (in seconds) of the disk cache file, or None if missing. - - Used by ``fetch_models_dev`` to short-circuit the network probe when - a recent on-disk cache exists. Errors (missing file, permission - denied, weird filesystem) all return None — callers fall through - to the network fetch path. - """ + """Age of the disk cache file in seconds, or None if missing/unreadable + (or mtime in the future from clock skew — treated as unknown freshness so + callers fall through to the network rather than trusting it forever).""" try: cache_path = _get_cache_path() if not cache_path.exists(): return None - mtime = cache_path.stat().st_mtime - age = time.time() - mtime - # Negative age means the file's mtime is in the future (clock skew - # or system clock reset). Treat as "unknown freshness" → fall - # through to network so we don't serve potentially-bad data - # forever. - if age < 0: - return None - return age + age = time.time() - cache_path.stat().st_mtime + return age if age >= 0 else None except Exception as e: logger.debug("Failed to stat models.dev disk cache: %s", e) return None def _save_disk_cache(data: Dict[str, Any], etag: str = "") -> None: - """Save models.dev data to disk cache atomically. - - Also persists the ETag sidecar when *etag* is non-empty so the next - refresh can issue a conditional GET. - """ + """Save the registry atomically, plus the ETag sidecar when non-empty.""" try: - cache_path = _get_cache_path() - atomic_json_write(cache_path, data, indent=None, separators=(",", ":")) + atomic_json_write(_get_cache_path(), data, indent=None, separators=(",", ":")) except Exception as e: logger.debug("Failed to save models.dev disk cache: %s", e) if etag: _save_etag(etag) +# --------------------------------------------------------------------------- +# Network refresh (all state mutation happens under _models_dev_fetch_lock) +# --------------------------------------------------------------------------- + class _NotModified(Exception): """Server returned 304 Not Modified — existing cache is still valid.""" @@ -400,22 +357,14 @@ class _NotModified(Exception): def _fetch_models_dev_from_network( *, conditional: bool = False ) -> Tuple[Dict[str, Any], str]: - """Fetch the live models.dev registry. + """Fetch the live registry; returns ``(registry, etag)`` (etag "" if none). - ``conditional`` enables ETag conditional GET (``If-None-Match`` with - the sidecar's ETag). Callers must pass True ONLY while holding - ``_models_dev_fetch_lock`` AND holding a servable registry the 304 - can re-confirm — a conditional request without one invites a 304 - that leaves the process with no data at all (previously a permanent - empty-registry loop when the sidecar outlived a corrupt cache file). - A 304 raises ``_NotModified`` so the caller can re-confirm the - existing cache's freshness without re-downloading the full payload. - - Returns ``(registry, etag)``; the etag is empty when the server sent - none. The caller persists it together with the cache body - (``_commit_registry``) so the sidecar can never get ahead of the data - it vouches for. Raises on network errors and on an empty/invalid - registry payload. + ``conditional`` sends ``If-None-Match`` with the sidecar's ETag and raises + ``_NotModified`` on 304. Pass True ONLY while holding + ``_models_dev_fetch_lock`` AND a servable registry — a conditional request + without one invites a 304 that leaves the process with no data (formerly a + permanent empty-registry loop when the sidecar outlived a corrupt cache). + Raises on network errors and on an empty/invalid payload. """ url = _get_models_dev_url() headers: Dict[str, str] = {} @@ -424,11 +373,8 @@ def _fetch_models_dev_from_network( if etag: headers["If-None-Match"] = etag - # Tuple (connect, read): a flat timeout=15 let a blackholed connect - # stall the first-turn critical path for the full 15 s. 5 s connect - # fails fast on unreachable hosts; 10 s read still tolerates a slow - # registry response (matches the OpenRouter fetch convention in - # agent/model_metadata.py). + # (connect, read) timeout: 5 s connect fails fast on blackholed hosts + # instead of stalling the first turn; 10 s read tolerates a slow registry. response = requests.get(url, headers=headers, timeout=(5, 10)) if response.status_code == 304: @@ -443,11 +389,11 @@ def _fetch_models_dev_from_network( def _mark_stale_cache_grace() -> None: - """Give stale cache data a short in-memory grace before retrying refresh. + """Give stale cache data a 5-minute in-memory grace before retrying refresh. - Only ever moves the timestamp forward: if a background refresh completed - between the caller's staleness check and this call, the fresh timestamp - is preserved instead of being rewound to a 5-minute grace. + Only ever moves the timestamp forward, so a background refresh that + completed between the caller's staleness check and this call keeps its + fresh timestamp. """ global _models_dev_cache_time grace_time = time.time() - _MODELS_DEV_CACHE_TTL + _MODELS_DEV_RETRY_DELAY @@ -456,12 +402,10 @@ def _mark_stale_cache_grace() -> None: def _commit_registry(data: Dict[str, Any], *, etag: str = "", where: str) -> None: - """Persist a freshly fetched registry: disk + in-mem + clear backoff. + """Persist a fetched registry: disk + in-mem + clear backoff. - Callers must hold ``_models_dev_fetch_lock`` so a failing refresh on one - path can never stomp the state a succeeding refresh on the other path - just committed (e.g. a failing background worker re-arming the backoff - immediately after a successful ``force_refresh``). + Callers hold ``_models_dev_fetch_lock`` so a failing refresh on one path + can never stomp state a succeeding refresh on the other just committed. """ global _models_dev_cache, _models_dev_cache_time, _models_dev_retry_after _save_disk_cache(data, etag) @@ -477,21 +421,14 @@ def _commit_registry(data: Dict[str, Any], *, etag: str = "", where: str) -> Non def _confirm_cache_not_modified(*, where: str) -> None: - """Re-confirm the existing cache as fresh after a 304 Not Modified. - - Callers must hold ``_models_dev_fetch_lock``. Clears the backoff and - resets the in-memory cache timestamp so the next caller hits the fast - path. The disk cache itself is not rewritten — its contents are - unchanged, only its freshness marker is advanced. - """ + """After a 304: clear backoff and re-mark the held cache fresh (disk is + untouched — only the freshness marker advances). Caller holds the lock.""" global _models_dev_cache_time, _models_dev_retry_after if not _models_dev_cache: - # Pathological: a 304 arrived but we hold no registry. Should be - # unreachable now that conditional GETs require a servable cache - # (see _fetch_models_dev_from_network); kept as defense in depth - # because this state previously caused a permanent empty-registry - # loop. Drop the sidecar so the next attempt is unconditional and - # arm the normal failure backoff instead of marking {} "fresh". + # A 304 with no registry held should be unreachable (conditional GETs + # require a servable cache) but previously caused a permanent + # empty-registry loop: drop the sidecar and arm the normal backoff + # rather than marking {} "fresh". _clear_etag() _models_dev_retry_after = time.time() + _MODELS_DEV_RETRY_DELAY logger.warning( @@ -510,10 +447,7 @@ def _confirm_cache_not_modified(*, where: str) -> None: def _note_refresh_failure(exc: Exception, *, where: str) -> None: - """Record a failed refresh: arm the process-wide 5-minute backoff. - - Callers must hold ``_models_dev_fetch_lock`` (see ``_commit_registry``). - """ + """Arm the process-wide 5-minute backoff. Caller holds the lock.""" global _models_dev_retry_after _models_dev_retry_after = time.time() + _MODELS_DEV_RETRY_DELAY logger.debug( @@ -528,11 +462,10 @@ def _background_refresh_models_dev() -> None: """Best-effort refresh after serving stale cache data.""" global _models_dev_refresh_in_flight try: - # Fetch INSIDE the lock: symmetric with the foreground path, so - # conditional-GET inputs (memory cache + etag sidecar) can't be - # mutated mid-fetch by a concurrent force_refresh, and the two - # paths can't double-download concurrently. Hot-path callers are - # unaffected — they return stale data without touching this lock. + # Fetch INSIDE the lock, symmetric with the foreground path: the + # conditional-GET inputs (memory cache + sidecar) can't be mutated + # mid-fetch by a concurrent force_refresh and the two paths can't + # double-download. Hot-path callers never touch this lock. with _models_dev_fetch_lock: data, etag = _fetch_models_dev_from_network( conditional=bool(_models_dev_cache) @@ -550,11 +483,8 @@ def _background_refresh_models_dev() -> None: def _start_background_refresh_models_dev() -> None: - """Start one daemon refresh worker if none is already running. - - Honors the process-wide failure backoff: after a failed refresh, - no new background worker is spawned until ``_models_dev_retry_after``. - """ + """Start one daemon refresh worker if none is running and the failure + backoff has elapsed.""" global _models_dev_refresh_in_flight if time.time() < _models_dev_retry_after: return @@ -570,8 +500,8 @@ def _start_background_refresh_models_dev() -> None: try: thread.start() except Exception as e: - # Thread/fd exhaustion: clear the flag so refresh isn't disabled - # for the rest of the process lifetime. Callers still get stale data. + # Thread/fd exhaustion: clear the flag so refresh isn't disabled for + # the rest of the process. Callers still get stale data. with _models_dev_refresh_lock: _models_dev_refresh_in_flight = False logger.debug("Failed to start models.dev refresh thread: %s", e) @@ -580,38 +510,28 @@ def _start_background_refresh_models_dev() -> None: def fetch_models_dev( force_refresh: bool = False, *, allow_network: bool = True ) -> Dict[str, Any]: - """Fetch models.dev registry. Cache hierarchy: in-mem → disk → network. + """Fetch the models.dev registry (dict keyed by provider ID; {} on failure). - Returns the full registry dict keyed by provider ID, or empty dict on failure. + Cache hierarchy when ``force_refresh=False``: + 1. Fresh in-memory cache → return. + 2. Stale in-memory cache → return it and refresh in one background daemon + thread. Callers never block on the network while any cache exists; + models.dev only changes when providers add models, so stale data beats + a foreground timeout. + 3. Disk cache (any age) → populate in-mem and return; a stale one + triggers the same background refresh. Corrupt/empty is rejected. + 4. No cache → singleflight foreground network fetch, saved to disk+mem. + 5. Any failed refresh suppresses automatic refreshes for 5 minutes. - Network requests use ETag conditional GET when a cached ETag exists - AND a servable registry is held (on a cold ``force_refresh`` the - memory cache is hydrated from disk first). A 304 Not Modified - response re-confirms the existing cache's freshness without - re-downloading the full (~2 MB) registry. + ``force_refresh=True`` (``hermes config refresh``) bypasses the cache fast + paths and the backoff, falling back to cached data only if the call fails. + ``allow_network=False`` returns any memory/disk cache regardless of age and + never makes a request — for latency-sensitive paths (gateway route-identity + checks, vision routing, context-length lookup). - Cache hierarchy (when ``force_refresh=False``): - 1. Fresh in-memory cache → return immediately. - 2. Stale in-memory cache → return immediately and refresh in a single - background daemon thread. Callers never block on the network while - any cache exists; ``models.dev`` only changes when providers add - new models, so stale data is preferable to a foreground timeout. - 3. Disk cache file (any age) → load, populate in-mem, return - immediately. Stale disk caches trigger the same background refresh. - A corrupt or empty disk cache is rejected with a warning. - 4. No cache at all → singleflight foreground network fetch. On - success, save to disk + in-mem and return. - 5. Any failed refresh (foreground or background) suppresses further - automatic refreshes for 5 minutes process-wide. - - When ``force_refresh=True`` (used by ``hermes config refresh``, the - \"refresh model catalog\" code path), cache fast paths and the failure - backoff are bypassed; the function hits the network and only falls back - to cached data if the call fails. When ``allow_network=False``, any - memory or disk cache is returned regardless of age and no request is - made — used by latency-sensitive paths (gateway route-identity checks, - vision routing, context-length lookup) that must never wait on the - network. + Network requests use ETag conditional GET when a servable registry is held + (a cold ``force_refresh`` hydrates memory from disk first); a 304 + re-confirms the cache without re-downloading ~2 MB. """ global _models_dev_cache, _models_dev_cache_time, _models_dev_retry_after @@ -627,8 +547,7 @@ def fetch_models_dev( ) return _models_dev_cache - # Stage 1: fresh in-memory cache wins. This is the hot path on - # long-lived processes — no I/O, no system calls. + # Stage 1: fresh in-memory cache — the hot path, no I/O. if ( not force_refresh and _models_dev_cache @@ -636,8 +555,7 @@ def fetch_models_dev( ): return _models_dev_cache - # Stage 2: stale in-memory cache is still better than blocking provider - # resolution on a foreground network timeout. Refresh it in the background. + # Stage 2: stale in-memory cache beats blocking on the network. if not force_refresh and _models_dev_cache: _mark_stale_cache_grace() _start_background_refresh_models_dev() @@ -646,11 +564,8 @@ def fetch_models_dev( ) return _models_dev_cache - # Stage 3: disk cache short-circuits the network call. - # Only kicks in on cold-start processes (in-mem cache is empty) and only - # when the user hasn't asked for a forced refresh. A stale disk cache is - # deliberately usable: provider/model resolution should not hang just - # because models.dev is unreachable. + # Stage 3: disk cache (cold-start only). A stale disk cache is deliberately + # usable so resolution doesn't hang when models.dev is unreachable. if not force_refresh: disk_age = _disk_cache_age_seconds() if disk_age is not None: @@ -658,8 +573,8 @@ def fetch_models_dev( if disk_data: _models_dev_cache = disk_data if disk_age < _MODELS_DEV_CACHE_TTL: - # Anchor in-mem TTL to the disk file's age so we don't - # extend an already-aging cache by another full hour. + # Anchor the in-mem TTL to the file's age so an aging + # cache isn't extended by another full TTL. _models_dev_cache_time = time.time() - disk_age logger.debug( "Loaded models.dev from fresh disk cache " @@ -675,28 +590,20 @@ def fetch_models_dev( ) return _models_dev_cache - # Failed automatic refreshes are process-wide. Avoid making every caller - # retry the same unreachable endpoint while no usable cache exists. + # Process-wide backoff: don't make every caller retry an unreachable + # endpoint while no usable cache exists. if not force_refresh and time.time() < _models_dev_retry_after: return _models_dev_cache - # Stage 4: singleflight foreground network fetch — only reached when no - # memory or disk cache exists (or on force_refresh). Recheck state after - # acquiring the lock because another caller may have refreshed or - # established backoff while we waited. + # Stage 4: singleflight foreground fetch. Recheck state under the lock — + # another caller may have refreshed or armed the backoff while we waited. with _models_dev_fetch_lock: now = time.time() - if not force_refresh: - if _models_dev_cache: - return _models_dev_cache - if now < _models_dev_retry_after: - return _models_dev_cache + if not force_refresh and (_models_dev_cache or now < _models_dev_retry_after): + return _models_dev_cache - # Cold force_refresh (fresh CLI process): stages 1-3 were skipped, - # so the memory cache may be empty even though a servable disk - # cache + ETag sidecar exist. Hydrate first so the conditional GET - # fires (a 304 then re-confirms the disk data instead of - # re-downloading the full ~2 MB registry). + # Cold force_refresh: stages 1-3 were skipped, so hydrate memory from + # disk first so the conditional GET fires and a 304 can re-confirm it. if force_refresh and not _models_dev_cache: disk = _load_disk_cache() if disk: @@ -710,16 +617,13 @@ def fetch_models_dev( _commit_registry(data, etag=etag, where="foreground") return data except _NotModified: - # Server confirmed our cache is still valid. Re-confirm freshness - # without re-downloading the full registry. _confirm_cache_not_modified(where="foreground") return _models_dev_cache except Exception as e: _note_refresh_failure(e, where="foreground") - # Stage 5: network failed — return any stale memory/disk cache. Cache - # freshness remains expired; the retry-after timestamp controls when - # the next automatic request is allowed. + # Stage 5: network failed — serve any stale memory/disk cache. Freshness + # stays expired; the retry-after timestamp gates the next attempt. if not _models_dev_cache: _models_dev_cache = _load_disk_cache() _models_dev_cache_time = 0 @@ -732,163 +636,157 @@ def fetch_models_dev( return _models_dev_cache -def lookup_models_dev_context( - provider: str, model: str, *, allow_network: bool = False -) -> Optional[int]: - """Look up context_length for a provider+model combo in models.dev. +# --------------------------------------------------------------------------- +# Catalog access helpers +# --------------------------------------------------------------------------- - Returns the context window in tokens, or None if not found. - Handles case-insensitive matching and filters out context=0 entries. +def _fetch_registry(allow_network: bool) -> Dict[str, Any]: + # Keep the zero-argument call on the allow_network path: dozens of test + # sites monkeypatch fetch_models_dev with zero-arg lambdas. + return fetch_models_dev() if allow_network else fetch_models_dev(allow_network=False) - An EXPLICIT ``model_overrides`` config entry for this provider+model - wins over the catalog value; ``_default`` entries fill the gap only - when the catalog has no answer — the supported self-unblock path for - models with wrong or missing context in models.dev (#84482). - ``allow_network`` defaults to False — context-length lookup is a - hot path (called during every conversation turn) and must never block - on the network. Pass True only from explicit refresh flows. +def _registry_models(mdev_id: str, *, allow_network: bool) -> Optional[Dict[str, Any]]: + """The ``models`` dict of a models.dev provider entry, or None.""" + provider_data = _fetch_registry(allow_network).get(mdev_id) + if not isinstance(provider_data, dict): + return None + models = provider_data.get("models", {}) + return models if isinstance(models, dict) else None + + +def _get_provider_models( + provider: str, *, allow_network: bool = False +) -> Optional[Dict[str, Any]]: + """Resolve a Hermes provider ID to its models dict, or None if unknown. + + ``allow_network`` defaults to False — called from hot paths (vision/image + routing, capability checks) that must never block. """ - # Explicit config override — checked before catalog so it always wins. - override_ctx = _override_context_window(provider, model) - if override_ctx is not None: - return override_ctx - mdev_provider_id = PROVIDER_TO_MODELS_DEV.get(provider) if not mdev_provider_id: - return _default_override_context(provider) + return None + return _registry_models(mdev_provider_id, allow_network=allow_network) - # NOTE: keep the zero-argument call on the allow_network path. Dozens - # of test sites monkeypatch fetch_models_dev with zero-arg lambdas; - # passing the kwarg unconditionally breaks them all (TypeError). - data = ( - fetch_models_dev() - if allow_network - else fetch_models_dev(allow_network=False) - ) - provider_data = data.get(mdev_provider_id) - if not isinstance(provider_data, dict): - return _default_override_context(provider) - models = provider_data.get("models", {}) - if not isinstance(models, dict): - return _default_override_context(provider) +def _iter_model_entries(models: Dict[str, Any], model: str, *, suffix_fallback: bool = True): + """Yield ``(model_id, entry)`` candidates: exact, case-insensitive, then + (optionally) ``:cloud``/``-cloud`` suffixed forms. - # Exact match + The suffix fallback exists because some providers (e.g. ollama-cloud) + store ``kimi-k2.6:cloud`` while the live API returns the bare name; + without it context lookup falls through to stale OpenRouter metadata and + trips the 64k minimum-context guard. Every consumer shares this order so + "is this model in the catalog" means the same thing everywhere — a + suffix-keyed catalog model must count as KNOWN for ``model_overrides`` + fill-gap ``_default`` semantics. + """ entry = models.get(model) - if entry: - ctx = _extract_context(entry) - if ctx: - return ctx - - # Case-insensitive match + if isinstance(entry, dict): + yield model, entry model_lower = model.lower() for mid, mdata in models.items(): - if mid.lower() == model_lower: - ctx = _extract_context(mdata) - if ctx: - return ctx - - # Suffix-aware fallback: some providers (e.g. ollama-cloud) store - # model IDs with :cloud / -cloud suffixes in models.dev while the - # live API returns bare names. Without this, kimi-k2.6 misses the - # kimi-k2.6:cloud entry and falls through to stale OpenRouter metadata - # reporting 32768 — tripping the 64k minimum-context guard. - # The suffix-stripping in fetch_ollama_cloud_models() handles the - # model-picker UX; this handles the context-length lookup path. + if mid.lower() == model_lower and isinstance(mdata, dict): + yield mid, mdata + if not suffix_fallback: + return for suffix in (":cloud", "-cloud"): - suffixed_key = model + suffix - entry = models.get(suffixed_key) - if entry: - ctx = _extract_context(entry) - if ctx: - return ctx - # Also try case-insensitive + entry = models.get(model + suffix) + if isinstance(entry, dict): + yield model + suffix, entry suffixed_lower = model_lower + suffix for mid, mdata in models.items(): - if mid.lower() == suffixed_lower: - ctx = _extract_context(mdata) - if ctx: - return ctx - - # Catalog miss — a _default override may fill the gap (#84482). - return _default_override_context(provider) + if mid.lower() == suffixed_lower and isinstance(mdata, dict): + yield mid, mdata -def _default_override_context(provider: str) -> Optional[int]: - """Fill-gap context from a ``_default`` override, for catalog misses.""" - default = _default_model_override(provider) - if default is None: - return None - return _override_int(default, "context_window") +def _find_model_entry(models: Dict[str, Any], model: str) -> Optional[Dict[str, Any]]: + """First catalog entry for *model* (exact, case-insensitive, suffix), or None.""" + for _mid, entry in _iter_model_entries(models, model): + return entry + return None -def _extract_context(entry: Dict[str, Any]) -> Optional[int]: - """Extract context_length from a models.dev model entry. - - Returns None for invalid/zero values (some audio/image models have context=0). - """ +def _extract_limit(entry: Any, key: str) -> Optional[int]: + """Positive int ``entry.limit[key]`` or None (audio/image models have context=0).""" if not isinstance(entry, dict): return None limit = entry.get("limit") if not isinstance(limit, dict): return None - ctx = limit.get("context") - if isinstance(ctx, (int, float)) and ctx > 0: - return int(ctx) + value = limit.get(key) + if isinstance(value, (int, float)) and value > 0: + return int(value) return None +def _extract_context(entry: Dict[str, Any]) -> Optional[int]: + """Context length from a models.dev model entry, or None if invalid/zero.""" + return _extract_limit(entry, "context") + + +def lookup_models_dev_context( + provider: str, model: str, *, allow_network: bool = False +) -> Optional[int]: + """Context window in tokens for provider+model, or None if not found. + + An EXPLICIT ``model_overrides`` entry wins over the catalog; ``_default`` + entries fill the gap only when the catalog has no answer (the supported + self-unblock path for models with wrong/missing context in models.dev). + Catalog entries with context=0 are skipped in favour of later candidates. + + ``allow_network`` defaults to False — this runs every conversation turn + and must never block; pass True only from explicit refresh flows. + """ + override_ctx = _override_context_window(provider, model) + if override_ctx is not None: + return override_ctx + + models = _get_provider_models(provider, allow_network=allow_network) + if models is not None: + for _mid, entry in _iter_model_entries(models, model): + ctx = _extract_context(entry) + if ctx: + return ctx + return _default_override_context(provider) + + # --------------------------------------------------------------------------- -# Model capability metadata +# Per-model metadata overrides (config.yaml → model_overrides) # --------------------------------------------------------------------------- - - -@dataclass -class ModelCapabilities: - """Structured capability metadata for a model from models.dev.""" - - supports_tools: bool = True - supports_vision: bool = False - supports_reasoning: bool = False - context_window: int = 200000 - max_output_tokens: int = 8192 - model_family: str = "" - - -# --------------------------------------------------------------------------- # -# Per-model metadata overrides (config.yaml → model_overrides) # -# --------------------------------------------------------------------------- # # # Canonical override schema (the ONLY key space consumers accept): # context_window, max_output_tokens, supports_tools, supports_vision, # supports_reasoning, model_family # -# Resolution semantics: -# 1. ``model_overrides..`` — explicit override. Always -# wins over the catalog for the fields it sets (partial patch). -# 2. ``model_overrides.._default`` / ``model_overrides._default`` -# — FILL-GAP defaults. They apply ONLY to models the catalog does not -# know (the #8731/#84482 self-unblock path for custom/local/new -# models) and never displace catalog data for known models. A -# ``_default: {context_window: 128000}`` therefore cannot clamp every -# catalog-known model of a provider. +# Resolution: ``model_overrides..`` is an explicit override +# that always wins over the catalog for the fields it sets (partial patch). +# ``model_overrides.._default`` / ``model_overrides._default`` are +# FILL-GAP defaults: they apply ONLY to models the catalog does not know (the +# self-unblock path for custom/local/new models) and never displace catalog +# data — a ``_default: {context_window: 128000}`` cannot clamp every +# catalog-known model of a provider. # -# Provider keys accept the Hermes provider id (as used elsewhere in -# config.yaml) or the models.dev provider id. Model ids match exactly, -# then case-insensitively (mirroring catalog lookup). +# Provider keys accept the Hermes provider id or the models.dev provider id. +# Model ids match exactly, then case-insensitively (mirroring catalog lookup). _OVERRIDE_WARNED_KEYS: set = set() +# Safe defaults for models absent from the catalog (tools on, vision/reasoning +# off, 200K context); shared by get_model_capabilities and get_model_info so +# the two unknown-model paths agree. +_UNKNOWN_MODEL_BASE: Dict[str, Any] = { + "limit": {"context": 200000, "output": 8192}, + "tool_call": True, +} + def _load_model_overrides() -> Dict[str, Any]: - """Load the ``model_overrides`` config section. + """The ``model_overrides`` config section ({} on any failure). - No local memoization on purpose: ``load_config_readonly()`` is already - (mtime, size)-cached upstream (a hit is ~one stat, no deepcopy, no - parse), and an ``id(cfg)``-keyed layer here can serve stale overrides - after a config reload when CPython reuses the freed dict's address. - Returns empty dict on any failure. + Deliberately not memoized: ``load_config_readonly()`` is already + (mtime, size)-cached upstream, and an ``id(cfg)``-keyed layer here can + serve stale overrides after a reload when CPython reuses the dict address. """ try: from hermes_cli.config import cfg_get, load_config_readonly @@ -899,12 +797,7 @@ def _load_model_overrides() -> Dict[str, Any]: def _provider_override_section(provider: str) -> Optional[Dict[str, Any]]: - """Return the override section for *provider*, or None. - - Accepts either the Hermes provider id or the models.dev provider id as - the config key, so ``copilot`` and ``github-copilot`` both work - regardless of which id space a caller passes in. - """ + """Override section for *provider* (keyed by Hermes OR models.dev id), or None.""" overrides = _load_model_overrides() if not overrides: return None @@ -929,11 +822,8 @@ def _provider_override_section(provider: str) -> Optional[Dict[str, Any]]: def _explicit_model_override(provider: str, model: str) -> Optional[Dict[str, Any]]: - """Return the explicit per-provider+model override dict, or None. - - Model ids match exactly first, then case-insensitively (skipping the - ``_default`` sentinel), mirroring catalog lookup behavior. - """ + """Explicit per-provider+model override dict (exact, then case-insensitive + skipping the ``_default`` sentinel), or None.""" model_key = (model or "").strip() if not model_key: return None @@ -955,18 +845,13 @@ def _explicit_model_override(provider: str, model: str) -> Optional[Dict[str, An def _default_model_override(provider: str) -> Optional[Dict[str, Any]]: - """Return the fill-gap ``_default`` override for *provider*, or None. - - Checks the per-provider ``_default`` first, then the global one. Only - consulted for models the catalog does not know — see the block comment. - """ + """Fill-gap ``_default`` override: per-provider first, then global; or None.""" section = _provider_override_section(provider) if section is not None: default = section.get("_default") if isinstance(default, dict): return default - overrides = _load_model_overrides() - global_default = overrides.get("_default") + global_default = _load_model_overrides().get("_default") if isinstance(global_default, dict): return global_default return None @@ -975,11 +860,7 @@ def _default_model_override(provider: str) -> Optional[Dict[str, Any]]: def _override_for( provider: str, model: str, *, catalog_hit: bool ) -> Optional[Dict[str, Any]]: - """Select the override dict for a lookup, honoring fill-gap semantics. - - Explicit per-provider+model overrides always apply. ``_default`` - entries apply only when the catalog has no entry for the model. - """ + """Explicit override if any; else the ``_default`` only on a catalog miss.""" explicit = _explicit_model_override(provider, model) if explicit is not None: return explicit @@ -1010,18 +891,21 @@ def _override_int(override: Dict[str, Any], key: str) -> Optional[int]: def _override_context_window(provider: str, model: str) -> Optional[int]: - """Return the EXPLICITLY overridden context_window, or None. + """EXPLICITLY overridden context_window, or None. Explicit-only on purpose: this runs early in the resolution chain - (agent/model_metadata.py step 0b, before custom_providers and live - probes), where a ``_default`` must not preempt more specific sources. - Fill-gap defaults are applied later by ``lookup_models_dev_context`` - once the catalog has actually missed. + (agent/model_metadata.py, before custom_providers and live probes) where + a ``_default`` must not preempt more specific sources; fill-gap defaults + apply later in ``lookup_models_dev_context`` once the catalog has missed. """ ov = _explicit_model_override(provider, model) - if ov is None: - return None - return _override_int(ov, "context_window") + return _override_int(ov, "context_window") if ov is not None else None + + +def _default_override_context(provider: str) -> Optional[int]: + """Fill-gap context from a ``_default`` override, for catalog misses.""" + default = _default_model_override(provider) + return _override_int(default, "context_window") if default is not None else None def _override_to_catalog_shape( @@ -1029,14 +913,9 @@ def _override_to_catalog_shape( ) -> Tuple[Dict[str, Any], Optional[bool]]: """Translate canonical override keys into a models.dev-shaped patch. - ``get_model_info``/``_parse_model_info`` consume the raw catalog shape - (``limit.context``, ``tool_call``, ...). All override consumers accept - ONE canonical schema (the documented ``context_window``/``supports_*`` - keys), so this boundary translates rather than forcing users to know - the internal catalog shape. - - Returns ``(patch, vision)`` — vision is returned out-of-band (not as - a key in the patch) because it maps onto the catalog's + Consumers read the raw catalog shape (``limit.context``, ``tool_call``, ...) + while users write ONE canonical schema, so this boundary translates. + Returns ``(patch, vision)`` — vision is out-of-band because it maps onto the ``modalities.input`` list rather than a scalar field. """ patch: Dict[str, Any] = {} @@ -1065,12 +944,9 @@ def _override_to_catalog_shape( def _merge_catalog_entry_with_override( raw: Dict[str, Any], override: Dict[str, Any] ) -> Dict[str, Any]: - """Patch a catalog entry with a canonical-schema override. - - Sub-dicts (``limit``, ``modalities``) are merged, not clobbered — an - override setting only ``context_window`` must not wipe the catalog's - ``limit.output``. - """ + """Patch a catalog entry with a canonical-schema override. Sub-dicts + (``limit``, ``modalities``) are merged, not clobbered — setting only + ``context_window`` must not wipe the catalog's ``limit.output``.""" shaped, vision_override = _override_to_catalog_shape(override) merged = dict(raw) limit_patch = shaped.pop("limit", None) @@ -1094,192 +970,64 @@ def _merge_catalog_entry_with_override( return merged -def _get_provider_models( - provider: str, *, allow_network: bool = False -) -> Optional[Dict[str, Any]]: - """Resolve a Hermes provider ID to its models dict from models.dev. +# --------------------------------------------------------------------------- +# Model capability metadata +# --------------------------------------------------------------------------- - Returns the models dict or None if the provider is unknown or has no data. - - ``allow_network`` defaults to False — this is called from hot paths - (vision routing, image routing, capability checks) and must never block. - """ - mdev_provider_id = PROVIDER_TO_MODELS_DEV.get(provider) - if not mdev_provider_id: - return None - - # NOTE: keep the zero-argument call on the allow_network path. Dozens - # of test sites monkeypatch fetch_models_dev with zero-arg lambdas; - # passing the kwarg unconditionally breaks them all (TypeError). - data = ( - fetch_models_dev() - if allow_network - else fetch_models_dev(allow_network=False) - ) - provider_data = data.get(mdev_provider_id) - if not isinstance(provider_data, dict): - return None - - models = provider_data.get("models", {}) - if not isinstance(models, dict): - return None - - return models - - -def _find_model_entry(models: Dict[str, Any], model: str) -> Optional[Dict[str, Any]]: - """Find a model entry: exact, case-insensitive, then suffix fallback. - - The ``:cloud``/``-cloud`` suffix fallback mirrors - ``lookup_models_dev_context`` so "is this model in the catalog" means - the same thing to every consumer — important for ``model_overrides`` - fill-gap ``_default`` semantics, where a suffix-keyed catalog model - (e.g. ``kimi-k2.6:cloud``) must count as KNOWN and keep its catalog - metadata rather than being displaced by a ``_default``. - """ - # Exact match - entry = models.get(model) - if isinstance(entry, dict): - return entry - - # Case-insensitive match - model_lower = model.lower() - for mid, mdata in models.items(): - if mid.lower() == model_lower and isinstance(mdata, dict): - return mdata - - # Suffix-aware fallback (e.g. ollama-cloud stores kimi-k2.6:cloud - # while the live API returns the bare name). - for suffix in (":cloud", "-cloud"): - entry = models.get(model + suffix) - if isinstance(entry, dict): - return entry - suffixed_lower = model_lower + suffix - for mid, mdata in models.items(): - if mid.lower() == suffixed_lower and isinstance(mdata, dict): - return mdata - - return None +def _entry_supports_vision(entry: Dict[str, Any]) -> bool: + """Prefer explicit ``modalities.input`` (the older ``attachment`` flag can be + stale or too broad for image routing); fall back to it only when the input + modalities are absent/invalid.""" + input_mods = entry.get("modalities", {}) + input_mods = input_mods.get("input") if isinstance(input_mods, dict) else None + if isinstance(input_mods, list): + return "image" in input_mods + return bool(entry.get("attachment", False)) def get_model_capabilities( provider: str, model: str, *, allow_network: bool = False ) -> Optional[ModelCapabilities]: - """Look up full capability metadata from models.dev cache. + """Capability metadata from the models.dev cache, or None if unresolvable. - Uses the existing fetch_models_dev() and PROVIDER_TO_MODELS_DEV mapping. - Returns None if model not found. + EXPLICIT ``model_overrides`` entries patch catalog values for the fields + they set; ``_default`` entries fill the gap only for models the catalog + does not know. Unspecified fields fall through to the catalog value, or to + safe defaults (tools on, vision/reasoning off, 200K/8K) when absent. - EXPLICIT ``model_overrides`` entries (per-provider+model) win over - catalog values for the fields they set. ``_default`` entries fill the - gap only for models the catalog does not know — the supported - self-unblock path for custom/local models (#8731) and for models with - wrong metadata in models.dev (#84482). An override may set any subset - of fields; unspecified fields fall through to the catalog value (or - sensible defaults when the model is absent from the catalog). - - ``allow_network`` defaults to False — capability lookup is a hot path - (vision routing, image routing) and must never block on the network. - - Extracts from model entry fields: - - reasoning (bool) → supports_reasoning - - tool_call (bool) → supports_tools - - attachment (bool) → supports_vision - - limit.context (int) → context_window - - limit.output (int) → max_output_tokens - - family (str) → model_family + ``allow_network`` defaults to False — vision/image routing is a hot path. """ models = _get_provider_models(provider, allow_network=allow_network) entry = _find_model_entry(models, model) if models is not None else None - # Select the override AFTER the catalog lookup: explicit overrides - # always apply; _default entries only fill gaps for catalog misses. + # Select the override AFTER the catalog lookup: _default only fills misses. override = _override_for(provider, model, catalog_hit=entry is not None) - - # If no catalog entry and no override, we can't resolve capabilities. if entry is None and override is None: return None - # Start from catalog entry (if found), else use defaults. - if entry is not None: - supports_tools = bool(entry.get("tool_call", False)) - # Vision: prefer explicit `modalities.input` when models.dev provides it. - # The older `attachment` flag can be stale or too broad for image routing; - # fall back to it only when the input modalities are absent/invalid. - input_mods = entry.get("modalities", {}) - if isinstance(input_mods, dict): - input_mods = input_mods.get("input") - else: - input_mods = None - if isinstance(input_mods, list): - supports_vision = "image" in input_mods - else: - supports_vision = bool(entry.get("attachment", False)) - supports_reasoning = bool(entry.get("reasoning", False)) - - limit = entry.get("limit", {}) - if not isinstance(limit, dict): - limit = {} - - ctx = limit.get("context") - context_window = int(ctx) if isinstance(ctx, (int, float)) and ctx > 0 else 200000 - - out = limit.get("output") - max_output_tokens = int(out) if isinstance(out, (int, float)) and out > 0 else 8192 - - model_family = entry.get("family", "") or "" - else: - # Unknown model — derive sensible defaults. The override will - # patch whichever fields it specifies; the rest stay at defaults - # that are safe for agentic use (tools on, vision/reasoning off). - supports_tools = True - supports_vision = False - supports_reasoning = False - context_window = 200000 - max_output_tokens = 8192 - model_family = "" - - # Apply override patches (each field is optional in the override dict). + raw = entry if entry is not None else _UNKNOWN_MODEL_BASE if override is not None: - if "supports_tools" in override: - supports_tools = bool(override["supports_tools"]) - if "supports_vision" in override: - supports_vision = bool(override["supports_vision"]) - if "supports_reasoning" in override: - supports_reasoning = bool(override["supports_reasoning"]) - ctx_ov = _override_int(override, "context_window") - if ctx_ov is not None: - context_window = ctx_ov - out_ov = _override_int(override, "max_output_tokens") - if out_ov is not None: - max_output_tokens = out_ov - if "model_family" in override: - model_family = str(override["model_family"] or "") + raw = _merge_catalog_entry_with_override(raw, override) return ModelCapabilities( - supports_tools=supports_tools, - supports_vision=supports_vision, - supports_reasoning=supports_reasoning, - context_window=context_window, - max_output_tokens=max_output_tokens, - model_family=model_family, + supports_tools=bool(raw.get("tool_call", False)), + supports_vision=_entry_supports_vision(raw), + supports_reasoning=bool(raw.get("reasoning", False)), + context_window=_extract_limit(raw, "context") or 200000, + max_output_tokens=_extract_limit(raw, "output") or 8192, + model_family=raw.get("family", "") or "", ) def list_provider_models( provider: str, *, allow_network: bool = True ) -> List[str]: - """Return all model IDs for a provider from models.dev. - - Returns an empty list if the provider is unknown or has no data. - - ``allow_network`` defaults to True — this is called from the model - picker (``hermes model``), which is an interactive user-facing flow - where a fresh catalog is worth a short network wait. - """ + """All model IDs for a provider ([] if unknown). ``allow_network`` defaults + to True: the model picker is interactive and a fresh catalog is worth a + short wait.""" from hermes_cli.models import normalize_provider provider = normalize_provider(provider) or provider - + models = _get_provider_models(provider, allow_network=allow_network) if models is None: return [] @@ -1289,19 +1037,16 @@ def list_provider_models( ] -# Patterns that indicate non-agentic or noise models (TTS, embedding, -# dated preview snapshots, live/streaming-only, image-only). -import re +# Non-agentic or noise models (TTS, embedding, dated preview snapshots, +# live/streaming-only, image-only). _NOISE_PATTERNS: re.Pattern = re.compile( r"-tts\b|embedding|live-|-(preview|exp)-\d{2,4}[-_]|" r"-image\b|-image-preview\b|-customtools\b", re.IGNORECASE, ) -# Google's live Gemini catalogs currently include a mix of stale slugs and -# Gemma models whose TPM quotas are too small for normal Hermes agent traffic. -# Keep capability metadata available for direct/manual use, but hide these from -# the Gemini model catalogs we surface in setup and model selection. +# Hidden from the Gemini catalogs surfaced in setup/model selection (capability +# metadata stays available for direct/manual use). _GOOGLE_HIDDEN_MODELS = frozenset({ # Low-TPM Gemma models that trip Google input-token quota walls under # agent-style traffic despite advertising large context windows. @@ -1318,8 +1063,7 @@ _GOOGLE_HIDDEN_MODELS = frozenset({ "gemma-3-12b-it", "gemma-3-27b", "gemma-3-27b-it", - # Stale/retired Google slugs that still surface through models.dev-backed - # Gemini selection but 404 on the current Google endpoints. + # Stale/retired Google slugs that 404 on the current endpoints. "gemini-1.5-flash", "gemini-1.5-pro", "gemini-1.5-flash-8b", @@ -1331,52 +1075,33 @@ _GOOGLE_HIDDEN_MODELS = frozenset({ def _should_hide_from_provider_catalog(provider: str, model_id: str) -> bool: provider_lower = (provider or "").strip().lower() model_lower = (model_id or "").strip().lower() - if provider_lower in {"gemini", "google"} and model_lower in _GOOGLE_HIDDEN_MODELS: - return True - return False + return provider_lower in {"gemini", "google"} and model_lower in _GOOGLE_HIDDEN_MODELS def list_agentic_models( provider: str, *, allow_network: bool = True ) -> List[str]: - """Return model IDs suitable for agentic use from models.dev. - - Filters for tool_call=True and excludes noise (TTS, embedding, - dated preview snapshots, live/streaming, image-only models). - Returns an empty list on any failure. - - ``allow_network`` defaults to True — like ``list_provider_models``, - this is called from interactive model selection flows. - """ + """Model IDs suitable for agentic use: tool_call=True, minus hidden and + noise models. [] on any failure. ``allow_network`` defaults to True (called + from interactive model selection).""" models = _get_provider_models(provider, allow_network=allow_network) if models is None: return [] - - result = [] - for mid, entry in models.items(): - if not isinstance(entry, dict): - continue - if _should_hide_from_provider_catalog(provider, mid): - continue - if not entry.get("tool_call", False): - continue - if _NOISE_PATTERNS.search(mid): - continue - result.append(mid) - return result - + return [ + mid for mid, entry in models.items() + if isinstance(entry, dict) + and not _should_hide_from_provider_catalog(provider, mid) + and entry.get("tool_call", False) + and not _NOISE_PATTERNS.search(mid) + ] # --------------------------------------------------------------------------- -# Rich dataclass constructors — parse raw models.dev JSON into dataclasses +# Rich dataclass constructors + queries # --------------------------------------------------------------------------- def _parse_model_info(model_id: str, raw: Dict[str, Any], provider_id: str) -> ModelInfo: """Convert a raw models.dev model entry dict into a ModelInfo dataclass.""" - limit = raw.get("limit") or {} - if not isinstance(limit, dict): - limit = {} - cost = raw.get("cost") or {} if not isinstance(cost, dict): cost = {} @@ -1384,16 +1109,11 @@ def _parse_model_info(model_id: str, raw: Dict[str, Any], provider_id: str) -> M modalities = raw.get("modalities") or {} if not isinstance(modalities, dict): modalities = {} - input_mods = modalities.get("input") or [] output_mods = modalities.get("output") or [] - ctx = limit.get("context") - ctx_int = int(ctx) if isinstance(ctx, (int, float)) and ctx > 0 else 0 - out = limit.get("output") - out_int = int(out) if isinstance(out, (int, float)) and out > 0 else 0 - inp = limit.get("input") - inp_int = int(inp) if isinstance(inp, (int, float)) and inp > 0 else None + def _cost(key: str) -> Optional[float]: + return float(cost[key]) if key in cost and cost[key] is not None else None return ModelInfo( id=model_id, @@ -1408,13 +1128,13 @@ def _parse_model_info(model_id: str, raw: Dict[str, Any], provider_id: str) -> M open_weights=bool(raw.get("open_weights", False)), input_modalities=tuple(input_mods) if isinstance(input_mods, list) else (), output_modalities=tuple(output_mods) if isinstance(output_mods, list) else (), - context_window=ctx_int, - max_output=out_int, - max_input=inp_int, + context_window=_extract_limit(raw, "context") or 0, + max_output=_extract_limit(raw, "output") or 0, + max_input=_extract_limit(raw, "input"), cost_input=float(cost.get("input", 0) or 0), cost_output=float(cost.get("output", 0) or 0), - cost_cache_read=float(cost["cache_read"]) if "cache_read" in cost and cost["cache_read"] is not None else None, - cost_cache_write=float(cost["cache_write"]) if "cache_write" in cost and cost["cache_write"] is not None else None, + cost_cache_read=_cost("cache_read"), + cost_cache_write=_cost("cache_write"), knowledge_cutoff=raw.get("knowledge", "") or "", release_date=raw.get("release_date", "") or "", status=raw.get("status", "") or "", @@ -1436,115 +1156,47 @@ def _parse_provider_info(provider_id: str, raw: Dict[str, Any]) -> ProviderInfo: ) -# --------------------------------------------------------------------------- -# Provider-level queries -# --------------------------------------------------------------------------- - def get_provider_info( provider_id: str, *, allow_network: bool = True ) -> Optional[ProviderInfo]: - """Get full provider metadata from models.dev. + """Provider metadata by Hermes or models.dev ID, or None if not cataloged. - Accepts either a Hermes provider ID (e.g. "kilocode") or a models.dev - ID (e.g. "kilo"). Returns None if the provider is not in the catalog. - - ``allow_network`` defaults to True — the primary caller is - ``resolve_provider_full`` during interactive setup, where a fresh - catalog is worth a short network wait. Hot-path callers should pass - ``allow_network=False``. + ``allow_network`` defaults to True — the primary caller is interactive + setup (``resolve_provider_full``). Hot-path callers pass False. """ - # Resolve Hermes ID → models.dev ID mdev_id = PROVIDER_TO_MODELS_DEV.get(provider_id, provider_id) - - # NOTE: keep the zero-argument call on the default path. Dozens of test - # sites monkeypatch fetch_models_dev with zero-arg lambdas; passing the - # kwarg unconditionally would break them all (they raise TypeError). - data = ( - fetch_models_dev() - if allow_network - else fetch_models_dev(allow_network=False) - ) - raw = data.get(mdev_id) + raw = _fetch_registry(allow_network).get(mdev_id) if not isinstance(raw, dict): return None - return _parse_provider_info(mdev_id, raw) -# --------------------------------------------------------------------------- -# Model-level queries (rich ModelInfo) -# --------------------------------------------------------------------------- - def get_model_info( provider_id: str, model_id: str, *, allow_network: bool = False ) -> Optional[ModelInfo]: - """Get full model metadata from models.dev. + """Full model metadata by Hermes or models.dev provider ID (exact match, + then case-insensitive), or None if not found. - Accepts Hermes or models.dev provider ID. Tries exact match then - case-insensitive fallback. Returns None if not found. + ``model_overrides`` use the same canonical schema as every other consumer + and are translated into the catalog shape here with sub-dicts merged, not + clobbered. EXPLICIT entries patch known catalog models; ``_default`` + entries fill the gap only for models the catalog does not know. - ``model_overrides`` entries use the SAME canonical schema as every - other consumer (``context_window``, ``max_output_tokens``, - ``supports_*``, ``model_family``) — they are translated into the - catalog shape at this boundary, and sub-dicts (``limit``, - ``modalities``) are merged rather than clobbered. EXPLICIT entries - patch known catalog models; ``_default`` entries fill the gap only - for models the catalog does not know (#8731, #84482). - - ``allow_network`` defaults to False — model info lookup is a hot path - (cost guard, inventory) and must never block on the network. + ``allow_network`` defaults to False — cost guard and inventory are hot paths. """ mdev_id = PROVIDER_TO_MODELS_DEV.get(provider_id, provider_id) - def _from_override_alone() -> Optional[ModelInfo]: - override = _override_for(provider_id, model_id, catalog_hit=False) - if override is None: - return None - # Seed the same safe defaults get_model_capabilities uses for - # unknown models (200K context, tools on) so the two - # unknown-model paths agree; the override patches its fields on - # top. - base = { - "limit": {"context": 200000, "output": 8192}, - "tool_call": True, - } - shaped = _merge_catalog_entry_with_override(base, override) - return _parse_model_info(model_id, shaped, mdev_id) - - # NOTE: keep the zero-argument call on the allow_network path. Dozens - # of test sites monkeypatch fetch_models_dev with zero-arg lambdas; - # passing the kwarg unconditionally breaks them all (TypeError). - data = ( - fetch_models_dev() - if allow_network - else fetch_models_dev(allow_network=False) - ) - pdata = data.get(mdev_id) - if not isinstance(pdata, dict): - return _from_override_alone() - - models = pdata.get("models", {}) - if not isinstance(models, dict): - return _from_override_alone() - - def _with_override(mid: str, raw: Dict[str, Any]) -> ModelInfo: - override = _override_for(provider_id, model_id, catalog_hit=True) + def _resolve(mid: str, raw: Dict[str, Any], *, catalog_hit: bool) -> Optional[ModelInfo]: + override = _override_for(provider_id, model_id, catalog_hit=catalog_hit) if override is not None: - merged = _merge_catalog_entry_with_override(raw, override) - return _parse_model_info(mid, merged, mdev_id) + raw = _merge_catalog_entry_with_override(raw, override) + elif not catalog_hit: + return None return _parse_model_info(mid, raw, mdev_id) - # Exact match - raw = models.get(model_id) - if isinstance(raw, dict): - return _with_override(model_id, raw) - - # Case-insensitive fallback - model_lower = model_id.lower() - for mid, mdata in models.items(): - if mid.lower() == model_lower and isinstance(mdata, dict): - return _with_override(mid, mdata) - - # Model not in catalog — an override (explicit or _default) may still - # provide the metadata. - return _from_override_alone() + models = _registry_models(mdev_id, allow_network=allow_network) + if models is not None: + for mid, raw in _iter_model_entries(models, model_id, suffix_fallback=False): + return _resolve(mid, raw, catalog_hit=True) + # Not in catalog — an override (explicit or _default) may still provide it. + return _resolve(model_id, _UNKNOWN_MODEL_BASE, catalog_hit=False) diff --git a/agent/portal_tags.py b/agent/portal_tags.py index 453ba1bbfb..8b1782b451 100644 --- a/agent/portal_tags.py +++ b/agent/portal_tags.py @@ -1,32 +1,16 @@ """Centralized Nous Portal request tags. -Every Hermes request that hits the Nous Portal — main agent loop, auxiliary -client (compression / titles / vision / web_extract / session_search / etc.), -and any future code path — must carry the same product-attribution tags so -Nous can attribute usage to Hermes Agent and bucket it by client release. +Every Hermes request to the Nous Portal (main loop, auxiliary client, fallback +paths) must carry the same product-attribution tags, sent in OpenAI-compatible +``extra_body['tags']``:: -Tag shape (sent in OpenAI-compatible ``extra_body['tags']``): + ["product=hermes-agent", "client=hermes-client-v<__version__>"] - [ - "product=hermes-agent", - "client=hermes-client-v<__version__>", - ] - -The version is sourced live from ``hermes_cli.__version__`` so it auto-aligns -to whatever release is installed; the release script -(``scripts/release.py``) regex-bumps that single string, and every Portal -request picks up the new tag on the next process start. - -Why one helper instead of inlining the literal at each site: -* Four call sites (main loop profile, aux client, run_agent compression - fallback, web_tools fallback) used to drift apart — see PR #24194 which - only got the aux site, leaving the main loop sending a different tag set. -* Tests should assert the same tag list everywhere; centralizing makes that - assertion a one-liner against this module. - -Do NOT pre-compute these as module-level constants in the consumers. The -version can change at runtime (editable installs, hot-reload tooling), and -``hermes_cli.__version__`` is the canonical source of truth. +One helper instead of inlined literals: the call sites drifted apart before, +and tests can assert one tag list everywhere. The version is read live from +``hermes_cli.__version__`` (the release script bumps that single string) — do +NOT pre-compute it as a module constant in consumers; it can change at runtime +(editable installs, hot reload). """ from __future__ import annotations @@ -34,63 +18,47 @@ from __future__ import annotations from contextvars import ContextVar from typing import List, Optional -# ── Ambient conversation context ───────────────────────────────────────────── -# -# The main agent loop knows its ``session_id``; the dozens of auxiliary call -# sites (compression, title generation, vision, web_extract, session_search, -# MoA reference/aggregator slots, curator, kanban helpers, ...) do not — they -# funnel through ``agent.auxiliary_client.call_llm`` which has no session -# handle. Rather than threading a ``session_id`` parameter through every one -# of those call sites (and every future one), the agent loop publishes the -# active conversation id here and ``nous_portal_tags()`` picks it up as a -# fallback whenever no explicit ``session_id`` is passed. -# -# ContextVar (not a module global) so concurrent agents in one process — -# gateway sessions, delegate_task subagents, batch runners — never see each -# other's conversation id. Worker threads spawned via -# ``tools.thread_context.propagate_context_to_thread`` (background review, -# MoA fan-out, tool executor) inherit it through the copied Context; bare -# threads (title generator) capture it explicitly at spawn time. +# Ambient conversation id (ATTRIBUTION value, sent as ``conversation=``). +# The agent loop publishes it at turn entry; the dozens of auxiliary call +# sites funnelling through ``auxiliary_client.call_llm`` (no session handle) +# pick it up via ``nous_portal_tags()`` instead of threading a session_id +# parameter everywhere. A ContextVar, not a module global, so concurrent agents +# in one process (gateway sessions, delegate subagents) never see each other's +# id; ``tools.thread_context.propagate_context_to_thread`` workers inherit it, +# bare threads capture it at spawn time. _conversation_id: ContextVar[Optional[str]] = ContextVar( "nous_portal_conversation_id", default=None ) - -# ── Ambient routing/affinity scope ─────────────────────────────────────────── -# -# Separate from the conversation id above, which is an ATTRIBUTION value: it -# names the conversation a request belongs to and is sent to the Portal as -# ``conversation=``. The affinity scope is a ROUTING value — OpenRouter's -# sticky ``session_id``, Nous Portal's sticky key and xAI's ``x-grok-conv-id`` -# use it to pin one conversation to one backend/prompt cache. -# -# The two agree for every host that keeps one session id per conversation, so -# the providers historically read the attribution id for both. They diverge -# for a host that mints one physical session per RESPONSE: attribution still -# resolves per row, while routing must follow the key the host declared for -# the whole chat (``agent.prompt_cache_scope.declared_conversation_scope``, -# issue #96811). Only that declared value is published here — unset means -# "no declaration", and consumers fall back to the conversation id exactly as -# before, so delegate trees keep sharing their parent's sticky key. +# Ambient affinity scope (ROUTING value): OpenRouter's sticky ``session_id``, +# Nous Portal's sticky key and xAI's ``x-grok-conv-id`` pin a conversation to +# one backend/prompt cache. Usually equal to the conversation id, but a host +# that mints one physical session per RESPONSE must route on the key it +# declared for the whole chat (``prompt_cache_scope.declared_conversation_scope``). +# Only that declared value is published; unset means consumers fall back to the +# conversation id, so delegate trees keep sharing their parent's sticky key. _affinity_scope: ContextVar[Optional[str]] = ContextVar( "hermes_affinity_scope", default=None ) -def set_affinity_scope(scope: Optional[str]): - """Publish the declared routing/affinity scope for this turn. +def _reset_var(var: ContextVar, token) -> None: + """Reset ``var``; a token from another Context (reset on a different + thread) falls back to clearing rather than raising in cleanup paths.""" + try: + var.reset(token) + except Exception: + var.set(None) - Returns the ContextVar token; pair with :func:`reset_affinity_scope`. - """ + +def set_affinity_scope(scope: Optional[str]): + """Publish the declared routing/affinity scope; returns the ContextVar token.""" return _affinity_scope.set(scope or None) def reset_affinity_scope(token) -> None: """Restore the previous affinity scope (pair with ``set_affinity_scope``).""" - try: - _affinity_scope.reset(token) - except Exception: - _affinity_scope.set(None) + _reset_var(_affinity_scope, token) def get_affinity_scope() -> Optional[str]: @@ -101,22 +69,16 @@ def get_affinity_scope() -> Optional[str]: def set_conversation_context(conversation_id: Optional[str]): """Publish the active conversation id for ambient Portal tagging. - Called by the agent loop at turn entry with the conversation's stable - id (the session-lineage ROOT id, so the tag survives context-compression - session rotation). Pass ``None`` to clear. Returns the ContextVar token - so callers can ``reset_conversation_context(token)`` on turn exit. + Called by the agent loop at turn entry with the session-lineage ROOT id + (so the tag survives context-compression rotation). ``None`` clears. + Returns the ContextVar token for ``reset_conversation_context``. """ return _conversation_id.set(conversation_id or None) def reset_conversation_context(token) -> None: """Restore the previous conversation context (pair with ``set_...``).""" - try: - _conversation_id.reset(token) - except Exception: - # Token from another Context (e.g. reset on a different thread) — - # fall back to clearing rather than raising in cleanup paths. - _conversation_id.set(None) + _reset_var(_conversation_id, token) def get_conversation_context() -> Optional[str]: @@ -125,11 +87,7 @@ def get_conversation_context() -> Optional[str]: def _hermes_version() -> str: - """Return the current Hermes release version, e.g. ``"0.13.0"``. - - Falls back to ``"unknown"`` if ``hermes_cli`` cannot be imported (should - never happen in a real install — guarded for defensive testing). - """ + """Current Hermes release version; ``"unknown"`` if hermes_cli is unimportable.""" try: from hermes_cli import __version__ return __version__ @@ -138,48 +96,24 @@ def _hermes_version() -> str: def hermes_client_tag() -> str: - """Return the ``client=...`` tag for Nous Portal requests. - - Format: ``client=hermes-client-v..``. - """ + """``client=hermes-client-v..``.""" return f"client=hermes-client-v{_hermes_version()}" def conversation_tag(session_id: str) -> str: - """Return the ``conversation=...`` tag for a Hermes session/conversation. - - Format: ``conversation=``. ``session_id`` is the canonical - Hermes conversation identifier (``AIAgent.session_id``) — the same value - used for ``~/.hermes/sessions/`` storage, session logs, and lineage. - - Unlike the product/client tags this is high-cardinality (one value per - conversation), so it is only appended when a session id is actually - available — never as part of the always-on base tag set. - """ + """``conversation=`` — high-cardinality, so only appended when + a session id is actually available, never in the always-on base set.""" return f"conversation={session_id}" def nous_portal_tags(session_id: str | None = None) -> List[str]: - """Return the canonical list of Nous Portal product tags. + """Return a fresh list of the canonical Nous Portal tags. - Always returns a fresh list so callers can mutate it freely - (e.g. ``merged_extra.setdefault("tags", []).extend(nous_portal_tags())``). - - When ``session_id`` is provided, a ``conversation=`` tag is - appended so Portal usage can be attributed to a specific Hermes - conversation. When it is omitted, the ambient conversation context - (``set_conversation_context``, published by the agent loop at turn - entry) is used instead — this is how auxiliary calls (compression, - titles, vision, MoA slots, ...) inherit the conversation tag without - per-call-site plumbing. Callers outside any conversation (e.g. the - auxiliary client's import-time base tags) get the canonical two-tag set. + The ambient conversation context (lineage ROOT id published by the agent + loop) wins over the explicit ``session_id``, which remains a fallback for + callers outside any agent turn; with neither, the two-tag base set. """ tags = ["product=hermes-agent", hermes_client_tag()] - # Ambient context first: the agent loop publishes the lineage ROOT id - # (stable across context-compression rotation and delegate subagent - # trees), which is the better conversation key than a per-segment - # session_id passed explicitly. The explicit argument remains as a - # fallback for callers running outside any agent turn. effective = get_conversation_context() or session_id if effective: tags.append(conversation_tag(effective)) diff --git a/agent/reasoning_effort.py b/agent/reasoning_effort.py index b6d89b0599..5c7f232dc5 100644 --- a/agent/reasoning_effort.py +++ b/agent/reasoning_effort.py @@ -1,37 +1,26 @@ """Canonical reasoning-effort vocabulary and wire clamping. Hermes' internal effort ladder (``hermes_constants.VALID_REASONING_EFFORTS`` -plus the ``none`` disable level) is wider than what any single provider wire -accepts. Historically every transport and provider profile hand-rolled its own -translation map, and the class of bugs that produced was constant: a new -internal level (``ultra``) leaking to a wire that rejects it with HTTP 400 -(#89503, #70058), or an unknown level being dropped to a weak default so the -strongest ask resolved *weaker* than an explicit ``high`` — a ladder -inversion (#74295, #87279). - -This module is the single source of truth both kinds of code use instead: +plus ``none``) is wider than any single provider wire accepts. Hand-rolled +per-transport translation maps produced two recurring bugs: a new internal +level (``ultra``) leaking to a wire that 400s on it, and an unknown level +dropped to a weak default so the strongest ask resolved *weaker* than an +explicit ``high`` (ladder inversion). This module is the single source of +truth instead: - :data:`EFFORT_LADDER` — canonical low→high ordering. -- :func:`clamp_effort` — the one clamping policy: keep a supported level - verbatim, otherwise take the **nearest weaker** supported level (never - silently escalate cost above what was asked), and only when nothing weaker - exists take the weakest supported level (a provider whose minimum thinking - level is ``high`` serves ``high`` for a ``low`` ask — GLM-5.2's shape). -- Named wire-vocabulary constants for the common OpenAI-compatible surfaces, - so call sites declare *data* ("this route accepts these levels") rather - than logic. +- :func:`clamp_effort` — keep a supported level verbatim, else the **nearest + weaker** supported level (never silently escalate cost); only when nothing + weaker exists take the weakest supported level (GLM-5.2's floor is ``high``). +- Named wire-vocabulary constants so call sites declare *data*, not logic. Rules for call sites: - -1. **Wire shape stays local.** Whether a route wants ``extra_body.reasoning``, - a top-level ``reasoning_effort`` string, or a ``thinking`` toggle is the - caller's business. Only the *vocabulary math* lives here. -2. **Unset stays unset.** ``clamp_effort`` translates an explicit request; it - does not invent one. When the user expressed no effort, prefer omitting - the field so the server default applies. -3. **Never patch a predicate.** When a provider rejects a level, fix its - declared supported set (data), never add another vendor-name special case - at the call site. +1. Wire shape (``extra_body.reasoning`` vs top-level ``reasoning_effort`` vs + a ``thinking`` toggle) stays local; only the vocabulary math lives here. +2. Unset stays unset: ``clamp_effort`` translates an explicit request, never + invents one — omit the field so the server default applies. +3. Never patch a predicate: when a provider rejects a level, fix its declared + supported set (data), not the call site. """ from __future__ import annotations @@ -39,32 +28,26 @@ from __future__ import annotations import re from typing import Optional, Sequence -#: K3 slug detector — matches ``k3`` as a delimited token (``k3``, -#: ``k3-256k``, ``kimi-k3``, ``kimi-k3-cot``) without matching K2-era names -#: (``kimi-k2.6``). From #76427 by @ruizanthony. +#: Matches ``k3`` as a delimited token (``k3``, ``k3-256k``, ``kimi-k3-cot``) +#: without matching K2-era names (``kimi-k2.6``). _KIMI_K3_SLUG_RE = re.compile(r"(?:^|[^a-z0-9])k3(?:[^a-z0-9]|$)") -# Canonical low→high ordering used for nearest-level clamping. Superset of -# hermes_constants.VALID_REASONING_EFFORTS ("none" included so an explicit -# disable can be clamped too when a provider publishes it as a level). +# Canonical low→high ordering for nearest-level clamping. Includes "none" so an +# explicit disable can be clamped when a provider publishes it as a level. EFFORT_LADDER: tuple[str, ...] = ( "none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra", ) -# ``ultra`` is Hermes-internal ladder vocabulary (the Codex product tier); no -# provider wire accepts it verbatim anywhere. Every declared wire set below -# therefore stops at ``max`` — ``ultra`` always clamps down. +# ``ultra`` is Hermes-internal (the Codex product tier); no wire accepts it, so +# every declared set below stops at ``max`` and ``ultra`` always clamps down. -#: The widest OpenAI-compatible wire vocabulary (OpenRouter, Nous Portal): -#: exactly max|xhigh|high|medium|low|minimal|none. +#: Widest OpenAI-compatible wire vocabulary (OpenRouter, Nous Portal). OPENAI_COMPAT_WIRE_EFFORTS: tuple[str, ...] = ( "none", "minimal", "low", "medium", "high", "xhigh", "max", ) -#: OpenAI/Codex Responses backend — per-model vocabulary, live-verified -#: (Aug 2026): ``minimal`` is rejected by both generations (clamps to low); -#: ``max`` is gpt-5.6-only — gpt-5.5 rejects it with "Supported values are: -#: 'none', 'low', 'medium', 'high', 'xhigh'" (#68365's premise, confirmed). +#: OpenAI/Codex Responses, per model generation (live-verified): ``minimal`` +#: is rejected by both (clamps to low); ``max`` is gpt-5.6-only. CODEX_GPT56_EFFORTS: tuple[str, ...] = ( "none", "low", "medium", "high", "xhigh", "max", ) @@ -80,78 +63,64 @@ def codex_supported_efforts(model: Optional[str]) -> tuple[str, ...]: return CODEX_LEGACY_EFFORTS -#: Backward-compat alias (pre-#68365-verification name). -CODEX_RESPONSES_EFFORTS: tuple[str, ...] = CODEX_GPT56_EFFORTS - #: xAI Responses — Grok 4.6+ accepts xhigh; older Grok tops out at high. XAI_GROK46_EFFORTS: tuple[str, ...] = ("low", "medium", "high", "xhigh") XAI_LEGACY_EFFORTS: tuple[str, ...] = ("low", "medium", "high") -#: Actual Computer relays (SGLang/vLLM): none/low/medium/high/max. +#: Actual Computer relays (SGLang/vLLM). ACTUAL_RELAY_EFFORTS: tuple[str, ...] = ("none", "low", "medium", "high", "max") -#: Moonshot/Kimi K3: low/high/max (server default high). +#: Moonshot/Kimi K3 (server default high) vs K2-era models. KIMI_K3_EFFORTS: tuple[str, ...] = ("low", "high", "max") -#: Moonshot/Kimi K2-era models: low/medium/high. KIMI_K2_EFFORTS: tuple[str, ...] = ("low", "medium", "high") -#: OpenCode "Ox Alpha" stealth model (x-preview-f-free): thinking is always -#: on and the wire accepts exactly low/high/max — medium/none/xhigh 400 with -#: "This model always engages in thinking and cannot be disabled; please use -#: low, high, or max" (verified live 2026-08-21). xhigh rounds up to max. +#: OpenCode "Ox Alpha" (x-preview-f-free): thinking cannot be disabled and the +#: wire accepts exactly low/high/max (medium/none/xhigh 400); xhigh rounds up. OX_ALPHA_EFFORTS: tuple[str, ...] = ("low", "high", "max") OX_ALPHA_OVERRIDES: dict[str, str] = {"xhigh": "max"} -#: Tencent TokenHub: low/medium/high. +#: Tencent TokenHub. TOKENHUB_EFFORTS: tuple[str, ...] = ("low", "medium", "high") -#: Nebius Token Factory: low/medium/high (top-level reasoning_effort knob). +#: Nebius Token Factory (top-level reasoning_effort knob). NEBIUS_EFFORTS: tuple[str, ...] = ("low", "medium", "high") -#: Kimi K3's vendor-documented translation quirks (platform.kimi.ai -#: thinking-model guide): ``high`` is K3's positional middle AND server -#: default, so ``medium`` rounds to it rather than down to ``low``; ``xhigh`` -#: rounds up to ``max`` (K3's top tier), matching the kimi-coding plugin. +#: Kimi K3 vendor-documented quirks: ``high`` is K3's positional middle AND +#: server default, so ``medium`` rounds to it rather than down to ``low``; +#: ``xhigh`` rounds up to ``max`` (K3's top tier). KIMI_K3_OVERRIDES: dict[str, str] = {"medium": "high", "xhigh": "max"} -#: GLM-5.2 native reasoning_effort knob: exactly two enabled levels, -#: ``high`` (its minimum thinking level) and ``max`` (per Z.AI/BigModel -#: docs). ``xhigh`` requests the top tier, not the floor. +#: GLM-5.2 native knob: exactly ``high`` (its minimum thinking level) and +#: ``max``; ``xhigh`` requests the top tier, not the floor. GLM52_EFFORTS: tuple[str, ...] = ("high", "max") GLM52_OVERRIDES: dict[str, str] = {"xhigh": "max"} -#: GLM-5.3 widens the knob to a graded low/medium/high/max scale — verified -#: live on api.z.ai/api/coding/paas/v4 (issue #91789, 2026-08-21): every -#: level accepted with monotonic reasoning-token scaling (low=4, medium=11, -#: high=98, max=125 on the probe prompt). ``xhigh`` requests the top tier. +#: GLM-5.3 widens the knob to a graded scale (live-verified, monotonic +#: reasoning-token scaling); ``xhigh`` requests the top tier. GLM53_EFFORTS: tuple[str, ...] = ("low", "medium", "high", "max") GLM53_OVERRIDES: dict[str, str] = {"xhigh": "max"} -#: DeepSeek V4 OpenAI-compat endpoint: low/medium/high/max; ``xhigh`` -#: requests the top tier (matches the shipped profile mapping). +#: DeepSeek V4 OpenAI-compat endpoint; ``xhigh`` requests the top tier. DEEPSEEK_V4_EFFORTS: tuple[str, ...] = ("low", "medium", "high", "max") DEEPSEEK_V4_OVERRIDES: dict[str, str] = {"xhigh": "max"} -#: Ollama Cloud /v1/chat/completions: accepts {none, low, medium, high, max}; -#: rejects ``minimal`` with HTTP 400. ``xhigh`` requests the top tier. +#: Ollama Cloud /v1/chat/completions: rejects ``minimal`` with HTTP 400. OLLAMA_CLOUD_EFFORTS: tuple[str, ...] = ("none", "low", "medium", "high", "max") OLLAMA_CLOUD_OVERRIDES: dict[str, str] = {"xhigh": "max"} -#: Meta Model API (Muse): minimal..xhigh; rejects ``none``. +#: Meta Model API (Muse): rejects ``none``. META_AI_EFFORTS: tuple[str, ...] = ("minimal", "low", "medium", "high", "xhigh") -#: Upstage Solar Pro/Open: low/medium/high. +#: Upstage Solar Pro/Open. SOLAR_EFFORTS: tuple[str, ...] = ("low", "medium", "high") def kimi_supported_efforts(model: Optional[str]) -> tuple[str, ...]: - """Supported effort set for a Moonshot/Kimi model slug. + """Supported effort set for a Moonshot/Kimi slug. - K3 is served as the bare slug ``k3``, plan variants like ``k3-256k``, - and the ``kimi-k3*`` aliases; its documented set is low/high/max. - Everything earlier speaks low/medium/high. Boundary-matched so K2-era - names (``kimi-k2.6``) never match (detection regex from #76427 by - @ruizanthony). + K3 is served as bare ``k3``, plan variants (``k3-256k``) and ``kimi-k3*`` + aliases; everything earlier speaks low/medium/high. Boundary-matched so + K2-era names (``kimi-k2.6``) never match. """ m = (model or "").strip().lower().split("/")[-1] if _KIMI_K3_SLUG_RE.search(m): @@ -166,22 +135,14 @@ def clamp_effort( ) -> Optional[str]: """Clamp a requested reasoning effort onto a wire's supported levels. - ``overrides`` is an optional declared mapping consulted first, for routes - whose vendor documents a translation that differs from nearest-weaker - (Kimi K3 documents ``medium → high``: high is its positional middle and - server default). Overrides are data, not logic — a call site never adds - vendor ``if``\\ s around this function. - - Otherwise: returns the requested effort unchanged when it is supported, - when the supported set is unknown (``None``/empty), or when the effort - isn't a recognized ladder level (custom providers may use bespoke names — - pass through rather than guess). Otherwise returns the **nearest weaker** - supported level, so a clamp never silently escalates cost; when nothing - weaker exists, the weakest supported level is returned (the caller asked - for *some* thinking and the provider's floor is the closest honest match). - - The policy is monotonic: a stronger request never resolves to a weaker - wire level than a weaker request would. + ``overrides`` (a declared vendor mapping, e.g. Kimi K3 ``medium → high``) + is consulted first. Otherwise the request passes through unchanged when it + is supported, when the supported set is unknown/empty, or when it isn't a + recognized ladder level (custom providers may use bespoke names). Else the + **nearest weaker** supported level is returned so a clamp never escalates + cost; when nothing weaker exists, the weakest supported level is (the + provider's floor is the closest honest match). Monotonic: a stronger + request never resolves weaker than a weaker request would. """ requested = str(effort or "").strip().lower() if not requested or not supported: @@ -199,9 +160,8 @@ def clamp_effort( return mapped if requested not in EFFORT_LADDER: return effort - # "none" disables reasoning — it is never a *degradation target* for an - # enabled ask (clamping "minimal" to "none" would silently switch - # thinking off). It still passes through verbatim when requested. + # "none" disables reasoning — never a degradation target for an enabled + # ask (clamping "minimal" to "none" would silently switch thinking off). candidates = [level for level in supported_norm if level != "none"] if not candidates: return effort @@ -218,9 +178,8 @@ def clamp_effort( def requested_effort(reasoning_config: Optional[dict]) -> Optional[str]: """Extract the user's explicit effort from a reasoning config, or None. - Returns ``None`` when the config is absent, malformed, carries no effort, - or reasoning is explicitly disabled — callers should then omit the wire - field entirely so the server default applies (rule 2 above). + None when the config is absent/malformed, carries no effort, or reasoning + is explicitly disabled — callers then omit the wire field (rule 2 above). """ if not isinstance(reasoning_config, dict): return None diff --git a/agent/reasoning_summaries.py b/agent/reasoning_summaries.py index 2dd1144fce..eeeaae1289 100644 --- a/agent/reasoning_summaries.py +++ b/agent/reasoning_summaries.py @@ -1,32 +1,13 @@ """Boundary repair for providers that stream reasoning as discrete summary parts. -Reasoning-summary models (OpenAI's gpt-5.x family, and anything relaying the -Responses API onto the OpenAI chat wire) do not stream a chain of thought token -by token. They emit one ``reasoning_content`` delta per *completed* summary -part, each opening with a bold markdown heading:: - - {"delta": {"reasoning_content": "**Investigating likely culprit PRs**"}} - {"delta": {"reasoning_content": "**Inspecting message schema**"}} - -On the Responses API those parts are delimited by ``summary_index`` -(``response.reasoning_summary_part.added`` / ``.done``). The OpenAI chat wire -carries no such field — verified live against Nous Portal's -``openai/gpt-5.6-sol``, whose reasoning chunks contain nothing but -``delta.reasoning_content`` — so the boundary cannot be recovered from -metadata, and consumers that concatenate deltas glue the parts together: - - **Investigating likely culprit PRs****Inspecting message schema** - -That ``****`` run is neither a bold close nor a bold open to a markdown parser, -so the whole trace renders as one unbroken, unspaced, half-bold paragraph. - -The AI SDK hit exactly this (vercel/ai#6742) and fixed it upstream by starting -a new reasoning part per ``summary_index``. That route needs the index, which -this wire does not give us, so we re-derive the boundary from the one signal it -does carry: a delta opening a bold heading. Hermes' own Responses adapter -already joins its summary parts with a blank line -(``agent/codex_responses_adapter.py``), so this brings the chat-completions -stream in line with the path that keeps the structure. +Reasoning-summary models (OpenAI gpt-5.x and anything relaying the Responses +API onto the OpenAI chat wire) emit one ``reasoning_content`` delta per +*completed* summary part, each opening with a bold heading. The Responses API +delimits parts by ``summary_index``; the chat wire carries no such field +(verified live on Nous Portal ``openai/gpt-5.6-sol``), so concatenating deltas +glues ``**One****Two**`` into one half-bold paragraph. We re-derive the +boundary from the one signal the wire keeps — a delta opening a bold heading — +matching the blank-line join Hermes' own Responses adapter already does. """ from __future__ import annotations @@ -37,31 +18,20 @@ __all__ = ["separate_glued_reasoning_blocks"] def separate_glued_reasoning_blocks(previous: str, delta: str) -> str: """Return *delta*, prefixed with a paragraph break when it glues onto *previous*. - *previous* is the reasoning text accumulated so far (only its tail matters). - A break is inserted when *delta* opens a bold heading and *previous* is - mid-line, which is the summary-part boundary the chat wire drops. Both - shapes the upstream issue reports are covered: a heading-only part butting - against the next heading (``**One****Two**``), and a part whose prose body - butts against the next heading (``...interaction!**Next**``). - - Token-streamed reasoning is left alone: its deltas carry their own leading - whitespace, so *previous* ends mid-line only when the model really did run - two parts together. + A break is inserted when *delta* opens a *closed* bold heading and + *previous* (the accumulated reasoning; only its tail matters) is mid-line. + Covers a heading butting a heading (``**One****Two**``) and prose butting a + heading (``...interaction!**Next**``). Token-streamed reasoning is left + alone: its deltas carry their own whitespace, and a fragment that merely + opens emphasis (``**`` alone) is not a part boundary — summary parts always + carry the whole heading in one delta. """ if not previous or not delta: return delta - if not delta.startswith("**"): return delta - - # Already separated — the provider (or an earlier part) ended the line. if previous[-1].isspace(): return delta - - # Require a *closed* heading. A token-streamed fragment that merely opens - # emphasis ("**" then "bold" then "**" across three deltas) is not a part - # boundary; a summary part always carries its whole heading in one delta. if "**" not in delta[2:]: return delta - return f"\n\n{delta}" diff --git a/agent/reasoning_timeouts.py b/agent/reasoning_timeouts.py index 4f6194903a..322b803878 100644 --- a/agent/reasoning_timeouts.py +++ b/agent/reasoning_timeouts.py @@ -1,53 +1,23 @@ -"""Per-reasoning-model stale-timeout floor for known reasoning models. +"""Per-model stale-timeout FLOOR for known reasoning models. -Reasoning models (those that emit extended thinking blocks before their -first content token) routinely exceed Hermes's default chat-model -stale detectors: +Reasoning models (extended thinking before the first content token) routinely +exceed the default chat-model stale detectors (stream ``HERMES_STREAM_STALE_TIMEOUT`` +180s, non-stream ``HERMES_API_CALL_STALE_TIMEOUT`` 90s): upstream proxies / +load-balancers idle-kill the stream mid-think, surfacing as +``BrokenPipeError``/``RemoteProtocolError`` on the next read. The existing +stale-detector scaling consults :func:`get_reasoning_stale_timeout_floor` and +applies ``max(default, floor)``. Being a floor it: -* Stream stale detector: ``HERMES_STREAM_STALE_TIMEOUT`` default 180s - ``agent/chat_completion_helpers.py:2544`` -* Non-stream stale detector: ``HERMES_API_CALL_STALE_TIMEOUT`` default 90s - ``run_agent.py:1140`` +* never overrides explicit user config (``providers..models.. + stale_timeout_seconds`` / ``request_timeout_seconds`` win — this never runs + in that branch); +* never lowers an existing threshold; +* has zero effect on non-allowlisted models (resolver returns ``None``). -For NVIDIA Nemotron 3 Ultra on the hosted NIM gateway the empirical -upstream idle kill is ~120s (first-party reproduction at -NVIDIA/NemoClaw#4846 — TTFB ~31s, stream dies at 120s). The same -failure mode exists on OpenAI o1/o3, Anthropic Opus 4.x thinking, -DeepSeek R1, Qwen QwQ, xAI Grok reasoning — every cloud reasoning -model hits upstream-proxies / load-balancers with idle timeouts -shorter than the model's thinking phase. Result: the stale detector -kills the connection mid-think, surfacing as -``BrokenPipeError``/``RemoteProtocolError`` on the next read. - -This module provides a floor that the existing stale-detector scaling -blocks consult via :func:`get_reasoning_stale_timeout_floor` and -apply as ``max(default, floor)``. It is a FLOOR: - -* Never overrides explicit user config (``providers..models..stale_timeout_seconds`` - or ``request_timeout_seconds`` already wins — this code never runs - in that branch). -* Never lowers an existing threshold. -* Has zero effect on non-reasoning models — they are not in the - allowlist and the resolver returns ``None``. - -Matching uses start-anchored regex on the slug-only component of -the model name (after stripping any aggregator prefix like -``openai/``, ``x-ai/``, ``anthropic/``). The right-anchor matches -end-of-string or a ``-``/``.``/``_`` slug separator, so ``qwen3-235b`` -matches the ``qwen3`` family entry (a future model slug would be -``qwen3-235b-instruct`` and would also match) but ``some-other-qwen3`` -does NOT match ``qwen3`` (the ``-qwen3`` is not at start of slug). - -The ``o1`` case is the most delicate: a model named -``llama-4-70b-o1-preview`` is a hypothetical community derivative that -should NOT trigger the reasoning-model floor for the user (the user -chose a non-OpenAI model, not a reasoning model). The start-of-slug -anchor naturally excludes this — the matched ``o1-preview`` is at -position 11 of the slug, not at position 0. The previous substring- -with-trailing-hyphen design would have over-matched here, which is -why start-of-slug anchoring is the right shape. - -Fixes #52217. +Matching is start-anchored on the slug after any aggregator prefix +(``openai/``, ``x-ai/``) with an end-or-separator right anchor, so +``qwen3-235b`` matches ``qwen3`` but ``some-other-qwen3`` and a hypothetical +``llama-4-70b-o1-preview`` do not trigger the ``o1`` floor. """ from __future__ import annotations @@ -56,41 +26,25 @@ import re from typing import Optional -# (slug, floor_seconds). Each slug is matched as a discrete -# word-boundary component via the wrapper regex in ``_match_any`` -# below. Order is irrelevant — the first regex match wins. +# (slug, floor_seconds). Order irrelevant — longest slug wins at match time. _REASONING_STALE_TIMEOUT_FLOORS: tuple[tuple[str, int], ...] = ( - # NVIDIA Nemotron — reasoning models behind hosted NIM with - # documented 60-180s upstream idle kill (NVIDIA/NemoClaw#4846: - # 120s measured). + # NVIDIA Nemotron behind hosted NIM: documented 60-180s upstream idle kill. ("nemotron-3-ultra", 600), ("nemotron-3-super", 600), ("nemotron-3-nano", 300), ("nemotron-3.5-lightning", 300), - # DeepSeek — R1 and V4 reasoning models on hosted NIM / DeepSeek direct. - # V4 series emits reasoning_content in a separate delta field before - # final content, requiring the same extended stale timeout floor. + # DeepSeek R1 / V4 (reasoning_content streamed before final content). ("deepseek-r1", 600), ("deepseek-reasoner", 600), ("deepseek-v4-flash", 600), ("deepseek-v4-pro", 600), - # Qwen — QwQ reasoning + Qwen3 thinking variants. QwQ-32B - # preview is the stable slug; ``qwen3`` covers the family of - # thinking-mode Qwen3 models (qwen3-235b-a22b, qwen3-32b, etc.) - # without over-matching every Qwen3 instruct variant — the - # right-anchor requires the slug to be at the start of the - # remaining model name, so ``qwen3-235b-instruct`` (instruct is - # NOT a thinking variant) would still match. Acceptable - # trade-off: instruct variants of qwen3 get the 180s floor - # even though they don't reason. The cost is a slightly longer - # wait on a hung provider; the alternative (matching only - # ``qwen3-.*-thinking``) breaks the moment NVIDIA or Alibaba - # ships a slightly different naming shape. + # Qwen QwQ + the qwen3 family. Instruct variants also match ``qwen3`` — + # accepted: a slightly longer wait on a hung provider beats a pattern + # (``qwen3-.*-thinking``) that breaks on the next naming shape. ("qwq-32b", 300), ("qwen3", 180), - # OpenAI o-series — known multi-minute TTFB. Each variant - # enumerated explicitly so bare ``o1`` doesn't over-match - # ``olmo-1`` or hypothetical future community derivatives. + # OpenAI o-series: each variant enumerated so bare ``o1`` cannot + # over-match ``olmo-1`` or community derivatives. ("o1", 600), ("o1-mini", 600), ("o1-pro", 600), @@ -99,80 +53,36 @@ _REASONING_STALE_TIMEOUT_FLOORS: tuple[tuple[str, int], ...] = ( ("o3-pro", 600), ("o3-mini", 300), ("o4-mini", 300), - # Anthropic Claude 4.x thinking variants. Anchored at - # ``claude-opus-4`` so non-thinking Claude 3.x or future - # non-reasoning Claude variants don't match. + # Anthropic Claude 4.x+ thinking variants (anchored so 3.x never matches). ("claude-opus-4", 240), ("claude-opus-5", 240), ("claude-sonnet-5", 180), ("claude-sonnet-4.5", 180), ("claude-sonnet-4.6", 180), - # Anthropic Mythos-class named reasoning models (claude-fable-5, …). - # 1M context + 128K output — heavier thinking phase than the - # numbered Claude line, so the floor is in the deep-reasoning tier - # alongside o1 / deepseek-r1 / nemotron-3-ultra. Without this - # entry the stale-stream detector kills fable-5's thinking phase - # at the default 180s (300s with context scaling), tripping the - # cross-turn circuit breaker after 5 consecutive stale kills. + # Mythos-class named models (claude-fable-5): 1M ctx + 128K output, a + # heavier thinking phase than the numbered line — deep-reasoning tier, + # otherwise the stale detector trips the cross-turn circuit breaker. ("claude-fable", 600), - # xAI Grok reasoning variants. Explicit reasoning-only keys - # plus one for the ``non-reasoning`` variant so users picking - # the fast variant don't get the 300s floor. Bare ``grok-3``, - # ``grok-4`` etc. don't match — only the explicit reasoning / - # non-reasoning pairs. + # xAI Grok: explicit reasoning / non-reasoning pairs only, so bare + # ``grok-3``/``grok-4`` fast variants don't inherit the 300s floor. ("grok-4-fast-reasoning", 300), ("grok-4.20-reasoning", 300), ("grok-4.5", 300), ("grok-4.6", 300), ("grok-4-fast-non-reasoning", 180), - # "Ox Alpha" stealth reasoning model (stealth/ox-alpha on OpenRouter, - # x-preview-f-free on OpenCode Zen). Marketed as a reasoning model for - # long-horizon coding/agentic work; 1M context — same tier as the Grok - # reasoning variants. + # "Ox Alpha" stealth reasoning model (OpenRouter / OpenCode Zen slugs). ("ox-alpha", 300), ("x-preview-f-free", 300), - # Thinking Machines Inkling (thinkingmachines/inkling[-small][:free] - # on OpenRouter). Reasoning model (OpenRouter supported_parameters - # includes "reasoning"); 1M context — same tier as the Grok - # reasoning variants and Ox Alpha. "inkling" left-anchors on the - # slug after the aggregator prefix and the right anchor accepts the - # "-" separator, so inkling-small and the :free SKUs all match. + # Thinking Machines Inkling; covers inkling-small and :free SKUs. ("inkling", 300), ) -# Pre-compile each pattern. Wrapper = start-of-slug + slug + end-or- -# separator, where ``start-of-slug`` means start-of-string OR -# immediately after the last ``/`` (aggregator separator) and -# ``end-or-separator`` means end-of-string OR a ``-``/``.``/``_``. -# -# Why start-of-slug and not start-of-string: aggregator prefixes -# like ``openai/`` should not affect matching — the slug identity is -# the part after the last ``/``. Stripping the aggregator prefix in -# :func:`get_reasoning_stale_timeout_floor` before regex matching -# gives the wrapper a clean start-of-string anchor. -# -# Why end-or-separator on the right: ``openai/o3-mini`` must match -# the ``o3-mini`` slug (the right anchor is end-of-string). And -# ``openai/o3-mini-2025-01-31`` must also match ``o3-mini`` (the right -# anchor is the ``-`` separator). But ``openai/o3-mini-fork`` should -# NOT match ``o3-mini`` if we wanted to exclude forks — though the -# pattern ``o3-mini-fork`` would be matched as a derivative anyway, -# so we accept that community forks inheriting the same prefix are -# treated as reasoning models (a reasonable default — the upstream -# gateway timing is the same). -# Pre-compile all patterns at module load time to avoid per-call regex -# compilation and thread-safety issues with the mutable _PATTERN_CACHE. -# The list is built once at import and never mutated afterwards, so it is -# safe for free-threaded Python 3.13+ without any locking. The slug is kept -# in each entry for debuggability (log/inspection), even though _match_any -# only consumes floor + pattern. +# Pre-compiled once at import (immutable afterwards — safe under free-threaded +# Python). Right anchor: end-of-string or a slug separator; ``:`` is included +# because OpenRouter routing suffixes (``:free``, ``:nitro``) attach directly +# to the slug. Sorted longest-first so ``o3-mini`` beats ``o3``. _SORTED_REASONING_FLOORS: list[tuple[str, float, re.Pattern[str]]] = [ - # Right anchor: end-of-string or a slug separator. ``:`` is in the - # separator class because OpenRouter SKU/routing suffixes - # (``:free``, ``:batch``, ``:nitro``, ``:floor``) attach directly to - # the slug — ``thinkingmachines/inkling:free`` must match the - # ``inkling`` entry the same way ``inkling-small`` does. (slug, floor, re.compile(r"^" + re.escape(slug) + r"(?:$|[\-._:])")) for slug, floor in sorted( _REASONING_STALE_TIMEOUT_FLOORS, key=lambda kv: -len(kv[0]) @@ -180,38 +90,14 @@ _SORTED_REASONING_FLOORS: list[tuple[str, float, re.Pattern[str]]] = [ ] -def _match_any(model_lower: str) -> Optional[float]: - """Return the floor for the first matching slug, else None. - - Each table entry is matched as a start-of-slug prefix with the - slug-separator-or-end-of-string right-anchor. Table iteration - order is irrelevant: longest slug wins (so ``o3-mini`` beats - ``o3`` on a model like ``openai/o3-mini``). - """ - for _slug, floor, pattern in _SORTED_REASONING_FLOORS: - if pattern.search(model_lower): - return float(floor) - return None - - def get_reasoning_stale_timeout_floor(model: object) -> Optional[float]: """Return the stale-timeout floor (seconds) for a known reasoning model. - Returns ``None`` when the model is not in the allowlist or the - argument is empty / not a string. Matching uses - word-boundary-anchored regex on the lowercased model name, so - ``openai/o3-mini`` matches the ``o3-mini`` slug but - ``olmo-1`` does NOT match ``o1`` (the ``o1`` substring is not - at a word boundary inside ``olmo-1``). - - Aggregator prefixes (``openai/``, ``x-ai/``, ``anthropic/`` etc.) - are preserved through matching — the ``/`` is itself a word - boundary, so ``openai/o3-mini`` matches ``o3-mini`` because the - ``/`` before ``o3-mini`` satisfies the left-anchor alternation. - - This is a FLOOR — callers must apply it as ``max(default, floor)`` - and only when no explicit user-configured per-model - ``stale_timeout_seconds`` exists. + ``None`` when the model is not allowlisted or the argument is empty / not + a string. The aggregator prefix (everything up to the last ``/``) is + stripped so the slug is matched start-anchored. Callers apply this as + ``max(default, floor)`` and only when no explicit per-model + ``stale_timeout_seconds`` is configured. >>> get_reasoning_stale_timeout_floor("nvidia/nemotron-3-ultra-550b-a55b") 600.0 @@ -243,9 +129,9 @@ def get_reasoning_stale_timeout_floor(model: object) -> Optional[float]: name = model.strip().lower() if not name: return None - # Strip aggregator prefix (everything before and including the - # last ``/``). The wrapper regex anchors at start-of-string, so - # the slug identity is the bare model name. if "/" in name: name = name.rsplit("/", 1)[1] - return _match_any(name) + for _slug, floor, pattern in _SORTED_REASONING_FLOORS: + if pattern.search(name): + return float(floor) + return None diff --git a/agent/subscription_view.py b/agent/subscription_view.py index c2c55e0ee2..4a62017a29 100644 --- a/agent/subscription_view.py +++ b/agent/subscription_view.py @@ -1,19 +1,13 @@ """Surface-agnostic core for the ``/subscription`` TUI screen. -Companion to :mod:`agent.billing_view` — same fail-open philosophy: 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 is decimal end-to-end -(server emits decimal strings); we only format for display. +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 (V3): it -previews the effect, then schedules a downgrade / cancellation / resume -(chargeless) or applies an upgrade (charges the card on the subscription). The -portal deep-link (built locally from ``portal_url`` + ``org_id``) remains the -fallback for an upgrade that needs 3DS / was declined. - -WS1 dependency: ``GET /api/billing/subscription`` is a NAS endpoint (WS1 Phase A). -Until it ships, the fail-open contract handles 404s — the builder returns -``logged_in=False`` and the surface degrades gracefully. +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 @@ -24,24 +18,21 @@ from dataclasses import dataclass from decimal import Decimal from typing import Any, Optional -from agent.billing_view import parse_money +from agent.billing_view import OrgRoleCapability, fetch_portal_state, format_money, parse_money, parse_org_fields logger = logging.getLogger(__name__) -# ============================================================================= -# Parsed sub-structures -# ============================================================================= +# ── Parsed sub-structures ──────────────────────────────────────────────────── @dataclass(frozen=True) class CurrentSubscription: """The user's active subscription. ``None`` (not this object) = no plan. - When present, ``tier_id`` / ``tier_name`` / ``monthly_credits`` / - ``cycle_ends_at`` are always set (NAS guarantees a present ``current`` is a - fully-populated plan). Only ``credits_remaining`` and the cancel/downgrade - fields are optional. + 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 @@ -57,12 +48,11 @@ class CurrentSubscription: @dataclass(frozen=True) class SubscriptionTier: - """A selectable plan in the catalog — one row of the in-terminal tier picker. + """One row of the tier picker (mirrors NAS ``SubscriptionTierOption``). - Mirrors NAS's ``SubscriptionTierOption``. ``is_current`` marks the active plan - (shown but not selectable); ``is_enabled=False`` is a grandfathered tier the - user is on but that can no longer be selected. ``tier_order`` sorts the picker - and drives the upgrade-vs-downgrade direction hint. + ``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 @@ -76,13 +66,11 @@ class SubscriptionTier: @dataclass(frozen=True) class SubscriptionChangePreview: - """Parsed ``POST /api/billing/subscription/preview`` — what a change would do. + """Parsed ``POST /api/billing/subscription/preview``. - ``effect`` is the disposition the commit would take: - - ``charge_now`` → an upgrade; ``amount_due_now_cents`` is the prorated charge. - - ``scheduled`` → a downgrade / same-price change at ``effective_at`` (period end). - - ``no_op`` → already on the target tier. - - ``blocked`` → the commit would be refused; ``reason`` says why. + ``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 @@ -97,12 +85,9 @@ class SubscriptionChangePreview: @dataclass(frozen=True) -class SubscriptionState: - """Parsed ``GET /api/billing/subscription`` — the overview screen's data. - - Fail-open: ``logged_in=False`` (and empty fields) when not logged in or the - portal is unreachable. - """ +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 @@ -113,35 +98,15 @@ class SubscriptionState: current: Optional[CurrentSubscription] = None tiers: tuple[SubscriptionTier, ...] = () # selectable catalog (picker) 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: - """Deprecated/display only — a legacy OWNER/ADMIN check. - - NOT a capability check; use :attr:`can_change_plan` for gating billing - 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 + error: Optional[str] = None # set when the fetch failed (vs cleanly not-logged-in) -# ============================================================================= -# Payload parsing -# ============================================================================= +# ── Payload parsing ────────────────────────────────────────────────────────── def _parse_current(raw: Any) -> Optional[CurrentSubscription]: - # "No plan" is wire-represented as current:null (free personal OR team) — - # the old all-null-object shape is gone. A present current is a real plan, - # so guard on a real tier id and return None otherwise. + # "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") @@ -161,11 +126,8 @@ def _parse_current(raw: Any) -> Optional[CurrentSubscription]: def _coalesce(*vals: Any) -> Any: - """First non-``None`` value (preserves a legit ``0``/``0.0``, unlike ``or``). - - NAS sends ``0`` for the free tier's ``tierOrder`` / ``dollarsPerMonth``; a plain - ``x or default`` would drop those, so coalesce on ``None`` specifically. - """ + """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 @@ -197,8 +159,7 @@ def subscription_change_preview_from_payload( effect = payload.get("effect") cents = payload.get("amountDueNowCents") return SubscriptionChangePreview( - # An unrecognized/missing effect is treated as ``blocked`` — fail safe, never - # charge on a malformed quote. + # 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"), @@ -215,89 +176,49 @@ 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`.""" - raw_org = payload.get("org") - org: dict[str, Any] = raw_org if isinstance(raw_org, dict) else {} - + org, can_change_plan_raw = parse_org_fields(payload) raw_context = payload.get("context") - context = raw_context if raw_context in ("personal", "team") else "personal" - 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=( - payload.get("canChangePlan") - if isinstance(payload.get("canChangePlan"), bool) - else None - ), - context=context, + 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) -# ============================================================================= +# ── Fail-open builders (the surface front doors) ───────────────────────────── def build_subscription_state(*, timeout: float = 15.0) -> SubscriptionState: - """Fetch + parse ``GET /api/billing/subscription``. Fail-open. + """Fetch + parse ``GET /api/billing/subscription``. Fail-open (see + :func:`agent.billing_view.fetch_portal_state`). - Returns ``SubscriptionState(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. - - Dev override: when ``HERMES_DEV_SUBSCRIPTION_FIXTURE`` names a fixture state, - ``/subscription`` renders from that fixture instead of the real portal — so - every plan/cancel/downgrade/team/not-admin state is testable on both - the CLI and TUI without a live account. Throwaway scaffolding; see - :func:`dev_fixture_subscription_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 - - try: - from hermes_cli.nous_billing import ( - BillingAuthError, - BillingError, - _absolutize_portal_url, - get_subscription_state, - resolve_portal_base_url, - ) - except Exception: - return SubscriptionState(logged_in=False, error="billing client unavailable") - - try: - payload = get_subscription_state(timeout=timeout) - except BillingAuthError: - return SubscriptionState(logged_in=False) - except BillingError as exc: - logger.debug("subscription ▸ /state fetch failed (fail-open)", exc_info=True) - return SubscriptionState(logged_in=False, error=str(exc)) - except Exception: - logger.debug("subscription ▸ /state unexpected error (fail-open)", exc_info=True) - return SubscriptionState(logged_in=False, error="could not load subscription state") - - 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 = resolve_portal_base_url() - except Exception: - portal_url = None - - return subscription_state_from_payload(payload, portal_url=portal_url) + 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( @@ -305,33 +226,24 @@ def subscription_manage_url( ) -> Optional[str]: """Build ``{portal_origin}/manage-subscription?org_id=[&plan=]``. - Mirrors the TUI's ``buildManageUrl`` (``subscription.ts``): the deep-link - target is NAS's OWN ``/manage-subscription`` page (NOT the Stripe Billing - Portal — decided Jun 23), which routes upgrade→Checkout / downgrade→scheduled - internally. ``org_id`` pins the page to the right account in multi-org - situations. Returns ``None`` when no portal URL is resolvable. - - ``tier_id`` (the stable ``tiers[]`` id, never a name/slug) is appended as - ``plan=`` so the portal preselects the picked plan — only for a NEW - subscription / upgrade the user chose. The portal validates it and simply - ignores an unknown tier, so the CLI appends unconditionally when a tier was - picked (parity with the TUI's ``?plan=``). + 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 urlencode, urlsplit, urlunsplit + 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 - from urllib.parse import parse_qsl - # 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)) @@ -341,68 +253,34 @@ def subscription_manage_url( params["org_id"] = state.org_id if tier_id: params["plan"] = tier_id - query = urlencode(params) - return urlunsplit((parts.scheme, parts.netloc, "/manage-subscription", query, "")) + return urlunsplit((parts.scheme, parts.netloc, "/manage-subscription", urlencode(params), "")) -# ============================================================================= -# Shared plan-catalog helpers (consumed by the CLI Free catalog + paid picker) -# ============================================================================= - - -def _format_dollars_grouped(value: Optional[Decimal]) -> str: - """``$1,000`` / ``$1,234.50`` — the whole-vs-fractional rule of - ``billing_view.format_money`` but thousands-grouped, matching the TUI's - ``toLocaleString('en-US')``. - - The shared ``format_money`` is intentionally ungrouped (and asserted so across - other surfaces), so plan-catalog rows group locally to mirror the TUI. - """ - if value is None: - return "—" - if value == value.to_integral_value(): - return f"${format(value.to_integral_value(), ',f')}" - return f"${format(value.quantize(Decimal('0.01')), ',f')}" +# ── 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. - - One derivation shared by the CLI Free catalog and the paid change picker: - ``is_enabled and not is_current and tier_order > 0`` (free / no-sub excluded — - dropping to free is a cancellation), sorted by ``tier_order``. - """ + """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 - ), + (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]`` — the shared plan-catalog row. - - Mirrors the TUI Free rows (``subscriptionOverlay.tsx``): thousands-grouped - money, and the ``$Y credits/mo`` suffix ONLY when monthly credits are present - and > 0 (a ``None`` / zero-credits tier hides it — never ``· — credits/mo`` or - ``· $0 credits/mo``). - """ - row = f"{tier.name} · {_format_dollars_grouped(tier.dollars_per_month)}/mo" + """``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_dollars_grouped(mc)} credits/mo" + 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 (what the picker derives from), else 0 (free). - """ + """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: @@ -412,96 +290,63 @@ def is_upgrade(state: SubscriptionState, tier_id: str) -> bool: return orders.get(tier_id, 0) > cur_order -# ============================================================================= -# Dev fixtures (throwaway scaffolding — env-var driven, no live portal) -# ============================================================================= +# ── 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", + tier_id="plus", tier_name="Plus", monthly_credits=Decimal("1000"), + credits_remaining=Decimal("420"), cycle_ends_at="2026-07-01", ) - base.update(over) - return CurrentSubscription(**base) + 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"), - ) + 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, + 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]: - """Return a fixture :class:`SubscriptionState` for ``HERMES_DEV_SUBSCRIPTION_FIXTURE``. + """``HERMES_DEV_SUBSCRIPTION_FIXTURE`` -> fixture :class:`SubscriptionState`; None when unset. - Lets every CLI/TUI subscription state be exercised without a live portal: - - free | mid | top | not-admin | downgrade | cancel | team | - logged-out - - Returns ``None`` when the env var is unset/empty (the real portal path runs). - Throwaway scaffolding — mirrors ``HERMES_DEV_CREDITS_FIXTURE``. + ``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 - - common = dict(org_name="Acme Inc", org_id="org_acme", role="OWNER", portal_url=_DEV_FIXTURE_PORTAL) - - if name in ("logged-out", "logged_out", "loggedout"): + 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) - if name == "free": - return SubscriptionState(logged_in=True, current=None, tiers=_dev_tiers(None), **common) - if name in ("mid", "mid-tier"): - return SubscriptionState(logged_in=True, current=_dev_current(), tiers=_dev_tiers("plus"), **common) - if name in ("top", "top-tier"): - return SubscriptionState( - logged_in=True, + + 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"), - **common, - ) - if name in ("not-admin", "member"): - return SubscriptionState(logged_in=True, current=_dev_current(), tiers=_dev_tiers("plus"), **{**common, "role": "MEMBER"}) - if name == "downgrade": - return SubscriptionState( - logged_in=True, + ), + "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"), - **common, - ) - if name == "cancel": - return SubscriptionState( - logged_in=True, - current=_dev_current(cancel_at_period_end=True, cancellation_effective_at="2026-07-01"), - tiers=_dev_tiers("plus"), - **common, - ) - if name == "team": - return SubscriptionState(logged_in=True, context="team", current=None, org_name="Acme Engineering", org_id="org_eng", role="OWNER", portal_url=_DEV_FIXTURE_PORTAL) - - # Unknown name → behave as logged-out so the misconfiguration is visible. - return SubscriptionState(logged_in=False, error=f"unknown HERMES_DEV_SUBSCRIPTION_FIXTURE: {name}") - + ), + "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]}) diff --git a/agent/thinking_timeout_guidance.py b/agent/thinking_timeout_guidance.py index bd8a44cb71..0801014ff9 100644 --- a/agent/thinking_timeout_guidance.py +++ b/agent/thinking_timeout_guidance.py @@ -1,30 +1,11 @@ """Thinking-timeout detection and user-facing guidance for reasoning models. -When a known reasoning model (NVIDIA Nemotron 3 Ultra, OpenAI o1/o3, -Anthropic Opus 4.x thinking, DeepSeek R1, Qwen QwQ, xAI Grok reasoning) -hits a transport-layer error before the first content token arrives, the -upstream proxy has almost certainly idle-killed a long thinking stream — -not a true context overflow or a configuration error. The user needs -distinct guidance for this case: - - "The model's thinking phase exceeded the upstream proxy's idle - timeout before the first content token arrived. This is a known - issue with reasoning models behind cloud gateways (NVIDIA NIM, - OpenAI, Anthropic, DeepSeek). Workarounds in priority order: - 1. Set `providers..models..stale_timeout_seconds: 900` - in `~/.hermes/config.yaml` to extend the per-call timeout... - 2. Lower `reasoning_budget` or set `reasoning_effort: medium`... - 3. Use a smaller / faster reasoning model..." - -The existing `_is_stream_drop` guidance at -``agent/conversation_loop.py:3464-3486`` fires for large-file-write -stream drops ("try execute_code with Python's open() for large files") -which is the WRONG advice for the thinking-timeout case. This module -provides the detection and the message as standalone helpers so the -detection logic is unit-testable without driving the full retry loop, -and the message text can be regression-tested for spelling and accuracy. - -Part 2 of Fixes #52310. +When a known reasoning model hits a transport-layer error before the first +content token, the upstream proxy has almost certainly idle-killed a long +thinking stream — not a context overflow or configuration error. The generic +stream-drop guidance in conversation_loop ("use execute_code for large files") +is wrong for that case, so detection and message live here as standalone, +unit-testable helpers. """ from __future__ import annotations @@ -32,12 +13,9 @@ from __future__ import annotations from typing import Optional -# Substring set that identifies a transport-layer failure on the -# response stream. Same shape as the existing -# ``_SERVER_DISCONNECT_PATTERNS`` in ``agent/error_classifier.py:394`` -# but extended to also catch the OSS-level error signature -# (``broken pipe`` / ``errno 32``) that the upstream kill surfaces -# to the OpenAI SDK wrapper. +# Transport-layer failure signatures on the response stream — the classifier's +# server-disconnect set plus the OS-level ``broken pipe`` / ``errno 32`` the +# upstream kill surfaces through the OpenAI SDK wrapper. _THINKING_TIMEOUT_SUBSTRINGS: tuple[str, ...] = ( "broken pipe", "errno 32", @@ -50,53 +28,22 @@ _THINKING_TIMEOUT_SUBSTRINGS: tuple[str, ...] = ( def is_thinking_timeout(classified: object, model: str, error_msg: str) -> bool: - """Return True when a reasoning model's thinking phase hit a transport kill. + """True when a reasoning model's thinking phase hit a transport kill. - Args: - classified: a :class:`agent.error_classifier.ClassifiedError` instance - (duck-typed here to avoid an import cycle in unit tests). - model: the model slug at failure time (e.g. - ``"nvidia/nemotron-3-ultra-550b-a55b"``). - error_msg: lowercased string representation of the underlying - exception (typically ``str(api_error).lower()``). - - Returns True when ALL conditions hold: - 1. ``classified.reason == FailoverReason.timeout`` (the classifier - override at ``agent/error_classifier.py:720-738`` ensures this - is the case for reasoning models even on large sessions). - 2. ``api_error`` has no ``.status_code`` attribute set (transport - disconnect, not an HTTP error). - 3. ``model`` is in the reasoning-model allowlist (reuses - ``agent.reasoning_timeouts.get_reasoning_stale_timeout_floor``). - 4. ``error_msg`` contains one of the transport-kill substrings. - - Non-reasoning models always return False. Non-transport errors - (billing / rate_limit / auth / context_overflow / format_error) - always return False. HTTP-status errors always return False. + All must hold: ``classified.reason`` is the ``timeout`` FailoverReason + (duck-typed via ``.value`` to avoid importing error_classifier), ``model`` + is in the reasoning allowlist (``reasoning_timeouts``), and ``error_msg`` + carries a transport-kill substring. The caller gates on the error having no + HTTP ``status_code`` before calling. Non-reasoning models and non-transport + errors (billing / rate_limit / auth / context_overflow) return False. """ - # Import here (not at module top) to keep this helper cheap to - # import even from callers that don't need it. ``agent.reasoning_timeouts`` - # is small and dependency-free. from agent.reasoning_timeouts import get_reasoning_stale_timeout_floor - # Condition 1: classifier says timeout. Use a string/value check - # rather than importing FailoverReason so this module has zero - # import cycles from the error_classifier package. reason = getattr(classified, "reason", None) - reason_value = getattr(reason, "value", None) - if reason_value != "timeout": + if getattr(reason, "value", None) != "timeout": return False - - # Condition 2: no HTTP status code (transport, not API error). - # Caller is expected to gate on ``getattr(api_error, "status_code", None) is None`` - # before calling this helper; the surface here is just the post-gate - # boolean so the caller can pass an already-prepped error_msg. - - # Condition 3: reasoning model allowlist. if get_reasoning_stale_timeout_floor(model) is None: return False - - # Condition 4: transport-kill substring in the error message. error_msg_lower = (error_msg or "").lower() return any(p in error_msg_lower for p in _THINKING_TIMEOUT_SUBSTRINGS) @@ -104,18 +51,11 @@ def is_thinking_timeout(classified: object, model: str, error_msg: str) -> bool: def build_thinking_timeout_guidance( provider: str, model: str, model_label: Optional[str] = None, ) -> str: - """Return the user-facing guidance string appended to ``_final_response``. + """User-facing guidance appended to the final response. - Args: - provider: provider slug (e.g. ``"nvidia"``, ``"openai"``). - model: bare model slug the user would put in their config - (e.g. ``"nemotron-3-ultra-550b-a55b"`` if the user uses - NVIDIA direct, or the full ``"nvidia/nemotron-3-ultra-550b-a55b"`` - if they go through an aggregator). Used verbatim in the - config snippet so the user can copy-paste. - model_label: optional short label for the model name in the - prose (e.g. ``"Nemotron 3 Ultra"``). Falls back to the - slug if not provided. + ``model`` is used verbatim in the config snippet so it is copy-pasteable + (bare slug for direct providers, ``vendor/slug`` through aggregators); + ``model_label`` is the optional prose name, defaulting to the slug. """ label = model_label or model return ( diff --git a/agent/usage_pricing.py b/agent/usage_pricing.py index 9af08f6dc0..a56b7982a3 100644 --- a/agent/usage_pricing.py +++ b/agent/usage_pricing.py @@ -12,14 +12,11 @@ from utils import base_url_host_matches, base_url_hostname logger = logging.getLogger(__name__) -DEFAULT_PRICING = {"input": 0.0, "output": 0.0} - _ZERO = Decimal("0") _ONE_MILLION = Decimal("1000000") _NOUS_DEFAULT_BASE_URL = "https://inference-api.nousresearch.com/v1" -# Sub-cent cost threshold: below $0.01, render at 4 decimal places so -# the display is non-zero (e.g. $0.0046 instead of $0.00). See #79220. +# Below $0.01, render at 4 dp so cheap-model costs never display as $0.00. _SUBCENT_THRESHOLD = Decimal("0.01") # Attached to every CostResult with status="included" so consumers can @@ -28,33 +25,19 @@ _INCLUDED_NOTE = "subscription-included; no provider invoice for usage" def format_cost_label(amount: Decimal) -> str: - """Format a cost amount as a display label. + """Format a cost as a display label, scaling precision to magnitude. - Scales precision to magnitude: - - Zero → "$0.00" - - Sub-cent (< $0.01) → "~$0.0046" (4 dp; amounts that ROUND to - 0.0000 at 4 dp — i.e. at or below $0.00005 under banker's - rounding — fall back to "~$<0.0001" so the label never reads - as zero) - - Normal → "~$1.23" (2 dp) - - This fixes #79220 where sub-cent per-turn costs on cheap models - (DeepSeek, etc.) rendered as "$0.00" despite amount_usd carrying - full Decimal precision. - - Shared by per-response cost labels (estimate_usage_cost) and the - insights cost-bucket formatters — keep both surfaces on this one - implementation so sub-cent honesty can't regress on one of them. + Zero → "$0.00"; sub-cent → "~$0.0046" (4 dp, or "~$<0.0001" when the + amount rounds to 0.0000 so the label never reads as zero); else "~$1.23". + Shared by per-response cost labels and the insights cost-bucket + formatters so sub-cent honesty cannot regress on one surface. """ if amount == _ZERO: return "$0.00" if amount < _SUBCENT_THRESHOLD: label = f"~${amount:.4f}" - # A positive amount that rounds to 0.0000 at 4 dp would render - # "~$0.0000" — a zero-looking label, the exact #79220 dishonesty. - # Comparing the rendered label checks the truth directly (a naive - # `< 0.00005` threshold misses the exact boundary under - # ROUND_HALF_EVEN). + # Compare the rendered label: a naive `< 0.00005` threshold misses + # the exact boundary under ROUND_HALF_EVEN. return label if label != "~$0.0000" else "~$<0.0001" return f"~${amount:.2f}" @@ -89,12 +72,9 @@ class CanonicalUsage: return self.prompt_tokens + self.output_tokens def __add__(self, other: "CanonicalUsage") -> "CanonicalUsage": - """Sum two usage buckets (e.g. MoA advisor fan-out + aggregator). - - ``raw_usage`` is dropped on the sum — it describes a single API - response and cannot be meaningfully merged. ``request_count`` adds so - callers can see how many underlying API calls a combined figure covers. - """ + """Sum two usage buckets. ``raw_usage`` (single-response detail) is + dropped; ``request_count`` adds so callers see how many API calls a + combined figure covers.""" if not isinstance(other, CanonicalUsage): return NotImplemented return CanonicalUsage( @@ -127,12 +107,10 @@ class PricingEntry: source_url: Optional[str] = None pricing_version: Optional[str] = None fetched_at: Optional[datetime] = None - # Context-tiered pricing (e.g. Gemini Pro models charge higher rates once - # the prompt exceeds 200k tokens). When ``tier_threshold_tokens`` is set - # and ``usage.prompt_tokens`` (input + cache read + cache write) exceeds - # it, the ``*_above`` rates replace the base rates for the WHOLE request — - # that matches Google's billing semantics (not marginal/bracketed rates). - # Any ``*_above`` field left as None falls back to its base rate. + # Context-tiered pricing (e.g. Gemini Pro above 200k prompt tokens): when + # ``usage.prompt_tokens`` exceeds ``tier_threshold_tokens`` the ``*_above`` + # rates replace the base rates for the WHOLE request (Google's semantics, + # not marginal brackets). A None ``*_above`` falls back to its base rate. tier_threshold_tokens: Optional[int] = None input_cost_per_million_above: Optional[Decimal] = None output_cost_per_million_above: Optional[Decimal] = None @@ -153,886 +131,175 @@ class CostResult: _UTC_NOW = lambda: datetime.now(timezone.utc) -# Official docs snapshot entries. Models whose published pricing and cache -# semantics are stable enough to encode exactly. +def _snap( + inp: str, + out: str, + cache_read: Optional[str] = None, + cache_write: Optional[str] = None, + *, + version: str, + url: Optional[str] = None, + **tiers: Any, +) -> PricingEntry: + """Build an official-docs snapshot entry from per-million USD rate strings.""" + return PricingEntry( + input_cost_per_million=Decimal(inp), + output_cost_per_million=Decimal(out), + cache_read_cost_per_million=Decimal(cache_read) if cache_read is not None else None, + cache_write_cost_per_million=Decimal(cache_write) if cache_write is not None else None, + source="official_docs_snapshot", + source_url=url, + pricing_version=version, + **tiers, + ) + + +# (source_url, pricing_version) shared by the entries of one snapshot. +_OPENAI_56 = dict(url="https://openai.com/index/previewing-gpt-5-6-sol/", version="openai-gpt-5.6-2026-07") +_ANTHROPIC = dict(url="https://platform.claude.com/docs/en/about-claude/pricing", version="anthropic-pricing-2026-05") +_OPENAI = dict(url="https://openai.com/api/pricing/", version="openai-pricing-2026-03-16") +_DEEPSEEK = dict(url="https://api-docs.deepseek.com/quick_start/pricing", version="deepseek-pricing-2026-07") +_GOOGLE = dict(url="https://ai.google.dev/pricing", version="google-pricing-2026-07-07") +_GOOGLE_NEW = dict(url="https://ai.google.dev/gemini-api/docs/pricing", version="google-pricing-2026-07-28") +_BEDROCK_URL = "https://aws.amazon.com/bedrock/pricing/" +_BEDROCK_ANTHROPIC = dict(url=_BEDROCK_URL, version="anthropic-list-2026-07") +_BEDROCK = dict(url=_BEDROCK_URL, version="bedrock-pricing-2026-04") +_FIREWORKS = dict(url="https://docs.fireworks.ai/serverless/pricing", version="fireworks-pricing-2026-07") + +# Official docs snapshot: models whose published pricing and cache semantics +# are stable enough to encode exactly. Positional rates are per 1M tokens: +# (input, output[, cache_read[, cache_write]]). _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = { - # ── OpenAI GPT-5.6 series (Sol/Terra/Luna) ─────────────────────────── - # Announced in limited preview 2026-06-26; GA 2026-07-09 at the same - # rates (Sol $5/$30, Terra $2.50/$15, Luna $1/$6 per 1M in/out). Cache - # writes are billed at 1.25x the uncached input rate; cache reads get the - # standard 90% discount (0.10x input, confirmed: Sol $0.50/M cached). - # Note: "Sol Fast mode" ($12.5/$75, up to 750 tok/s via Cerebras) is a - # separate serving tier, not covered by these entries. The "-pro" - # variants (high-effort modes, GA alongside base tiers) bill at the - # SAME per-token rates and are aliased onto these entries below the - # dict (they cost more per task by consuming more tokens, not by a - # higher rate — verified against OpenRouter's live pricing 2026-07-09). - # Source: https://openai.com/index/previewing-gpt-5-6-sol/ - ( - "openai", - "gpt-5.6-sol", - ): PricingEntry( - input_cost_per_million=Decimal("5.00"), - output_cost_per_million=Decimal("30.00"), - cache_read_cost_per_million=Decimal("0.50"), - cache_write_cost_per_million=Decimal("6.25"), - source="official_docs_snapshot", - source_url="https://openai.com/index/previewing-gpt-5-6-sol/", - pricing_version="openai-gpt-5.6-2026-07", + # OpenAI GPT-5.6 (Sol/Terra/Luna). Cache write = 1.25x input, cache read = + # 0.10x input. "-pro" high-effort modes bill at the same per-token rates + # (aliased below the dict); "Sol Fast mode" is a separate tier, not covered. + ("openai", "gpt-5.6-sol"): _snap("5.00", "30.00", "0.50", "6.25", **_OPENAI_56), + ("openai", "gpt-5.6-terra"): _snap("2.50", "15.00", "0.25", "3.125", **_OPENAI_56), + ("openai", "gpt-5.6-luna"): _snap("1.00", "6.00", "0.10", "1.25", **_OPENAI_56), + # Anthropic Claude 4.8; fast mode is a separate model id at a 2x premium. + ("anthropic", "claude-opus-4-8"): _snap("5.00", "25.00", "0.50", "6.25", **_ANTHROPIC), + ("anthropic", "claude-opus-4-8-fast"): _snap( + "10.00", "50.00", "1.00", "12.50", + url="https://openrouter.ai/anthropic/claude-opus-4.8-fast", version="anthropic-pricing-2026-05", ), - ( - "openai", - "gpt-5.6-terra", - ): PricingEntry( - input_cost_per_million=Decimal("2.50"), - output_cost_per_million=Decimal("15.00"), - cache_read_cost_per_million=Decimal("0.25"), - cache_write_cost_per_million=Decimal("3.125"), - source="official_docs_snapshot", - source_url="https://openai.com/index/previewing-gpt-5-6-sol/", - pricing_version="openai-gpt-5.6-2026-07", - ), - ( - "openai", - "gpt-5.6-luna", - ): PricingEntry( - input_cost_per_million=Decimal("1.00"), - output_cost_per_million=Decimal("6.00"), - cache_read_cost_per_million=Decimal("0.10"), - cache_write_cost_per_million=Decimal("1.25"), - source="official_docs_snapshot", - source_url="https://openai.com/index/previewing-gpt-5-6-sol/", - pricing_version="openai-gpt-5.6-2026-07", - ), - # ── Anthropic Claude 4.8 ───────────────────────────────────────────── - # Same $5/$25 base pricing as 4.6/4.7. Fast-mode variant is a separate - # model ID with 2x premium (vs the 6x premium on older Opus generations). - # Source: https://openrouter.ai/anthropic/claude-opus-4.8 - ( - "anthropic", - "claude-opus-4-8", - ): PricingEntry( - input_cost_per_million=Decimal("5.00"), - output_cost_per_million=Decimal("25.00"), - cache_read_cost_per_million=Decimal("0.50"), - cache_write_cost_per_million=Decimal("6.25"), - source="official_docs_snapshot", - source_url="https://platform.claude.com/docs/en/about-claude/pricing", - pricing_version="anthropic-pricing-2026-05", - ), - ( - "anthropic", - "claude-opus-4-8-fast", - ): PricingEntry( - input_cost_per_million=Decimal("10.00"), - output_cost_per_million=Decimal("50.00"), - cache_read_cost_per_million=Decimal("1.00"), - cache_write_cost_per_million=Decimal("12.50"), - source="official_docs_snapshot", - source_url="https://openrouter.ai/anthropic/claude-opus-4.8-fast", - pricing_version="anthropic-pricing-2026-05", - ), - # ── Anthropic Claude Sonnet 5 ──────────────────────────────────────── - # Launched 2026-06-30. Introductory pricing ($2/$10 per MTok) runs - # through 2026-08-31, after which it reverts to $3/$15 (matching - # Sonnet 4.6). Update this entry when the intro window closes. - # Source: https://platform.claude.com/docs/en/about-claude/pricing - ( - "anthropic", - "claude-sonnet-5", - ): PricingEntry( - input_cost_per_million=Decimal("2.00"), - output_cost_per_million=Decimal("10.00"), - cache_read_cost_per_million=Decimal("0.20"), - cache_write_cost_per_million=Decimal("2.50"), - source="official_docs_snapshot", - source_url="https://platform.claude.com/docs/en/about-claude/pricing", - pricing_version="anthropic-pricing-2026-06-intro", - ), - # ── Anthropic Claude 4.7 ───────────────────────────────────────────── - # Opus 4.5/4.6/4.7 share $5/$25 pricing (new tokenizer, up to 35% more - # tokens for the same text). - # Source: https://platform.claude.com/docs/en/about-claude/pricing - ( - "anthropic", - "claude-opus-4-7", - ): PricingEntry( - input_cost_per_million=Decimal("5.00"), - output_cost_per_million=Decimal("25.00"), - cache_read_cost_per_million=Decimal("0.50"), - cache_write_cost_per_million=Decimal("6.25"), - source="official_docs_snapshot", - source_url="https://platform.claude.com/docs/en/about-claude/pricing", - pricing_version="anthropic-pricing-2026-05", - ), - ( - "anthropic", - "claude-opus-4-7-20250507", - ): PricingEntry( - input_cost_per_million=Decimal("5.00"), - output_cost_per_million=Decimal("25.00"), - cache_read_cost_per_million=Decimal("0.50"), - cache_write_cost_per_million=Decimal("6.25"), - source="official_docs_snapshot", - source_url="https://platform.claude.com/docs/en/about-claude/pricing", - pricing_version="anthropic-pricing-2026-05", - ), - # ── Anthropic Claude 4.6 ───────────────────────────────────────────── - ( - "anthropic", - "claude-opus-4-6", - ): PricingEntry( - input_cost_per_million=Decimal("5.00"), - output_cost_per_million=Decimal("25.00"), - cache_read_cost_per_million=Decimal("0.50"), - cache_write_cost_per_million=Decimal("6.25"), - source="official_docs_snapshot", - source_url="https://platform.claude.com/docs/en/about-claude/pricing", - pricing_version="anthropic-pricing-2026-05", - ), - ( - "anthropic", - "claude-opus-4-6-20250414", - ): PricingEntry( - input_cost_per_million=Decimal("5.00"), - output_cost_per_million=Decimal("25.00"), - cache_read_cost_per_million=Decimal("0.50"), - cache_write_cost_per_million=Decimal("6.25"), - source="official_docs_snapshot", - source_url="https://platform.claude.com/docs/en/about-claude/pricing", - pricing_version="anthropic-pricing-2026-05", - ), - ( - "anthropic", - "claude-sonnet-4-6", - ): PricingEntry( - input_cost_per_million=Decimal("3.00"), - output_cost_per_million=Decimal("15.00"), - cache_read_cost_per_million=Decimal("0.30"), - cache_write_cost_per_million=Decimal("3.75"), - source="official_docs_snapshot", - source_url="https://platform.claude.com/docs/en/about-claude/pricing", - pricing_version="anthropic-pricing-2026-05", - ), - ( - "anthropic", - "claude-sonnet-4-6-20250414", - ): PricingEntry( - input_cost_per_million=Decimal("3.00"), - output_cost_per_million=Decimal("15.00"), - cache_read_cost_per_million=Decimal("0.30"), - cache_write_cost_per_million=Decimal("3.75"), - source="official_docs_snapshot", - source_url="https://platform.claude.com/docs/en/about-claude/pricing", - pricing_version="anthropic-pricing-2026-05", - ), - # ── Anthropic Claude 4.5 ───────────────────────────────────────────── - ( - "anthropic", - "claude-opus-4-5", - ): PricingEntry( - input_cost_per_million=Decimal("5.00"), - output_cost_per_million=Decimal("25.00"), - cache_read_cost_per_million=Decimal("0.50"), - cache_write_cost_per_million=Decimal("6.25"), - source="official_docs_snapshot", - source_url="https://platform.claude.com/docs/en/about-claude/pricing", - pricing_version="anthropic-pricing-2026-05", - ), - ( - "anthropic", - "claude-sonnet-4-5", - ): PricingEntry( - input_cost_per_million=Decimal("3.00"), - output_cost_per_million=Decimal("15.00"), - cache_read_cost_per_million=Decimal("0.30"), - cache_write_cost_per_million=Decimal("3.75"), - source="official_docs_snapshot", - source_url="https://platform.claude.com/docs/en/about-claude/pricing", - pricing_version="anthropic-pricing-2026-05", - ), - ( - "anthropic", - "claude-haiku-4-5", - ): PricingEntry( - input_cost_per_million=Decimal("1.00"), - output_cost_per_million=Decimal("5.00"), - cache_read_cost_per_million=Decimal("0.10"), - cache_write_cost_per_million=Decimal("1.25"), - source="official_docs_snapshot", - source_url="https://platform.claude.com/docs/en/about-claude/pricing", - pricing_version="anthropic-pricing-2026-05", - ), - # ── Anthropic Claude 4 / 4.1 ───────────────────────────────────────── - ( - "anthropic", - "claude-opus-4-20250514", - ): PricingEntry( - input_cost_per_million=Decimal("15.00"), - output_cost_per_million=Decimal("75.00"), - cache_read_cost_per_million=Decimal("1.50"), - cache_write_cost_per_million=Decimal("18.75"), - source="official_docs_snapshot", - source_url="https://platform.claude.com/docs/en/about-claude/pricing", - pricing_version="anthropic-pricing-2026-05", - ), - ( - "anthropic", - "claude-sonnet-4-20250514", - ): PricingEntry( - input_cost_per_million=Decimal("3.00"), - output_cost_per_million=Decimal("15.00"), - cache_read_cost_per_million=Decimal("0.30"), - cache_write_cost_per_million=Decimal("3.75"), - source="official_docs_snapshot", - source_url="https://platform.claude.com/docs/en/about-claude/pricing", - pricing_version="anthropic-pricing-2026-05", + # Claude Sonnet 5: introductory $2/$10 through 2026-08-31, then $3/$15 + # (matching Sonnet 4.6). Update this entry when the intro window closes. + ("anthropic", "claude-sonnet-5"): _snap( + "2.00", "10.00", "0.20", "2.50", url=_ANTHROPIC["url"], version="anthropic-pricing-2026-06-intro" ), + # Claude 4.5/4.6/4.7 Opus share $5/$25 (new tokenizer, up to 35% more tokens). + ("anthropic", "claude-opus-4-7"): _snap("5.00", "25.00", "0.50", "6.25", **_ANTHROPIC), + ("anthropic", "claude-opus-4-7-20250507"): _snap("5.00", "25.00", "0.50", "6.25", **_ANTHROPIC), + ("anthropic", "claude-opus-4-6"): _snap("5.00", "25.00", "0.50", "6.25", **_ANTHROPIC), + ("anthropic", "claude-opus-4-6-20250414"): _snap("5.00", "25.00", "0.50", "6.25", **_ANTHROPIC), + ("anthropic", "claude-sonnet-4-6"): _snap("3.00", "15.00", "0.30", "3.75", **_ANTHROPIC), + ("anthropic", "claude-sonnet-4-6-20250414"): _snap("3.00", "15.00", "0.30", "3.75", **_ANTHROPIC), + ("anthropic", "claude-opus-4-5"): _snap("5.00", "25.00", "0.50", "6.25", **_ANTHROPIC), + ("anthropic", "claude-sonnet-4-5"): _snap("3.00", "15.00", "0.30", "3.75", **_ANTHROPIC), + ("anthropic", "claude-haiku-4-5"): _snap("1.00", "5.00", "0.10", "1.25", **_ANTHROPIC), + ("anthropic", "claude-opus-4-20250514"): _snap("15.00", "75.00", "1.50", "18.75", **_ANTHROPIC), + ("anthropic", "claude-sonnet-4-20250514"): _snap("3.00", "15.00", "0.30", "3.75", **_ANTHROPIC), # OpenAI - ( - "openai", - "gpt-4o", - ): PricingEntry( - input_cost_per_million=Decimal("2.50"), - output_cost_per_million=Decimal("10.00"), - cache_read_cost_per_million=Decimal("1.25"), - source="official_docs_snapshot", - source_url="https://openai.com/api/pricing/", - pricing_version="openai-pricing-2026-03-16", - ), - ( - "openai", - "gpt-4o-mini", - ): PricingEntry( - input_cost_per_million=Decimal("0.15"), - output_cost_per_million=Decimal("0.60"), - cache_read_cost_per_million=Decimal("0.075"), - source="official_docs_snapshot", - source_url="https://openai.com/api/pricing/", - pricing_version="openai-pricing-2026-03-16", - ), - ( - "openai", - "gpt-4.1", - ): PricingEntry( - input_cost_per_million=Decimal("2.00"), - output_cost_per_million=Decimal("8.00"), - cache_read_cost_per_million=Decimal("0.50"), - source="official_docs_snapshot", - source_url="https://openai.com/api/pricing/", - pricing_version="openai-pricing-2026-03-16", - ), - ( - "openai", - "gpt-4.1-mini", - ): PricingEntry( - input_cost_per_million=Decimal("0.40"), - output_cost_per_million=Decimal("1.60"), - cache_read_cost_per_million=Decimal("0.10"), - source="official_docs_snapshot", - source_url="https://openai.com/api/pricing/", - pricing_version="openai-pricing-2026-03-16", - ), - ( - "openai", - "gpt-4.1-nano", - ): PricingEntry( - input_cost_per_million=Decimal("0.10"), - output_cost_per_million=Decimal("0.40"), - cache_read_cost_per_million=Decimal("0.025"), - source="official_docs_snapshot", - source_url="https://openai.com/api/pricing/", - pricing_version="openai-pricing-2026-03-16", - ), - ( - "openai", - "o3", - ): PricingEntry( - input_cost_per_million=Decimal("10.00"), - output_cost_per_million=Decimal("40.00"), - cache_read_cost_per_million=Decimal("2.50"), - source="official_docs_snapshot", - source_url="https://openai.com/api/pricing/", - pricing_version="openai-pricing-2026-03-16", - ), - ( - "openai", - "o3-mini", - ): PricingEntry( - input_cost_per_million=Decimal("1.10"), - output_cost_per_million=Decimal("4.40"), - cache_read_cost_per_million=Decimal("0.55"), - source="official_docs_snapshot", - source_url="https://openai.com/api/pricing/", - pricing_version="openai-pricing-2026-03-16", - ), - # ── Anthropic older models (pre-4.5 generation) ──────────────────────── - ( - "anthropic", - "claude-3-5-sonnet-20241022", - ): PricingEntry( - input_cost_per_million=Decimal("3.00"), - output_cost_per_million=Decimal("15.00"), - cache_read_cost_per_million=Decimal("0.30"), - cache_write_cost_per_million=Decimal("3.75"), - source="official_docs_snapshot", - source_url="https://platform.claude.com/docs/en/about-claude/pricing", - pricing_version="anthropic-pricing-2026-05", - ), - ( - "anthropic", - "claude-3-5-haiku-20241022", - ): PricingEntry( - input_cost_per_million=Decimal("0.80"), - output_cost_per_million=Decimal("4.00"), - cache_read_cost_per_million=Decimal("0.08"), - cache_write_cost_per_million=Decimal("1.00"), - source="official_docs_snapshot", - source_url="https://platform.claude.com/docs/en/about-claude/pricing", - pricing_version="anthropic-pricing-2026-05", - ), - ( - "anthropic", - "claude-3-opus-20240229", - ): PricingEntry( - input_cost_per_million=Decimal("15.00"), - output_cost_per_million=Decimal("75.00"), - cache_read_cost_per_million=Decimal("1.50"), - cache_write_cost_per_million=Decimal("18.75"), - source="official_docs_snapshot", - source_url="https://platform.claude.com/docs/en/about-claude/pricing", - pricing_version="anthropic-pricing-2026-05", - ), - ( - "anthropic", - "claude-3-haiku-20240307", - ): PricingEntry( - input_cost_per_million=Decimal("0.25"), - output_cost_per_million=Decimal("1.25"), - cache_read_cost_per_million=Decimal("0.03"), - cache_write_cost_per_million=Decimal("0.30"), - source="official_docs_snapshot", - source_url="https://platform.claude.com/docs/en/about-claude/pricing", - pricing_version="anthropic-pricing-2026-05", - ), - # DeepSeek - # Snapshot of https://api-docs.deepseek.com/quick_start/pricing (2026-07). - # deepseek-chat / deepseek-reasoner are deprecated 2026-07-24 and now alias + ("openai", "gpt-4o"): _snap("2.50", "10.00", "1.25", **_OPENAI), + ("openai", "gpt-4o-mini"): _snap("0.15", "0.60", "0.075", **_OPENAI), + ("openai", "gpt-4.1"): _snap("2.00", "8.00", "0.50", **_OPENAI), + ("openai", "gpt-4.1-mini"): _snap("0.40", "1.60", "0.10", **_OPENAI), + ("openai", "gpt-4.1-nano"): _snap("0.10", "0.40", "0.025", **_OPENAI), + ("openai", "o3"): _snap("10.00", "40.00", "2.50", **_OPENAI), + ("openai", "o3-mini"): _snap("1.10", "4.40", "0.55", **_OPENAI), + # Anthropic pre-4.5 generation + ("anthropic", "claude-3-5-sonnet-20241022"): _snap("3.00", "15.00", "0.30", "3.75", **_ANTHROPIC), + ("anthropic", "claude-3-5-haiku-20241022"): _snap("0.80", "4.00", "0.08", "1.00", **_ANTHROPIC), + ("anthropic", "claude-3-opus-20240229"): _snap("15.00", "75.00", "1.50", "18.75", **_ANTHROPIC), + ("anthropic", "claude-3-haiku-20240307"): _snap("0.25", "1.25", "0.03", "0.30", **_ANTHROPIC), + # DeepSeek. deepseek-chat / deepseek-reasoner are deprecated aliases of # deepseek-v4-flash's non-thinking / thinking modes — same rates. - ( - "deepseek", - "deepseek-chat", - ): PricingEntry( - input_cost_per_million=Decimal("0.14"), - output_cost_per_million=Decimal("0.28"), - cache_read_cost_per_million=Decimal("0.0028"), - source="official_docs_snapshot", - source_url="https://api-docs.deepseek.com/quick_start/pricing", - pricing_version="deepseek-pricing-2026-07", - ), - ( - "deepseek", - "deepseek-reasoner", - ): PricingEntry( - input_cost_per_million=Decimal("0.14"), - output_cost_per_million=Decimal("0.28"), - cache_read_cost_per_million=Decimal("0.0028"), - source="official_docs_snapshot", - source_url="https://api-docs.deepseek.com/quick_start/pricing", - pricing_version="deepseek-pricing-2026-07", - ), - ( - "deepseek", - "deepseek-v4-pro", - ): PricingEntry( - input_cost_per_million=Decimal("0.435"), - output_cost_per_million=Decimal("0.87"), - cache_read_cost_per_million=Decimal("0.003625"), - source="official_docs_snapshot", - source_url="https://api-docs.deepseek.com/quick_start/pricing", - pricing_version="deepseek-pricing-2026-07", - ), - ( - "deepseek", - "deepseek-v4-flash", - ): PricingEntry( - input_cost_per_million=Decimal("0.14"), - output_cost_per_million=Decimal("0.28"), - cache_read_cost_per_million=Decimal("0.0028"), - source="official_docs_snapshot", - source_url="https://api-docs.deepseek.com/quick_start/pricing", - pricing_version="deepseek-pricing-2026-07", - ), + ("deepseek", "deepseek-chat"): _snap("0.14", "0.28", "0.0028", **_DEEPSEEK), + ("deepseek", "deepseek-reasoner"): _snap("0.14", "0.28", "0.0028", **_DEEPSEEK), + ("deepseek", "deepseek-v4-pro"): _snap("0.435", "0.87", "0.003625", **_DEEPSEEK), + ("deepseek", "deepseek-v4-flash"): _snap("0.14", "0.28", "0.0028", **_DEEPSEEK), # Google Gemini - ( - "google", - "gemini-3.6-flash", - ): PricingEntry( - input_cost_per_million=Decimal("1.50"), - output_cost_per_million=Decimal("7.50"), - cache_read_cost_per_million=Decimal("0.15"), - source="official_docs_snapshot", - source_url="https://ai.google.dev/gemini-api/docs/pricing", - pricing_version="google-pricing-2026-07-28", - ), - ( - "google", - "gemini-3.5-flash", - ): PricingEntry( - input_cost_per_million=Decimal("1.50"), - output_cost_per_million=Decimal("9.00"), - cache_read_cost_per_million=Decimal("0.15"), - source="official_docs_snapshot", - source_url="https://ai.google.dev/pricing", - pricing_version="google-pricing-2026-07-07", - ), - ( - "google", - "gemini-3.5-flash-lite", - ): PricingEntry( - input_cost_per_million=Decimal("0.30"), - output_cost_per_million=Decimal("2.50"), - cache_read_cost_per_million=Decimal("0.03"), - source="official_docs_snapshot", - source_url="https://ai.google.dev/gemini-api/docs/pricing", - pricing_version="google-pricing-2026-07-28", - ), - ( - "google", - "gemini-3.1-pro", - ): PricingEntry( - input_cost_per_million=Decimal("2.00"), - output_cost_per_million=Decimal("12.00"), - cache_read_cost_per_million=Decimal("0.20"), + ("google", "gemini-3.6-flash"): _snap("1.50", "7.50", "0.15", **_GOOGLE_NEW), + ("google", "gemini-3.5-flash"): _snap("1.50", "9.00", "0.15", **_GOOGLE), + ("google", "gemini-3.5-flash-lite"): _snap("0.30", "2.50", "0.03", **_GOOGLE_NEW), + ("google", "gemini-3.1-pro"): _snap( + "2.00", "12.00", "0.20", tier_threshold_tokens=200_000, input_cost_per_million_above=Decimal("4.00"), output_cost_per_million_above=Decimal("18.00"), cache_read_cost_per_million_above=Decimal("0.40"), - source="official_docs_snapshot", - source_url="https://ai.google.dev/pricing", - pricing_version="google-pricing-2026-07-07", + **_GOOGLE, ), - ( - "google", - "gemini-3.1-flash-lite", - ): PricingEntry( - input_cost_per_million=Decimal("0.25"), - output_cost_per_million=Decimal("1.50"), - cache_read_cost_per_million=Decimal("0.025"), - source="official_docs_snapshot", - source_url="https://ai.google.dev/pricing", - pricing_version="google-pricing-2026-07-07", - ), - ( - "google", - "gemini-3-pro-preview", - ): PricingEntry( - input_cost_per_million=Decimal("2.00"), - output_cost_per_million=Decimal("12.00"), - cache_read_cost_per_million=Decimal("0.20"), - source="official_docs_snapshot", - source_url="https://ai.google.dev/pricing", - pricing_version="google-pricing-2026-07-07", - ), - ( - "google", - "gemini-3-flash-preview", - ): PricingEntry( - input_cost_per_million=Decimal("0.50"), - output_cost_per_million=Decimal("3.00"), - cache_read_cost_per_million=Decimal("0.05"), - source="official_docs_snapshot", - source_url="https://ai.google.dev/pricing", - pricing_version="google-pricing-2026-07-07", - ), - ( - "google", - "gemini-2.5-pro", - ): PricingEntry( - input_cost_per_million=Decimal("1.25"), - output_cost_per_million=Decimal("10.00"), - cache_read_cost_per_million=Decimal("0.125"), + ("google", "gemini-3.1-flash-lite"): _snap("0.25", "1.50", "0.025", **_GOOGLE), + ("google", "gemini-3-pro-preview"): _snap("2.00", "12.00", "0.20", **_GOOGLE), + ("google", "gemini-3-flash-preview"): _snap("0.50", "3.00", "0.05", **_GOOGLE), + ("google", "gemini-2.5-pro"): _snap( + "1.25", "10.00", "0.125", tier_threshold_tokens=200_000, input_cost_per_million_above=Decimal("2.50"), output_cost_per_million_above=Decimal("15.00"), - source="official_docs_snapshot", - source_url="https://ai.google.dev/pricing", - pricing_version="google-pricing-2026-07-07", + **_GOOGLE, ), - ( - "google", - "gemini-2.5-flash", - ): PricingEntry( - input_cost_per_million=Decimal("0.15"), - output_cost_per_million=Decimal("0.60"), - cache_read_cost_per_million=Decimal("0.015"), - source="official_docs_snapshot", - source_url="https://ai.google.dev/pricing", - pricing_version="google-pricing-2026-07-07", - ), - ( - "google", - "gemini-2.0-flash", - ): PricingEntry( - input_cost_per_million=Decimal("0.10"), - output_cost_per_million=Decimal("0.40"), - cache_read_cost_per_million=Decimal("0.01"), - source="official_docs_snapshot", - source_url="https://ai.google.dev/pricing", - pricing_version="google-pricing-2026-07-07", - ), - # AWS Bedrock — pricing per the Bedrock pricing page. - # Bedrock charges the same per-token rates as the model provider but - # through AWS billing. These are the on-demand prices (no commitment). - # Source: https://aws.amazon.com/bedrock/pricing/ - # Current-gen Claude Opus on Bedrock. Commercial Bedrock on-demand - # mirrors Anthropic's published list price for the Claude line - # ($5/$25 for Opus 4.6/4.7/4.8; cache write = 1.25x input at the - # 5-minute TTL, cache read = 0.1x input). NOTE: the AWS Price List API - # had not published these SKUs machine-readably as of 2026-07 — these - # are commercial-list snapshots pending an authoritative machine source. - ( - "bedrock", - "anthropic.claude-opus-4-8", - ): PricingEntry( - input_cost_per_million=Decimal("5.00"), - output_cost_per_million=Decimal("25.00"), - cache_read_cost_per_million=Decimal("0.50"), - cache_write_cost_per_million=Decimal("6.25"), - source="official_docs_snapshot", - source_url="https://aws.amazon.com/bedrock/pricing/", - pricing_version="anthropic-list-2026-07", - ), - ( - "bedrock", - "anthropic.claude-opus-4-7", - ): PricingEntry( - input_cost_per_million=Decimal("5.00"), - output_cost_per_million=Decimal("25.00"), - cache_read_cost_per_million=Decimal("0.50"), - cache_write_cost_per_million=Decimal("6.25"), - source="official_docs_snapshot", - source_url="https://aws.amazon.com/bedrock/pricing/", - pricing_version="anthropic-list-2026-07", - ), - ( - "bedrock", - "anthropic.claude-opus-4-6", - ): PricingEntry( - input_cost_per_million=Decimal("5.00"), - output_cost_per_million=Decimal("25.00"), - cache_read_cost_per_million=Decimal("0.50"), - cache_write_cost_per_million=Decimal("6.25"), - source="official_docs_snapshot", - source_url="https://aws.amazon.com/bedrock/pricing/", - pricing_version="anthropic-list-2026-07", - ), - ( - "bedrock", - "anthropic.claude-sonnet-5", - ): PricingEntry( - input_cost_per_million=Decimal("3.00"), - output_cost_per_million=Decimal("15.00"), - cache_read_cost_per_million=Decimal("0.30"), - cache_write_cost_per_million=Decimal("3.75"), - source="official_docs_snapshot", - source_url="https://aws.amazon.com/bedrock/pricing/", - pricing_version="bedrock-pricing-2026-06", - ), - ( - "bedrock", - "anthropic.claude-sonnet-4-6", - ): PricingEntry( - input_cost_per_million=Decimal("3.00"), - output_cost_per_million=Decimal("15.00"), - cache_read_cost_per_million=Decimal("0.30"), - cache_write_cost_per_million=Decimal("3.75"), - source="official_docs_snapshot", - source_url="https://aws.amazon.com/bedrock/pricing/", - pricing_version="bedrock-pricing-2026-04", - ), - ( - "bedrock", - "anthropic.claude-sonnet-4-5", - ): PricingEntry( - input_cost_per_million=Decimal("3.00"), - output_cost_per_million=Decimal("15.00"), - cache_read_cost_per_million=Decimal("0.30"), - cache_write_cost_per_million=Decimal("3.75"), - source="official_docs_snapshot", - source_url="https://aws.amazon.com/bedrock/pricing/", - pricing_version="bedrock-pricing-2026-04", - ), - ( - "bedrock", - "anthropic.claude-haiku-4-5", - ): PricingEntry( - input_cost_per_million=Decimal("0.80"), - output_cost_per_million=Decimal("4.00"), - cache_read_cost_per_million=Decimal("0.08"), - cache_write_cost_per_million=Decimal("1.00"), - source="official_docs_snapshot", - source_url="https://aws.amazon.com/bedrock/pricing/", - pricing_version="bedrock-pricing-2026-04", - ), - ( - "bedrock", - "amazon.nova-pro", - ): PricingEntry( - input_cost_per_million=Decimal("0.80"), - output_cost_per_million=Decimal("3.20"), - source="official_docs_snapshot", - source_url="https://aws.amazon.com/bedrock/pricing/", - pricing_version="bedrock-pricing-2026-04", - ), - ( - "bedrock", - "amazon.nova-lite", - ): PricingEntry( - input_cost_per_million=Decimal("0.06"), - output_cost_per_million=Decimal("0.24"), - source="official_docs_snapshot", - source_url="https://aws.amazon.com/bedrock/pricing/", - pricing_version="bedrock-pricing-2026-04", - ), - ( - "bedrock", - "amazon.nova-micro", - ): PricingEntry( - input_cost_per_million=Decimal("0.035"), - output_cost_per_million=Decimal("0.14"), - source="official_docs_snapshot", - source_url="https://aws.amazon.com/bedrock/pricing/", - pricing_version="bedrock-pricing-2026-04", + ("google", "gemini-2.5-flash"): _snap("0.15", "0.60", "0.015", **_GOOGLE), + ("google", "gemini-2.0-flash"): _snap("0.10", "0.40", "0.01", **_GOOGLE), + # AWS Bedrock on-demand: same per-token rates as the model provider, billed + # through AWS. Current-gen Claude rows are commercial-list snapshots (the AWS + # Price List API had not published these SKUs machine-readably). + ("bedrock", "anthropic.claude-opus-4-8"): _snap("5.00", "25.00", "0.50", "6.25", **_BEDROCK_ANTHROPIC), + ("bedrock", "anthropic.claude-opus-4-7"): _snap("5.00", "25.00", "0.50", "6.25", **_BEDROCK_ANTHROPIC), + ("bedrock", "anthropic.claude-opus-4-6"): _snap("5.00", "25.00", "0.50", "6.25", **_BEDROCK_ANTHROPIC), + ("bedrock", "anthropic.claude-sonnet-5"): _snap( + "3.00", "15.00", "0.30", "3.75", url=_BEDROCK_URL, version="bedrock-pricing-2026-06" ), + ("bedrock", "anthropic.claude-sonnet-4-6"): _snap("3.00", "15.00", "0.30", "3.75", **_BEDROCK), + ("bedrock", "anthropic.claude-sonnet-4-5"): _snap("3.00", "15.00", "0.30", "3.75", **_BEDROCK), + ("bedrock", "anthropic.claude-haiku-4-5"): _snap("0.80", "4.00", "0.08", "1.00", **_BEDROCK), + ("bedrock", "amazon.nova-pro"): _snap("0.80", "3.20", **_BEDROCK), + ("bedrock", "amazon.nova-lite"): _snap("0.06", "0.24", **_BEDROCK), + ("bedrock", "amazon.nova-micro"): _snap("0.035", "0.14", **_BEDROCK), # MiniMax - ( - "minimax", - "minimax-m2.7", - ): PricingEntry( - input_cost_per_million=Decimal("0.30"), - output_cost_per_million=Decimal("1.20"), - source="official_docs_snapshot", - pricing_version="minimax-pricing-2026-04", - ), - ( - "minimax-cn", - "minimax-m2.7", - ): PricingEntry( - input_cost_per_million=Decimal("0.30"), - output_cost_per_million=Decimal("1.20"), - source="official_docs_snapshot", - pricing_version="minimax-pricing-2026-04", - ), - # Fireworks AI — serverless pricing for the models hermes typically routes - # through when configured with provider="fireworks". Fireworks publishes a - # cached_input rate per model alongside input/output, which maps to - # cache_read_cost_per_million. No separately published cache_write rate. - # Snapshot of https://docs.fireworks.ai/serverless/pricing (Standard tier). - ( - "fireworks", - "kimi-k2p6", - ): PricingEntry( - input_cost_per_million=Decimal("0.95"), - output_cost_per_million=Decimal("4.00"), - cache_read_cost_per_million=Decimal("0.16"), - source="official_docs_snapshot", - source_url="https://docs.fireworks.ai/serverless/pricing", - pricing_version="fireworks-pricing-2026-07", - ), - ( - "fireworks", - "kimi-k2p7-code", - ): PricingEntry( - input_cost_per_million=Decimal("0.95"), - output_cost_per_million=Decimal("4.00"), - cache_read_cost_per_million=Decimal("0.19"), - source="official_docs_snapshot", - source_url="https://docs.fireworks.ai/serverless/pricing", - pricing_version="fireworks-pricing-2026-07", - ), - ( - "fireworks", - "glm-5p2", - ): PricingEntry( - input_cost_per_million=Decimal("1.40"), - output_cost_per_million=Decimal("4.40"), - cache_read_cost_per_million=Decimal("0.14"), - source="official_docs_snapshot", - source_url="https://docs.fireworks.ai/serverless/pricing", - pricing_version="fireworks-pricing-2026-07", - ), - ( - "fireworks", - "deepseek-v4-pro", - ): PricingEntry( - input_cost_per_million=Decimal("1.74"), - output_cost_per_million=Decimal("3.48"), - cache_read_cost_per_million=Decimal("0.145"), - source="official_docs_snapshot", - source_url="https://docs.fireworks.ai/serverless/pricing", - pricing_version="fireworks-pricing-2026-07", - ), - ( - "fireworks", - "deepseek-v4-flash", - ): PricingEntry( - input_cost_per_million=Decimal("0.14"), - output_cost_per_million=Decimal("0.28"), - cache_read_cost_per_million=Decimal("0.028"), - source="official_docs_snapshot", - source_url="https://docs.fireworks.ai/serverless/pricing", - pricing_version="fireworks-pricing-2026-07", - ), - ( - "fireworks", - "qwen3p7-plus", - ): PricingEntry( - input_cost_per_million=Decimal("0.40"), - output_cost_per_million=Decimal("1.60"), - cache_read_cost_per_million=Decimal("0.08"), - source="official_docs_snapshot", - source_url="https://docs.fireworks.ai/serverless/pricing", - pricing_version="fireworks-pricing-2026-07", - ), - ( - "fireworks", - "minimax-m3", - ): PricingEntry( - input_cost_per_million=Decimal("0.30"), - output_cost_per_million=Decimal("1.20"), - cache_read_cost_per_million=Decimal("0.06"), - source="official_docs_snapshot", - source_url="https://docs.fireworks.ai/serverless/pricing", - pricing_version="fireworks-pricing-2026-07", - ), - ( - "fireworks", - "gpt-oss-120b", - ): PricingEntry( - input_cost_per_million=Decimal("0.15"), - output_cost_per_million=Decimal("0.60"), - cache_read_cost_per_million=Decimal("0.015"), - source="official_docs_snapshot", - source_url="https://docs.fireworks.ai/serverless/pricing", - pricing_version="fireworks-pricing-2026-07", - ), - ( - "fireworks", - "gpt-oss-20b", - ): PricingEntry( - input_cost_per_million=Decimal("0.07"), - output_cost_per_million=Decimal("0.30"), - cache_read_cost_per_million=Decimal("0.035"), - source="official_docs_snapshot", - source_url="https://docs.fireworks.ai/serverless/pricing", - pricing_version="fireworks-pricing-2026-07", - ), - ( - "fireworks", - "glm-5p1", - ): PricingEntry( - input_cost_per_million=Decimal("1.40"), - output_cost_per_million=Decimal("4.40"), - cache_read_cost_per_million=Decimal("0.26"), - source="official_docs_snapshot", - source_url="https://docs.fireworks.ai/serverless/pricing", - pricing_version="fireworks-pricing-2026-07", - ), - ( - "fireworks", - "minimax-m2p7", - ): PricingEntry( - input_cost_per_million=Decimal("0.30"), - output_cost_per_million=Decimal("1.20"), - cache_read_cost_per_million=Decimal("0.06"), - source="official_docs_snapshot", - source_url="https://docs.fireworks.ai/serverless/pricing", - pricing_version="fireworks-pricing-2026-07", - ), - # Fast/turbo serving tiers — exposed as accounts/fireworks/routers/, - # so rsplit("/", 1) yields these distinct ids with their own (higher) rates. - ( - "fireworks", - "kimi-k2p6-fast", - ): PricingEntry( - input_cost_per_million=Decimal("2.00"), - output_cost_per_million=Decimal("8.00"), - cache_read_cost_per_million=Decimal("0.30"), - source="official_docs_snapshot", - source_url="https://docs.fireworks.ai/serverless/pricing", - pricing_version="fireworks-pricing-2026-07", - ), - ( - "fireworks", - "kimi-k2p6-turbo", - ): PricingEntry( - input_cost_per_million=Decimal("2.00"), - output_cost_per_million=Decimal("8.00"), - cache_read_cost_per_million=Decimal("0.30"), - source="official_docs_snapshot", - source_url="https://docs.fireworks.ai/serverless/pricing", - pricing_version="fireworks-pricing-2026-07", - ), - ( - "fireworks", - "kimi-k2p7-code-fast", - ): PricingEntry( - input_cost_per_million=Decimal("1.90"), - output_cost_per_million=Decimal("8.00"), - cache_read_cost_per_million=Decimal("0.38"), - source="official_docs_snapshot", - source_url="https://docs.fireworks.ai/serverless/pricing", - pricing_version="fireworks-pricing-2026-07", - ), - ( - "fireworks", - "glm-5p2-fast", - ): PricingEntry( - input_cost_per_million=Decimal("2.10"), - output_cost_per_million=Decimal("6.60"), - cache_read_cost_per_million=Decimal("0.21"), - source="official_docs_snapshot", - source_url="https://docs.fireworks.ai/serverless/pricing", - pricing_version="fireworks-pricing-2026-07", - ), - ( - "fireworks", - "glm-5p1-fast", - ): PricingEntry( - input_cost_per_million=Decimal("2.80"), - output_cost_per_million=Decimal("8.80"), - cache_read_cost_per_million=Decimal("0.52"), - source="official_docs_snapshot", - source_url="https://docs.fireworks.ai/serverless/pricing", - pricing_version="fireworks-pricing-2026-07", - ), + ("minimax", "minimax-m2.7"): _snap("0.30", "1.20", version="minimax-pricing-2026-04"), + ("minimax-cn", "minimax-m2.7"): _snap("0.30", "1.20", version="minimax-pricing-2026-04"), + # Fireworks AI serverless (Standard tier). Fireworks publishes a per-model + # cached_input rate (→ cache_read) but no separate cache_write rate. + ("fireworks", "kimi-k2p6"): _snap("0.95", "4.00", "0.16", **_FIREWORKS), + ("fireworks", "kimi-k2p7-code"): _snap("0.95", "4.00", "0.19", **_FIREWORKS), + ("fireworks", "glm-5p2"): _snap("1.40", "4.40", "0.14", **_FIREWORKS), + ("fireworks", "deepseek-v4-pro"): _snap("1.74", "3.48", "0.145", **_FIREWORKS), + ("fireworks", "deepseek-v4-flash"): _snap("0.14", "0.28", "0.028", **_FIREWORKS), + ("fireworks", "qwen3p7-plus"): _snap("0.40", "1.60", "0.08", **_FIREWORKS), + ("fireworks", "minimax-m3"): _snap("0.30", "1.20", "0.06", **_FIREWORKS), + ("fireworks", "gpt-oss-120b"): _snap("0.15", "0.60", "0.015", **_FIREWORKS), + ("fireworks", "gpt-oss-20b"): _snap("0.07", "0.30", "0.035", **_FIREWORKS), + ("fireworks", "glm-5p1"): _snap("1.40", "4.40", "0.26", **_FIREWORKS), + ("fireworks", "minimax-m2p7"): _snap("0.30", "1.20", "0.06", **_FIREWORKS), + # Fast/turbo tiers are exposed as accounts/fireworks/routers/, so + # rsplit("/", 1) yields these distinct ids with their own (higher) rates. + ("fireworks", "kimi-k2p6-fast"): _snap("2.00", "8.00", "0.30", **_FIREWORKS), + ("fireworks", "kimi-k2p6-turbo"): _snap("2.00", "8.00", "0.30", **_FIREWORKS), + ("fireworks", "kimi-k2p7-code-fast"): _snap("1.90", "8.00", "0.38", **_FIREWORKS), + ("fireworks", "glm-5p2-fast"): _snap("2.10", "6.60", "0.21", **_FIREWORKS), + ("fireworks", "glm-5p1-fast"): _snap("2.80", "8.80", "0.52", **_FIREWORKS), } +del _OPENAI_56, _ANTHROPIC, _OPENAI, _DEEPSEEK, _GOOGLE, _GOOGLE_NEW +del _BEDROCK_URL, _BEDROCK_ANTHROPIC, _BEDROCK, _FIREWORKS -# GPT-5.6 "-pro" high-effort variants bill at the same per-token rates as -# their base tiers (more tokens per task, not a higher rate). Alias them -# onto the base entries so the snapshot stays single-source. The Hermes-side -# "-900k" large-context Codex picker variants are the same underlying model -# (the suffix is stripped on the wire), so they alias identically. +# GPT-5.6 "-pro" high-effort variants bill at the base tier's per-token rates +# (more tokens per task, not a higher rate); the Hermes-side "-900k" Codex +# picker variants are the same model with the suffix stripped on the wire. +# Alias both onto the base entries so the snapshot stays single-source. for _base_56 in ("gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"): - _OFFICIAL_DOCS_PRICING[("openai", f"{_base_56}-pro")] = _OFFICIAL_DOCS_PRICING[ - ("openai", _base_56) - ] - _OFFICIAL_DOCS_PRICING[("openai", f"{_base_56}-900k")] = _OFFICIAL_DOCS_PRICING[ - ("openai", _base_56) - ] + _OFFICIAL_DOCS_PRICING[("openai", f"{_base_56}-pro")] = _OFFICIAL_DOCS_PRICING[("openai", _base_56)] + _OFFICIAL_DOCS_PRICING[("openai", f"{_base_56}-900k")] = _OFFICIAL_DOCS_PRICING[("openai", _base_56)] del _base_56 -# The direct Gemini provider currently exposes preview IDs for these two -# models. Keep the official snapshot keyed by both their documented stable -# names and the provider's emitted IDs so a catalog selection is billable. +# The direct Gemini provider emits preview IDs for these two models; key the +# snapshot by both the documented stable name and the emitted ID. for _alias, _canonical in { "gemini-3.1-pro-preview": "gemini-3.1-pro", "gemini-3.1-flash-lite-preview": "gemini-3.1-flash-lite", }.items(): - _OFFICIAL_DOCS_PRICING[("google", _alias)] = _OFFICIAL_DOCS_PRICING[ - ("google", _canonical) - ] + _OFFICIAL_DOCS_PRICING[("google", _alias)] = _OFFICIAL_DOCS_PRICING[("google", _canonical)] del _alias, _canonical @@ -1053,12 +320,10 @@ def _to_int(value: Any) -> int: def _usage_get(obj: Any, name: str, default: Any = 0) -> Any: - """Read a field from a usage object that may be a dict or an attribute object. + """Read a usage field from either a dict or an attribute object. - The Responses API can return usage as either a typed SDK object (accessible - via ``getattr``) or a plain ``dict`` (from JSON deserialisation). Using - ``getattr`` on a dict silently yields the default, zeroing out all token - counts. This helper normalises access so both shapes work transparently. + The Responses API returns usage as a typed SDK object OR a plain dict; + ``getattr`` on a dict silently yields the default and zeroes every count. """ if isinstance(obj, dict): return obj.get(name, default) @@ -1066,14 +331,28 @@ def _usage_get(obj: Any, name: str, default: Any = 0) -> Any: def _usage_count(value: Any) -> int: - """Coerce a usage counter to a non-negative integer. - - Providers occasionally emit malformed negative counters; clamp them to 0 - so a bad field cannot corrupt session accounting (#85706). - """ + """Coerce a usage counter to a non-negative int (providers occasionally + emit negative counters; clamp so they cannot corrupt session accounting).""" return max(0, _to_int(value)) +def _usage_field(obj: Any, name: str, *path: str) -> int: + """``_usage_count`` of ``obj.name[.path...]``; 0 if any hop is falsy.""" + for hop in (name, *path): + if not obj: + return 0 + obj = _usage_get(obj, hop, 0) + return _usage_count(obj) + + +def _first_nonzero(obj: Any, *paths: tuple[str, ...]) -> int: + """First non-zero ``_usage_field`` across candidate paths, else 0.""" + for path in paths: + value = _usage_field(obj, *path) + if value: + return value + return 0 + def resolve_billing_route( model_name: str, @@ -1089,77 +368,57 @@ def resolve_billing_route( provider_name = inferred_provider model = bare_model + url = base_url or "" + bare = model.split("/")[-1] + + def host(name: str) -> bool: + return base_url_host_matches(url, name) + if provider_name == "openai-codex": - return BillingRoute(provider="openai-codex", model=model, base_url=base_url or "", billing_mode="subscription_included") - if provider_name == "openrouter" or base_url_host_matches(base_url or "", "openrouter.ai"): - return BillingRoute(provider="openrouter", model=model, base_url=base_url or "", billing_mode="official_models_api") - if provider_name == "nous" or base_url_host_matches(base_url or "", "inference-api.nousresearch.com"): + return BillingRoute(provider="openai-codex", model=model, base_url=url, billing_mode="subscription_included") + if provider_name == "openrouter" or host("openrouter.ai"): + return BillingRoute(provider="openrouter", model=model, base_url=url, billing_mode="official_models_api") + if provider_name == "nous" or host("inference-api.nousresearch.com"): return BillingRoute(provider="nous", model=model, base_url=base_url or _NOUS_DEFAULT_BASE_URL, billing_mode="official_models_api") if provider_name == "anthropic": - return BillingRoute(provider="anthropic", model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot") - # "openai-api" is the picker/registry slug for direct api.openai.com; it - # bills identically to bare "openai", so normalize it here — otherwise the - # ("openai", ) _OFFICIAL_DOCS_PRICING keys are unreachable from the - # openai-api provider path. + return BillingRoute(provider="anthropic", model=bare, base_url=url, billing_mode="official_docs_snapshot") + # "openai-api" is the picker slug for direct api.openai.com; it bills as + # bare "openai", whose keys the snapshot uses. if provider_name in {"openai", "openai-api"}: - return BillingRoute(provider="openai", model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot") + return BillingRoute(provider="openai", model=bare, base_url=url, billing_mode="official_docs_snapshot") if provider_name in {"minimax", "minimax-cn"}: - return BillingRoute(provider=provider_name, model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot") - # Google AI Studio (Gemini) and Vertex AI host the same Gemini models. - # Price them off the official docs snapshot — the pricing keys are - # keyed on provider='google', so normalize every Google-flavored - # provider name/host onto it. Strip the "google/" vendor prefix the - # Vertex OpenAI-compat endpoint requires so the pricing key matches. + return BillingRoute(provider=provider_name, model=bare, base_url=url, billing_mode="official_docs_snapshot") + # AI Studio and Vertex host the same Gemini models; the snapshot is keyed on + # provider='google', and the Vertex "google/" vendor prefix is stripped. if ( provider_name in {"google", "gemini", "vertex", "google-gemini", "google-ai-studio", "google-vertex", "vertex-ai"} - or base_url_host_matches(base_url or "", "aiplatform.googleapis.com") - or base_url_host_matches(base_url or "", "generativelanguage.googleapis.com") + or host("aiplatform.googleapis.com") + or host("generativelanguage.googleapis.com") ): - return BillingRoute(provider="google", model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot") - if provider_name == "fireworks" or base_url_host_matches(base_url or "", "api.fireworks.ai"): - # Fireworks model ids look like accounts/fireworks/models/; - # rsplit("/", 1)[-1] yields just which is what the dict keys on. - return BillingRoute(provider="fireworks", model=model.rsplit("/", 1)[-1], base_url=base_url or "", billing_mode="official_docs_snapshot") + return BillingRoute(provider="google", model=bare, base_url=url, billing_mode="official_docs_snapshot") + if provider_name == "fireworks" or host("api.fireworks.ai"): + # Fireworks ids look like accounts/fireworks/models/; keys use . + return BillingRoute(provider="fireworks", model=model.rsplit("/", 1)[-1], base_url=url, billing_mode="official_docs_snapshot") if provider_name in {"custom", "local"} or (base and base_url_hostname(base) in ("localhost", "127.0.0.1")): - return BillingRoute(provider=provider_name or "custom", model=model, base_url=base_url or "", billing_mode="unknown") - return BillingRoute(provider=provider_name or "unknown", model=model.split("/")[-1] if model else "", base_url=base_url or "", billing_mode="unknown") + return BillingRoute(provider=provider_name or "custom", model=model, base_url=url, billing_mode="unknown") + return BillingRoute(provider=provider_name or "unknown", model=bare if model else "", base_url=url, billing_mode="unknown") def _normalize_bedrock_model_name(model: str) -> str: """Normalize a Bedrock model id to its bare foundation-model form. - Bedrock cross-region inference profiles prefix the foundation model id - with a region scope (``us.`` / ``global.`` / ``eu.`` / ``apac.`` / ``au.`` - / ...), e.g. ``us.anthropic.claude-opus-4-7`` or - ``au.anthropic.claude-sonnet-4-5-20250929-v1:0``. The pricing table is - keyed on the bare ``anthropic.claude-*`` id, so the prefix must be - stripped before the lookup or every cross-region session prices as - unknown. Note Asia-Pacific uses ``apac.`` (a bare ``ap.`` never matches - an ``apac.*`` id) and Australia/New Zealand use ``au.``. Also normalizes - dot-notation version numbers (``4.7`` → ``4-7``) and the documented - trailing date, revision, and profile components (``-20250514-v1:0``). + Cross-region inference profiles prefix the id with a region scope + (``us.``/``global.``/``apac.``/``au.``/...); the pricing table is keyed on + the bare ``anthropic.claude-*`` id, so the prefix is stripped. Also maps + dotted versions (``4.7`` → ``4-7``) and strips only the documented + trailing date/revision/profile components (``-20250514-v1:0``). """ name = model.lower().strip() - for prefix in ( - "global.", - "us.", - "eu.", - "apac.", - "ap.", - "au.", - "jp.", - "ca.", - "sa.", - "me.", - "af.", - ): + for prefix in ("global.", "us.", "eu.", "apac.", "ap.", "au.", "jp.", "ca.", "sa.", "me.", "af."): if name.startswith(prefix): name = name[len(prefix):] break name = re.sub(r"(\d+)\.(\d+)", r"\1-\2", name) - # Bedrock inference profile IDs append these documented components to the - # foundation model ID. Strip only the trailing forms, not arbitrary model - # name continuations that could be a distinct SKU. name = re.sub(r":\d+$", "", name) name = re.sub(r"-v\d+$", "", name) name = re.sub(r"-\d{8}$", "", name) @@ -1167,39 +426,26 @@ def _normalize_bedrock_model_name(model: str) -> str: def _normalize_anthropic_model_name(model: str) -> str: - """Normalize Anthropic model name variants to canonical form. - - Handles: - - Dot notation: claude-opus-4.7 → claude-opus-4-7 - - Short aliases: claude-opus-4.7 → claude-opus-4-7 - - Strips anthropic/ prefix if present - """ + """Strip an ``anthropic/`` prefix and map dotted versions (4.7 → 4-7).""" name = model.lower().strip() if name.startswith("anthropic/"): name = name[len("anthropic/"):] - # Normalize dots to dashes in version numbers (e.g. 4.7 → 4-7, 4.6 → 4-6) - # But preserve the rest of the name structure - name = re.sub(r"(\d+)\.(\d+)", r"\1-\2", name) - return name + return re.sub(r"(\d+)\.(\d+)", r"\1-\2", name) def _lookup_official_docs_pricing(route: BillingRoute) -> Optional[PricingEntry]: model = route.model.lower() - # Direct lookup first entry = _OFFICIAL_DOCS_PRICING.get((route.provider, model)) if entry: return entry - # Try normalized name for Anthropic (handles dot-notation like opus-4.7) - if route.provider == "anthropic": - normalized = _normalize_anthropic_model_name(model) - if normalized != model: - entry = _OFFICIAL_DOCS_PRICING.get((route.provider, normalized)) - if entry: - return entry - # Bedrock cross-region inference profiles carry a region prefix - # (us./global./eu./...) that the bare pricing keys don't have. - if route.provider == "bedrock": - normalized = _normalize_bedrock_model_name(model) + # Anthropic dot-notation (opus-4.7) and Bedrock region-prefixed ids need + # normalizing before a second lookup. + normalize = { + "anthropic": _normalize_anthropic_model_name, + "bedrock": _normalize_bedrock_model_name, + }.get(route.provider) + if normalize: + normalized = normalize(model) if normalized != model: entry = _OFFICIAL_DOCS_PRICING.get((route.provider, normalized)) if entry: @@ -1242,16 +488,14 @@ def _pricing_entry_from_metadata( if prompt is None and completion is None and request is None: return None - def _per_token_to_per_million(value: Optional[Decimal]) -> Optional[Decimal]: - if value is None: - return None - return value * _ONE_MILLION + def _per_million(value: Optional[Decimal]) -> Optional[Decimal]: + return None if value is None else value * _ONE_MILLION return PricingEntry( - input_cost_per_million=_per_token_to_per_million(prompt), - output_cost_per_million=_per_token_to_per_million(completion), - cache_read_cost_per_million=_per_token_to_per_million(cache_read), - cache_write_cost_per_million=_per_token_to_per_million(cache_write), + input_cost_per_million=_per_million(prompt), + output_cost_per_million=_per_million(completion), + cache_read_cost_per_million=_per_million(cache_read), + cache_write_cost_per_million=_per_million(cache_write), request_cost=request, source="provider_models_api", source_url=source_url, @@ -1298,132 +542,74 @@ def normalize_usage( ) -> CanonicalUsage: """Normalize raw API response usage into canonical token buckets. - Handles three API shapes: - - Anthropic: input_tokens/output_tokens/cache_read_input_tokens/cache_creation_input_tokens - - Codex Responses: input_tokens includes cache tokens; input_tokens_details.cached_tokens separates them - - OpenAI Chat Completions: prompt_tokens includes cache tokens; prompt_tokens_details.cached_tokens separates them - - In both Codex and OpenAI modes, input_tokens is derived by subtracting cache - tokens from the total — the API contract is that input/prompt totals include - cached tokens and the details object breaks them out. + Three shapes: Anthropic (input/output/cache_read_input/cache_creation_input + tokens), Codex Responses and OpenAI Chat Completions. In the latter two the + input/prompt total INCLUDES cached tokens and the ``*_details`` object breaks + them out, so input_tokens is derived by subtraction. """ if not response_usage: return CanonicalUsage() provider_name = (provider or "").strip().lower() mode = (api_mode or "").strip().lower() + u = response_usage if mode == "anthropic_messages" or provider_name == "anthropic": - input_tokens = _usage_count(_usage_get(response_usage, "input_tokens", 0)) - output_tokens = _usage_count(_usage_get(response_usage, "output_tokens", 0)) - cache_read_tokens = _usage_count(_usage_get(response_usage, "cache_read_input_tokens", 0)) - cache_write_tokens = _usage_count( - _usage_get(response_usage, "cache_creation_input_tokens", 0) - ) + input_tokens = _usage_field(u, "input_tokens") + output_tokens = _usage_field(u, "output_tokens") + cache_read_tokens = _usage_field(u, "cache_read_input_tokens") + cache_write_tokens = _usage_field(u, "cache_creation_input_tokens") elif mode == "codex_responses": - input_total = _usage_count(_usage_get(response_usage, "input_tokens", 0)) - output_tokens = _usage_count(_usage_get(response_usage, "output_tokens", 0)) - details = _usage_get(response_usage, "input_tokens_details", None) - cache_read_tokens = _usage_count( - _usage_get(details, "cached_tokens", 0) if details else 0 + input_total = _usage_field(u, "input_tokens") + output_tokens = _usage_field(u, "output_tokens") + cache_read_tokens = _usage_field(u, "input_tokens_details", "cached_tokens") + # OpenAI's documented GPT-5.6+ field is `cache_write_tokens` (billed at + # 1.25x); `cache_creation_tokens` is a fallback for older endpoints. + cache_write_tokens = _first_nonzero( + u, + ("input_tokens_details", "cache_write_tokens"), + ("input_tokens_details", "cache_creation_tokens"), ) - # OpenAI's documented field for GPT-5.6+ explicit cache writes is - # `cache_write_tokens` (billed at 1.25x); `cache_creation_tokens` is - # kept as a fallback for older/alternate Responses-compatible - # endpoints (#70543). - cache_write_tokens = _usage_count( - _usage_get(details, "cache_write_tokens", 0) if details else 0 - ) - if not cache_write_tokens: - cache_write_tokens = _usage_count( - _usage_get(details, "cache_creation_tokens", 0) if details else 0 - ) input_tokens = max(0, input_total - cache_read_tokens - cache_write_tokens) else: - # OpenAI-style names first; fall back to Anthropic-style - # (input_tokens/output_tokens). Local OpenAI-compatible servers like - # mlx_vlm.server emit the Anthropic names in chat_completions responses, - # and the OpenAI Python client preserves them as extra attributes. - prompt_total = _usage_count( - _usage_get(response_usage, "prompt_tokens", 0) - ) or _usage_count(_usage_get(response_usage, "input_tokens", 0)) - output_tokens = _usage_count( - _usage_get(response_usage, "completion_tokens", 0) - ) or _usage_count(_usage_get(response_usage, "output_tokens", 0)) - details = _usage_get(response_usage, "prompt_tokens_details", None) - # Primary: OpenAI-style prompt_tokens_details. Fallback: Anthropic-style - # top-level fields that some OpenAI-compatible proxies (OpenRouter, Vercel - # AI Gateway, Cline) expose when routing Claude models — without this - # fallback, cache writes are undercounted as 0 and cache reads can be - # missed when the proxy only surfaces them at the top level. - # Port of cline/cline#10266. - cache_read_tokens = _usage_count( - _usage_get(details, "cached_tokens", 0) if details else 0 + # OpenAI-style names first, then Anthropic-style: local OpenAI-compatible + # servers (e.g. mlx_vlm.server) emit input_tokens/output_tokens and the + # OpenAI client preserves them as extra attributes. + prompt_total = _first_nonzero(u, ("prompt_tokens",), ("input_tokens",)) + output_tokens = _first_nonzero(u, ("completion_tokens",), ("output_tokens",)) + # Cache reads: nested OpenAI shape, then Anthropic-style top-level fields + # exposed by proxies routing Claude (OpenRouter, Vercel AI Gateway, Cline), + # then DeepSeek's top-level prompt_cache_hit_tokens, then Kimi/Moonshot's + # top-level cached_tokens — without these, direct sessions show 0 hits + # and bill hits at the full input rate. + cache_read_tokens = _first_nonzero( + u, + ("prompt_tokens_details", "cached_tokens"), + ("cache_read_input_tokens",), + ("prompt_cache_hit_tokens",), + ("cached_tokens",), ) - if not cache_read_tokens: - cache_read_tokens = _usage_count( - _usage_get(response_usage, "cache_read_input_tokens", 0) - ) - if not cache_read_tokens: - # DeepSeek's native API (api.deepseek.com) reports context-cache - # hits as top-level prompt_cache_hit_tokens (+ the complementary - # prompt_cache_miss_tokens; prompt_tokens = hit + miss), not the - # OpenAI nested shape. Without this, direct DeepSeek sessions - # always showed 0 cache-hit tokens (#61871). - cache_read_tokens = _usage_count( - _usage_get(response_usage, "prompt_cache_hit_tokens", 0) - ) - if not cache_read_tokens: - # Kimi/Moonshot's native API (api.moonshot.cn / .ai) reports - # context-cache hits as a top-level usage.cached_tokens, not the - # OpenAI nested prompt_tokens_details.cached_tokens shape. Without - # this, direct Kimi sessions always showed 0 cache-hit tokens and - # the hits were billed at the full input rate (#65722). - cache_read_tokens = _usage_count( - _usage_get(response_usage, "cached_tokens", 0) - ) - cache_write_tokens = _usage_count( - _usage_get(details, "cache_write_tokens", 0) if details else 0 + cache_write_tokens = _first_nonzero( + u, + ("prompt_tokens_details", "cache_write_tokens"), + ("prompt_tokens_details", "cache_creation_input_tokens"), + ("cache_creation_input_tokens",), + ("cache_write_tokens",), ) - if not cache_write_tokens: - cache_write_tokens = _usage_count( - _usage_get(details, "cache_creation_input_tokens", 0) - if details else 0 - ) - if not cache_write_tokens: - cache_write_tokens = _usage_count( - _usage_get(response_usage, "cache_creation_input_tokens", 0) - ) - if not cache_write_tokens: - cache_write_tokens = _usage_count( - _usage_get(response_usage, "cache_write_tokens", 0) - ) input_tokens = max(0, prompt_total - cache_read_tokens - cache_write_tokens) - reasoning_tokens = 0 - # Responses API shape: output_tokens_details.reasoning_tokens. - # Chat Completions shape (OpenAI, OpenRouter, DeepSeek, etc.): - # completion_tokens_details.reasoning_tokens. Reading only the former - # left reasoning_tokens=0 for every chat_completions reasoning model — - # hidden thinking was invisible in session accounting even though it - # dominates output spend on models like deepseek-v4-flash (measured: - # single calls burning 21K reasoning tokens to emit 500 visible tokens). - output_details = _usage_get(response_usage, "output_tokens_details", None) - if output_details: - reasoning_tokens = _usage_count(_usage_get(output_details, "reasoning_tokens", 0)) - if not reasoning_tokens: - completion_details = _usage_get(response_usage, "completion_tokens_details", None) - if completion_details: - reasoning_tokens = _usage_count( - _usage_get(completion_details, "reasoning_tokens", 0) - ) + # Responses API: output_tokens_details.reasoning_tokens. Chat Completions + # (OpenAI, OpenRouter, DeepSeek, ...): completion_tokens_details.reasoning_tokens. + # Hidden thinking dominates output spend on reasoning models, so read both. + reasoning_tokens = _first_nonzero( + u, + ("output_tokens_details", "reasoning_tokens"), + ("completion_tokens_details", "reasoning_tokens"), + ) - # Cache observability for MiniMax's Anthropic wire: on MiniMax-M3, - # usage.cache_read_input_tokens carries a constant +128 floor and - # cache_creation_input_tokens is always 0, so cache_read is NOT a - # reliable hit signal — the signal that survives is the input_tokens - # drop between consecutive calls. Standard level-gated logger.debug; - # enable via logging config to confirm cache behavior. + # On MiniMax-M3's Anthropic wire, cache_read_input_tokens carries a constant + # +128 floor and cache_creation is always 0, so cache_read is not a reliable + # hit signal; the input_tokens drop between consecutive calls is. # Docs: https://platform.minimax.io/docs/api-reference/text-prompt-caching if provider_name in {"minimax", "minimax-cn"} and mode == "anthropic_messages": logger.debug( @@ -1445,6 +631,10 @@ def normalize_usage( ) +def _unknown_cost(source: CostSource, *notes: str) -> CostResult: + return CostResult(amount_usd=None, status="unknown", source=source, label="n/a", notes=notes) + + def estimate_usage_cost( model_name: str, usage: CanonicalUsage, @@ -1466,22 +656,15 @@ def estimate_usage_cost( entry = get_pricing_entry(model_name, provider=provider, base_url=base_url, api_key=api_key) if not entry: - return CostResult(amount_usd=None, status="unknown", source="none", label="n/a") + return _unknown_cost("none") - notes: list[str] = [] - amount = _ZERO - - # Whole-request context-tier selection (e.g. Gemini Pro >200k prompts): - # once the prompt (input + cache read + cache write) exceeds the entry's - # threshold, the above-threshold rates apply to the entire request. Any - # tier rate left as None falls back to the base rate. + # Whole-request context tier (e.g. Gemini Pro >200k prompts): above the + # threshold the *_above rates apply to the entire request; None falls back. input_rate = entry.input_cost_per_million output_rate = entry.output_cost_per_million cache_read_rate = entry.cache_read_cost_per_million - if ( - entry.tier_threshold_tokens is not None - and usage.prompt_tokens > entry.tier_threshold_tokens - ): + cache_write_rate = entry.cache_write_cost_per_million + if entry.tier_threshold_tokens is not None and usage.prompt_tokens > entry.tier_threshold_tokens: if entry.input_cost_per_million_above is not None: input_rate = entry.input_cost_per_million_above if entry.output_cost_per_million_above is not None: @@ -1490,39 +673,27 @@ def estimate_usage_cost( cache_read_rate = entry.cache_read_cost_per_million_above if usage.input_tokens and input_rate is None: - return CostResult(amount_usd=None, status="unknown", source=entry.source, label="n/a") + return _unknown_cost(entry.source) if usage.output_tokens and output_rate is None: - return CostResult(amount_usd=None, status="unknown", source=entry.source, label="n/a") - if usage.cache_read_tokens: - if cache_read_rate is None: - return CostResult( - amount_usd=None, - status="unknown", - source=entry.source, - label="n/a", - notes=("cache-read pricing unavailable for route",), - ) - if usage.cache_write_tokens: - if entry.cache_write_cost_per_million is None: - return CostResult( - amount_usd=None, - status="unknown", - source=entry.source, - label="n/a", - notes=("cache-write pricing unavailable for route",), - ) + return _unknown_cost(entry.source) + if usage.cache_read_tokens and cache_read_rate is None: + return _unknown_cost(entry.source, "cache-read pricing unavailable for route") + if usage.cache_write_tokens and cache_write_rate is None: + return _unknown_cost(entry.source, "cache-write pricing unavailable for route") - if input_rate is not None: - amount += Decimal(usage.input_tokens) * input_rate / _ONE_MILLION - if output_rate is not None: - amount += Decimal(usage.output_tokens) * output_rate / _ONE_MILLION - if cache_read_rate is not None: - amount += Decimal(usage.cache_read_tokens) * cache_read_rate / _ONE_MILLION - if entry.cache_write_cost_per_million is not None: - amount += Decimal(usage.cache_write_tokens) * entry.cache_write_cost_per_million / _ONE_MILLION + amount = _ZERO + for tokens, rate in ( + (usage.input_tokens, input_rate), + (usage.output_tokens, output_rate), + (usage.cache_read_tokens, cache_read_rate), + (usage.cache_write_tokens, cache_write_rate), + ): + if rate is not None: + amount += Decimal(tokens) * rate / _ONE_MILLION if entry.request_cost is not None and usage.request_count: amount += Decimal(usage.request_count) * entry.request_cost + notes: list[str] = [] status: CostStatus = "estimated" label = format_cost_label(amount) if entry.source == "none" and amount == _ZERO: @@ -1550,17 +721,11 @@ def has_known_pricing( base_url: Optional[str] = None, api_key: Optional[str] = None, ) -> bool: - """Check whether we have pricing data for this model+route. - - Uses direct lookup instead of routing through the full estimation - pipeline — avoids creating dummy usage objects just to check status. - """ + """True if pricing data exists for this model+route (direct lookup, no dummy usage).""" route = resolve_billing_route(model_name, provider=provider, base_url=base_url) if route.billing_mode == "subscription_included": return True - entry = get_pricing_entry(model_name, provider=provider, base_url=base_url, api_key=api_key) - return entry is not None - + return get_pricing_entry(model_name, provider=provider, base_url=base_url, api_key=api_key) is not None def format_duration_compact(seconds: float) -> str: @@ -1573,8 +738,7 @@ def format_duration_compact(seconds: float) -> str: if hours < 24: remaining_min = int(minutes % 60) return f"{int(hours)}h {remaining_min}m" if remaining_min else f"{int(hours)}h" - days = hours / 24 - return f"{days:.1f}d" + return f"{hours / 24:.1f}d" def format_token_count_compact(value: int) -> str: @@ -1583,18 +747,10 @@ def format_token_count_compact(value: int) -> str: return str(int(value)) sign = "-" if value < 0 else "" - units = ((1_000_000_000, "B"), (1_000_000, "M"), (1_000, "K")) - for threshold, suffix in units: + for threshold, suffix in ((1_000_000_000, "B"), (1_000_000, "M"), (1_000, "K")): if abs_value >= threshold: scaled = abs_value / threshold - if scaled < 10: - text = f"{scaled:.2f}" - elif scaled < 100: - text = f"{scaled:.1f}" - else: - text = f"{scaled:.0f}" + text = f"{scaled:.2f}" if scaled < 10 else f"{scaled:.1f}" if scaled < 100 else f"{scaled:.0f}" if "." in text: text = text.rstrip("0").rstrip(".") return f"{sign}{text}{suffix}" - - return f"{value:,}" diff --git a/tests/agent/test_reasoning_effort_module.py b/tests/agent/test_reasoning_effort_module.py index dc04406a1f..16fdf1451c 100644 --- a/tests/agent/test_reasoning_effort_module.py +++ b/tests/agent/test_reasoning_effort_module.py @@ -16,7 +16,7 @@ supported vocabulary. The policy under test: import pytest from agent.reasoning_effort import ( - CODEX_RESPONSES_EFFORTS, + CODEX_GPT56_EFFORTS, EFFORT_LADDER, GLM52_EFFORTS, GLM52_OVERRIDES, @@ -137,8 +137,8 @@ class TestGlm52Vocabulary: class TestCodexVocabulary: def test_minimal_and_ultra(self): - assert clamp_effort("minimal", CODEX_RESPONSES_EFFORTS) == "low" - assert clamp_effort("ultra", CODEX_RESPONSES_EFFORTS) == "max" + assert clamp_effort("minimal", CODEX_GPT56_EFFORTS) == "low" + assert clamp_effort("ultra", CODEX_GPT56_EFFORTS) == "max" def test_per_model_max_support(self): """Live-verified (Aug 2026, #68365): 'max' is gpt-5.6-only — gpt-5.5