diff --git a/agent/billing_links.py b/agent/billing_links.py new file mode 100644 index 00000000000..1e9320ebb45 --- /dev/null +++ b/agent/billing_links.py @@ -0,0 +1,124 @@ +"""Provider-agnostic billing/credit recovery links. + +Maps a billing-classified failure onto a recovery link + label. *Detection* +is not done here — that is :mod:`agent.error_classifier` +(``FailoverReason.billing``), the single source of truth for "credit wall vs. +rate limit / auth / transport". The resulting :class:`BillingBlock` rides the +turn result and the gateway ``message.complete`` event so every surface (CLI, +TUI, desktop) renders one structured signal instead of re-parsing error text. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass +from typing import Optional + +from utils import base_url_host_matches + + +@dataclass +class BillingBlock: + """Structured billing-wall descriptor shared across every surface. + + ``is_nous`` is the routing bit: Nous has a first-class in-app billing surface + (desktop Settings → Billing, TUI/CLI ``/topup``), so surfaces prefer that over + ``billing_url``; third-party providers have no in-app flow, so ``billing_url`` + is the deep link the user actually needs. + """ + + provider: str + provider_label: str + model: str + billing_url: Optional[str] + is_nous: bool + message: str + + def to_dict(self) -> dict: + return asdict(self) + + +@dataclass(frozen=True) +class _Provider: + label: str + url: str + slugs: tuple[str, ...] + hosts: tuple[str, ...] = () + + +# Single source of truth: internal slug(s) + base_url host(s) → billing page. +# Curated "add credits / manage billing" landing pages, not marketing homes. +# Hosts back the OpenAI-compatible fallback where the slug is a generic bucket +# (e.g. "openai_compatible") but base_url reveals the real upstream. An unknown +# provider degrades to a readable label with no invented URL. +_PROVIDERS: tuple[_Provider, ...] = ( + _Provider("OpenAI", "https://platform.openai.com/settings/organization/billing", ("openai",), ("api.openai.com",)), + _Provider("Anthropic", "https://console.anthropic.com/settings/billing", ("anthropic",), ("api.anthropic.com",)), + _Provider("OpenRouter", "https://openrouter.ai/settings/credits", ("openrouter",), ("openrouter.ai",)), + _Provider("xAI", "https://console.x.ai/team/default/billing", ("xai", "xai-oauth"), ("api.x.ai",)), + _Provider("DeepSeek", "https://platform.deepseek.com/top_up", ("deepseek",), ("api.deepseek.com",)), + _Provider("Groq", "https://console.groq.com/settings/billing", ("groq",), ("api.groq.com",)), + _Provider("Mistral", "https://console.mistral.ai/billing", ("mistral",), ("api.mistral.ai",)), + _Provider("Together AI", "https://api.together.ai/settings/billing", ("together",), ("api.together.ai", "api.together.xyz")), + _Provider("Fireworks AI", "https://fireworks.ai/account/billing", ("fireworks",), ("fireworks.ai",)), + _Provider("Perplexity", "https://www.perplexity.ai/settings/api", ("perplexity",), ("perplexity.ai",)), + _Provider("Google AI", "https://aistudio.google.com/app/billing", ("google", "gemini"), ("generativelanguage.googleapis.com",)), + _Provider("Cohere", "https://dashboard.cohere.com/billing", ("cohere",)), + _Provider("Moonshot AI", "https://platform.moonshot.ai/console/pay", ("moonshot",)), + _Provider("NVIDIA", "https://build.nvidia.com/settings/billing", ("nvidia",)), +) + +_BY_SLUG: dict[str, _Provider] = {slug: p for p in _PROVIDERS for slug in p.slugs} + + +def is_nous_inference_route(provider: str, base_url: str) -> bool: + """True when the failing route is the Nous-managed inference gateway.""" + if (provider or "").strip().lower() == "nous": + return True + return base_url_host_matches(str(base_url or ""), "inference-api.nousresearch.com") + + +def _nous_billing_url() -> Optional[str]: + """Best-effort Nous portal billing URL (text-surface fallback; Nous prefers the in-app flow).""" + try: + from hermes_cli.nous_account import nous_portal_billing_url + + return nous_portal_billing_url(None) + except Exception: + return "https://portal.nousresearch.com/billing" + + +def _resolve_provider_link(slug: str, base_url: str) -> tuple[str, Optional[str]]: + """Resolve ``(label, url)``: exact slug → base_url host → readable-label fallback.""" + hit = _BY_SLUG.get(slug) + if hit: + return hit.label, hit.url + + base = str(base_url or "") + for p in _PROVIDERS: + if any(base_url_host_matches(base, host) for host in p.hosts): + return p.label, p.url + + return slug.replace("_", " ").replace("-", " ").strip().title() or "your provider", None + + +def build_billing_block( + *, + provider: str, + base_url: str, + model: str, + message: str = "", +) -> BillingBlock: + """Build the billing descriptor for a billing-classified failure. + + ``message`` is the guidance already assembled by the agent loop + (:func:`agent.conversation_loop._billing_or_entitlement_message`), carried + through unchanged so every surface shows identical copy. + """ + slug = (provider or "").strip().lower() + model = (model or "").strip() + + if is_nous_inference_route(slug, base_url): + return BillingBlock(slug or "nous", "Nous Portal", model, _nous_billing_url(), True, message or "") + + label, url = _resolve_provider_link(slug, base_url) + return BillingBlock(slug, label, model, url, False, message or "") diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index fb9d11b66fc..22b1a6c213b 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -300,6 +300,19 @@ def _billing_or_entitlement_message( ] return "\n".join(lines) + # Provider-agnostic billing URL derivation (OpenAI, DeepSeek, xAI, Groq, + # OpenRouter, …) so every text surface — CLI, gateway messaging, TUI + # transcript — shows the same actionable link, not just OpenRouter. + try: + from agent.billing_links import build_billing_block + + _link = build_billing_block(provider=provider, base_url=base_url, model=model) + if _link.provider_label: + provider_label = _link.provider_label + billing_url = _link.billing_url + except Exception: + billing_url = None + lines = [ ( f"{provider_label} reported that billing, credits, or account " @@ -307,12 +320,24 @@ def _billing_or_entitlement_message( ), "Add credits or update billing with that provider, then retry.", ] - if base_url_host_matches(str(base_url or ""), "openrouter.ai"): - lines.append("OpenRouter credits: https://openrouter.ai/settings/credits") + if billing_url: + lines.append(f"{provider_label} billing: {billing_url}") lines.append("You can switch providers temporarily with /model --provider .") return "\n".join(lines) +def _billing_block_dict(provider, base_url, model, message="") -> Optional[dict]: + """Best-effort structured billing descriptor (None if billing_links is unavailable).""" + try: + from agent.billing_links import build_billing_block + + return build_billing_block( + provider=provider, base_url=str(base_url), model=model, message=message + ).to_dict() + except Exception: + return None + + def _print_billing_or_entitlement_guidance( agent, *, @@ -4275,6 +4300,31 @@ def run_conversation( final_response=_policy_response, error_detail=_nonretryable_summary, ) + # Billing walls are the common non-retryable abort: enrich + # the result with the same structured recovery descriptor as + # the max-retries path so every surface (CLI, TUI, desktop) + # renders one consistent billing signal. + if classified.reason == FailoverReason.billing: + _ce_guidance = _billing_or_entitlement_message( + capability="model access", + provider=_provider, + base_url=str(_base), + model=_model, + ) + _ce_final = f"Billing or credits exhausted: {_nonretryable_summary}" + if _ce_guidance: + _ce_final += f"\n\n{_ce_guidance}" + _ce_block = _billing_block_dict(_provider, _base, _model, _ce_guidance) + return { + "final_response": _ce_final, + "messages": messages, + "api_calls": api_call_count, + "completed": False, + "failed": True, + "error": _nonretryable_summary, + "failure_reason": classified.reason.value, + "billing_block": _ce_block, + } return { "final_response": _nonretryable_summary, "messages": messages, @@ -4435,10 +4485,14 @@ def run_conversation( api_kwargs, reason="max_retries_exhausted", error=api_error, ) agent._persist_session(messages, conversation_history) + _billing_block = None if classified.reason == FailoverReason.billing: _final_response = f"Billing or credits exhausted: {_final_summary}" if _billing_guidance: _final_response += f"\n\n{_billing_guidance}" + # Structured recovery descriptor so every surface renders + # the same link + label from one signal (see helper). + _billing_block = _billing_block_dict(_provider, _base, _model, _billing_guidance) else: _final_response = f"API call failed after {max_retries} retries: {_final_summary}" if _is_thinking_timeout: @@ -4478,6 +4532,9 @@ def run_conversation( # different exit code. ``rate_limit`` / ``billing`` here # mean "quota wall, not a task error". "failure_reason": classified.reason.value, + # Present only for billing walls: structured recovery + # descriptor (provider, billing_url, is_nous, message). + "billing_block": _billing_block, } # For rate limits, respect the Retry-After header if present diff --git a/apps/shared/src/billing-types.ts b/apps/shared/src/billing-types.ts index d8d7415c7bb..9a7801c2233 100644 --- a/apps/shared/src/billing-types.ts +++ b/apps/shared/src/billing-types.ts @@ -6,6 +6,29 @@ * gateway event union out of this runtime-free module. */ +// ── Billing wall (inference credit exhaustion) ─────────────────────── + +/** + * Structured billing-wall descriptor emitted by the gateway on the + * `message.complete` event (`payload.billing`) when an inference call fails + * because the account is out of credits / payment is required — mirrors the + * Python `agent/billing_links.py::BillingBlock`. + * + * Detection is backend-only (`agent/error_classifier.py` → + * `FailoverReason.billing`), so every surface renders from this one signal and + * never re-classifies free-form error text. `is_nous` routes recovery: Nous is + * the managed route with in-app billing (desktop Settings → Billing, TUI + * `/topup`), while third-party providers deep-link to `billing_url`. + */ +export interface BillingBlock { + provider: string + provider_label: string + model: string + billing_url: string | null + is_nous: boolean + message: string +} + // ── Remote Spending (Phase 2b) ─────────────────────────────────────── /** One serialized usage bar (mirrors server `_serialize_usage_bar`). */ diff --git a/apps/shared/src/index.ts b/apps/shared/src/index.ts index b951bc4700e..09e10e40052 100644 --- a/apps/shared/src/index.ts +++ b/apps/shared/src/index.ts @@ -6,6 +6,7 @@ export { } from './billing-policy' export type { BillingAutoReload, + BillingBlock, BillingCardInfo, BillingChargeResponse, BillingChargeStatusResponse, diff --git a/tests/agent/test_billing_links.py b/tests/agent/test_billing_links.py new file mode 100644 index 00000000000..2c17853ea59 --- /dev/null +++ b/tests/agent/test_billing_links.py @@ -0,0 +1,103 @@ +"""Tests for provider-agnostic billing recovery links (agent/billing_links.py). + +Behavior/invariant tests — no snapshotting of the exact URL strings beyond the +few that are the whole point of the mapping (the host they must land on). +""" + +from __future__ import annotations + +from agent.billing_links import ( + BillingBlock, + build_billing_block, + is_nous_inference_route, +) + + +def test_nous_route_by_provider_slug(): + block = build_billing_block(provider="nous", base_url="", model="hermes-4") + assert block.is_nous is True + assert block.provider_label == "Nous Portal" + # Nous always resolves an in-app/portal billing URL as a fallback. + assert block.billing_url and "nousresearch.com" in block.billing_url + + +def test_nous_route_by_base_url_host(): + block = build_billing_block( + provider="openai_compatible", + base_url="https://inference-api.nousresearch.com/v1", + model="hermes-4", + ) + assert block.is_nous is True + + +def test_is_nous_inference_route_helper(): + assert is_nous_inference_route("nous", "") is True + assert is_nous_inference_route("", "https://inference-api.nousresearch.com/v1") is True + assert is_nous_inference_route("openai", "https://api.openai.com/v1") is False + + +def test_known_provider_by_slug_resolves_label_and_url(): + block = build_billing_block(provider="openai", base_url="", model="gpt-5") + assert block.is_nous is False + assert block.provider_label == "OpenAI" + assert block.billing_url is not None + assert "openai.com" in block.billing_url + + +def test_openrouter_resolves_credits_page(): + block = build_billing_block( + provider="openrouter", + base_url="https://openrouter.ai/api/v1", + model="anthropic/claude", + ) + assert block.is_nous is False + assert block.billing_url is not None + assert "openrouter.ai" in block.billing_url + + +def test_unknown_provider_via_base_url_host_fallback(): + # Provider slug is a generic bucket; the host reveals the real upstream. + block = build_billing_block( + provider="custom", + base_url="https://api.deepseek.com/v1", + model="deepseek-chat", + ) + assert block.provider_label == "DeepSeek" + assert block.billing_url is not None + assert "deepseek.com" in block.billing_url + + +def test_unknown_provider_degrades_without_url(): + block = build_billing_block( + provider="my_local_llm", + base_url="http://localhost:1234/v1", + model="llama", + ) + assert block.is_nous is False + # No invented URL for an unknown provider — but a readable label survives. + assert block.billing_url is None + assert block.provider_label # non-empty, humanized + + +def test_message_is_carried_through_unchanged(): + block = build_billing_block( + provider="openai", + base_url="", + model="gpt-5", + message="You are out of credits.", + ) + assert block.message == "You are out of credits." + + +def test_to_dict_round_trips_all_fields(): + block = build_billing_block(provider="openai", base_url="", model="gpt-5") + data = block.to_dict() + assert set(data) == { + "provider", + "provider_label", + "model", + "billing_url", + "is_nous", + "message", + } + assert isinstance(block, BillingBlock) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 2e624336ba1..285258acb93 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -10608,6 +10608,13 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None: payload["warning"] = status_note if result.get("response_previewed"): payload["response_previewed"] = True + # Forward the structured billing-wall descriptor (provider, + # billing_url, is_nous, message) so the TUI/desktop render a + # billing-specific recovery surface instead of re-parsing text. + _billing_block = result.get("billing_block") if isinstance(result, dict) else None + if _billing_block: + payload["billing"] = _billing_block + payload["failure_reason"] = result.get("failure_reason") rendered = render_message(raw, cols) if rendered: payload["rendered"] = rendered