diff --git a/agent/billing_links.py b/agent/billing_links.py new file mode 100644 index 000000000000..1e9320ebb45a --- /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 f31d3c54a333..e8034d32652b 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -307,6 +307,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 " @@ -314,12 +327,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, *, @@ -4284,6 +4309,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, @@ -4444,10 +4494,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: @@ -4487,6 +4541,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/desktop/src/app/chat/composer/status-stack/index.tsx b/apps/desktop/src/app/chat/composer/status-stack/index.tsx index e8107764d881..739080be8b85 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/index.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/index.tsx @@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom' import { blurComposerInput } from '@/app/chat/composer/focus' import { AGENTS_ROUTE } from '@/app/routes' +import { BillingBanner } from '@/components/billing-banner' import { composerDockCard } from '@/components/chat/composer-dock' import { StatusSection } from '@/components/chat/status-section' import { Button } from '@/components/ui/button' @@ -11,6 +12,7 @@ import { Codicon } from '@/components/ui/codicon' import { Tip, TipKeybindLabel } from '@/components/ui/tooltip' import { type Translations, useI18n } from '@/i18n' import { cn } from '@/lib/utils' +import { $billingBlock } from '@/store/billing-block' import { $statusItemsBySession, type ComposerStatusItem, @@ -70,6 +72,7 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro const itemsBySession = useStore($statusItemsBySession) const previewsBySession = useStore($previewStatusBySession) const scrolledUp = useStore($threadScrolledUp) + const billing = useStore($billingBlock) const groups = useMemo( () => groupStatusItems(sessionId ? (itemsBySession[sessionId] ?? []) : []), @@ -123,6 +126,13 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro const sections: { key: string; node: ReactNode }[] = [] + // Billing wall sits at the very top of the stack — it's the most important + // thing above the composer when the account is out of credits. Rendered here + // (not as a composer-disable) so slash commands stay usable. + if (billing && sessionId && billing.sessionId === sessionId) { + sections.push({ key: 'billing', node: }) + } + for (const group of groups) { sections.push({ key: group.type, diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index dae9c2f0d791..21e866c3f4d3 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -27,6 +27,7 @@ import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChat import { sessionMessagesSignature } from '@/lib/session-signatures' import { isMessagingSource } from '@/lib/session-source' import { latestSessionTodos } from '@/lib/todos' +import { $billingSettingsRequest } from '@/store/billing-block' import { setCronFocusJobId } from '@/store/cron' import { $pinnedSessionIds, pinSession, restoreWorktree, unpinSession } from '@/store/layout' import { $filePreviewTarget, $previewTarget } from '@/store/preview' @@ -125,6 +126,10 @@ export function ContribWiring({ children }: { children: ReactNode }) { const busyRef = useRef(false) const creatingSessionRef = useRef(false) + // Billing recovery routes to Settings → Billing from surfaces without router + // context (the sticky toast). The shell owns `navigate`, so it consumes the + // intent counter here; the ref skips the initial mount value. + const billingSettingsSeenRef = useRef(0) const messagingTranscriptSignatureRef = useRef(new Map()) // Stable identity for the whole callback surface (see WiringActions). Mutated // in place each render so memoized surfaces never re-render on churn. @@ -132,7 +137,20 @@ export function ContribWiring({ children }: { children: ReactNode }) { const gatewayState = useStore($gatewayState) const activeSessionId = useStore($activeSessionId) + const billingSettingsRequest = useStore($billingSettingsRequest) const currentCwd = useStore($currentCwd) + + useEffect(() => { + if (billingSettingsRequest === billingSettingsSeenRef.current) { + return + } + + billingSettingsSeenRef.current = billingSettingsRequest + + if (billingSettingsRequest > 0) { + navigate(`${SETTINGS_ROUTE}?tab=billing`) + } + }, [billingSettingsRequest, navigate]) const freshDraftReady = useStore($freshDraftReady) const resumeFailedSessionId = useStore($resumeFailedSessionId) const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts index 173e356958de..950cccf60a7b 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts @@ -1,3 +1,4 @@ +import type { BillingBlock } from '@hermes/shared' import type { HermesSkin } from '@hermes/shared/skin' import type { QueryClient } from '@tanstack/react-query' import { type MutableRefObject, useCallback, useEffect, useRef } from 'react' @@ -15,6 +16,7 @@ import { triggerHaptic } from '@/lib/haptics' import { modelOptionsQueryKey } from '@/lib/model-options' import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors' import { reconcileApprovalModeForProfile } from '@/store/approval-mode' +import { billingCtaLabel, clearBillingBlock, runBillingRecovery, setBillingBlock } from '@/store/billing-block' import { clearClarifyRequest, setClarifyRequest } from '@/store/clarify' import { setSessionCompacting } from '@/store/compaction' import { refreshBackgroundProcesses } from '@/store/composer-status' @@ -57,6 +59,50 @@ import type { ClientSessionState } from '../../../types' import { hasSessionInfoStatePatch, sessionInfoStatePatch, SUBAGENT_EVENT_TYPES, toTodoPayload } from './utils' +function firstBillingLine(text: string): string { + return (text || '').split('\n')[0]?.trim() ?? '' +} + +/** + * A turn failed on a billing wall (out of credits / payment required). The + * gateway forwards the structured descriptor built by `agent/billing_links.py`; + * we cache it per-session (drives the in-chat banner) AND raise one sticky, + * billing-specific toast — never the generic "Hermes error" — with a smart CTA + * (Nous → in-app Settings → Billing, other providers → their billing page). + */ +function surfaceBillingBlock(sessionId: string, raw: unknown): void { + if (!raw || typeof raw !== 'object') { + return + } + + const block = raw as BillingBlock + + if (typeof block.provider !== 'string') { + return + } + + setBillingBlock(sessionId, block) + + const ctaCopy = { + addCredits: translateNow('billingBlock.addCredits'), + openBilling: translateNow('billingBlock.openBilling') + } + + notify({ + // Collapse repeat walls from the same provider into one toast. + id: `billing-block:${block.provider}`, + kind: 'warning', + icon: 'credit-card', + title: block.is_nous + ? translateNow('billingBlock.titleNous') + : translateNow('billingBlock.titleProvider', block.provider_label), + message: firstBillingLine(block.message) || translateNow('billingBlock.fallbackMessage'), + // Sticky: a credit wall blocks every turn until resolved. + durationMs: 0, + action: { label: billingCtaLabel(block, ctaCopy), onClick: () => runBillingRecovery(block) } + }) +} + const COMPACTION_RESUME_EVENT_TYPES = new Set([ 'message.delta', 'message.interim', @@ -390,6 +436,9 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { setSessionCompacting(sessionId, false) compactedTurnRef.current.delete(sessionId) nativeSubagentSessionsRef.current.delete(sessionId) + // A fresh turn on this session optimistically clears its billing wall; + // if credits are still exhausted the next failure re-raises it. + clearBillingBlock(sessionId) if (isActiveEvent) { triggerHaptic('streamStart') @@ -513,6 +562,12 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { const finalText = coerceGatewayText(payload?.text) || coerceGatewayText(payload?.rendered) completeAssistantMessage(sessionId, finalText, payload?.response_previewed) + // Structured billing wall forwarded by the gateway (out of credits / + // payment required) — cache it + raise a billing-specific toast. + if (payload?.billing) { + surfaceBillingBlock(sessionId, payload.billing) + } + if (isActiveEvent) { setTurnStartedAt(null) diff --git a/apps/desktop/src/components/billing-banner.tsx b/apps/desktop/src/components/billing-banner.tsx new file mode 100644 index 000000000000..73f7107decbf --- /dev/null +++ b/apps/desktop/src/components/billing-banner.tsx @@ -0,0 +1,72 @@ +import { useStore } from '@nanostores/react' + +import { StatusRow } from '@/components/chat/status-row' +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { Tip } from '@/components/ui/tooltip' +import { useI18n } from '@/i18n' +import { $billingBlock, billingCtaLabel, clearBillingBlock, runBillingRecovery } from '@/store/billing-block' + +function firstLine(text: string): string { + return (text || '').split('\n')[0]?.trim() ?? '' +} + +/** + * Persistent, in-stack billing wall for THIS session. Rendered as a shared + * {@link StatusRow} — same chrome as its status-stack siblings, so it reads as + * one piece with the composer card (no bordered alert-in-a-card). It never + * disables the composer — slash commands (`/topup`, `/model`, `/login`) stay + * usable — it only offers recovery: Nous opens Settings → Billing in-app, other + * providers deep-link out. The sticky toast is the loud surface; this is the calm + * reminder that outlives it. + */ +export function BillingBanner({ sessionId }: { sessionId: null | string }) { + const active = useStore($billingBlock) + const { t } = useI18n() + + if (!active || !sessionId || active.sessionId !== sessionId) { + return null + } + + const { block } = active + const copy = t.billingBlock + const title = block.is_nous ? copy.titleNous : copy.titleProvider(block.provider_label) + const message = firstLine(block.message) || copy.fallbackMessage + + return ( + } + trailing={ + <> + + + + + + } + trailingVisible + > + + {title} + {message && · {message}} + + + ) +} diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 79351a815174..53802fcd8cdb 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -177,6 +177,15 @@ export const en: Translations = { `Software rendering active — remote display detected (${reason}). GPU acceleration is disabled to prevent flickering.` }, + billingBlock: { + titleNous: 'Out of Nous credits', + titleProvider: provider => `Out of credits — ${provider}`, + fallbackMessage: 'Your account is out of credits. Add credits to keep going.', + openBilling: 'Open billing', + addCredits: 'Add credits', + dismiss: 'Dismiss' + }, + titlebar: { hideSidebar: 'Hide sidebar', showSidebar: 'Show sidebar', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index afa02b44bf8b..c712198e6132 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -178,6 +178,15 @@ export const ja = defineLocale({ `ソフトウェアレンダリングが有効です — リモートディスプレイを検出しました(${reason})。ちらつきを防ぐため GPU アクセラレーションは無効化されています。` }, + billingBlock: { + titleNous: 'Nous クレジットが不足しています', + titleProvider: provider => `クレジット不足 — ${provider}`, + fallbackMessage: 'アカウントのクレジットが不足しています。続行するにはクレジットを追加してください。', + openBilling: '請求を開く', + addCredits: 'クレジットを追加', + dismiss: '閉じる' + }, + titlebar: { hideSidebar: 'サイドバーを非表示', showSidebar: 'サイドバーを表示', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 4ba7fc84c38c..18f96afb90d1 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -218,6 +218,15 @@ export interface Translations { message: (reason: string) => string } + billingBlock: { + titleNous: string + titleProvider: (provider: string) => string + fallbackMessage: string + openBilling: string + addCredits: string + dismiss: string + } + titlebar: { hideSidebar: string showSidebar: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index e3c382a8ef59..c19c10bb4769 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -172,6 +172,15 @@ export const zhHant = defineLocale({ message: reason => `軟體繪圖已啟用 — 偵測到遠端顯示(${reason})。為防止畫面閃爍,已停用 GPU 加速。` }, + billingBlock: { + titleNous: 'Nous 額度已用盡', + titleProvider: provider => `額度已用盡 — ${provider}`, + fallbackMessage: '您的帳戶額度已用盡。請儲值以繼續使用。', + openBilling: '開啟帳單', + addCredits: '新增額度', + dismiss: '忽略' + }, + titlebar: { hideSidebar: '隱藏側邊欄', showSidebar: '顯示側邊欄', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 578a6faa7409..8515ec15f429 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -172,6 +172,15 @@ export const zh: Translations = { message: reason => `软件渲染已启用 — 检测到远程显示(${reason})。为防止画面闪烁,已禁用 GPU 加速。` }, + billingBlock: { + titleNous: 'Nous 额度已用尽', + titleProvider: provider => `额度已用尽 — ${provider}`, + fallbackMessage: '您的账户额度已用尽。请充值以继续使用。', + openBilling: '打开账单', + addCredits: '添加额度', + dismiss: '忽略' + }, + titlebar: { hideSidebar: '隐藏侧边栏', showSidebar: '显示侧边栏', diff --git a/apps/desktop/src/lib/chat-messages.ts b/apps/desktop/src/lib/chat-messages.ts index 74beafcffd60..828021b21eaa 100644 --- a/apps/desktop/src/lib/chat-messages.ts +++ b/apps/desktop/src/lib/chat-messages.ts @@ -1,4 +1,5 @@ import type { ThreadMessageLike } from '@assistant-ui/react' +import type { BillingBlock } from '@hermes/shared' import { dedupeGeneratedImageEchoesInParts } from '@/lib/generated-images' import { mediaDisplayLabel, mediaMarkdownHref } from '@/lib/media' @@ -96,6 +97,10 @@ export type GatewayEventPayload = { // message.complete — signals the final text was already previewed via // interim_assistant_callback, so the UI can settle instead of duplicating. response_previewed?: boolean + // Structured billing wall forwarded on message.complete when a turn fails + // with FailoverReason.billing (shape mirrors @hermes/shared BillingBlock). + billing?: BillingBlock + failure_reason?: string } export function textPart(text: string): ChatMessagePart { diff --git a/apps/desktop/src/store/billing-block.test.ts b/apps/desktop/src/store/billing-block.test.ts new file mode 100644 index 000000000000..24d2f97402f1 --- /dev/null +++ b/apps/desktop/src/store/billing-block.test.ts @@ -0,0 +1,86 @@ +import type { BillingBlock } from '@hermes/shared' +import { beforeEach, expect, test, vi } from 'vitest' + +vi.mock('@/lib/external-link', () => ({ openExternalLink: vi.fn() })) + +import { openExternalLink } from '@/lib/external-link' + +import { + $billingBlock, + $billingSettingsRequest, + billingCtaLabel, + clearBillingBlock, + requestBillingSettings, + runBillingRecovery, + setBillingBlock +} from './billing-block' + +function makeBlock(overrides: Partial = {}): BillingBlock { + return { + billing_url: 'https://platform.openai.com/settings/organization/billing', + is_nous: false, + message: 'You are out of credits.', + model: 'gpt-5', + provider: 'openai', + provider_label: 'OpenAI', + ...overrides + } +} + +beforeEach(() => { + $billingBlock.set(null) + $billingSettingsRequest.set(0) + vi.clearAllMocks() +}) + +test('setBillingBlock stores the block against its session', () => { + setBillingBlock('s1', makeBlock()) + expect($billingBlock.get()?.sessionId).toBe('s1') + expect($billingBlock.get()?.block.provider).toBe('openai') +}) + +test('clearBillingBlock scoped to a session leaves a different session block intact', () => { + setBillingBlock('s1', makeBlock()) + clearBillingBlock('s2') + expect($billingBlock.get()).not.toBeNull() + + clearBillingBlock('s1') + expect($billingBlock.get()).toBeNull() +}) + +test('clearBillingBlock with no arg clears any active block', () => { + setBillingBlock('s1', makeBlock()) + clearBillingBlock() + expect($billingBlock.get()).toBeNull() +}) + +test('runBillingRecovery routes Nous to in-app Settings, never an external link', () => { + runBillingRecovery(makeBlock({ is_nous: true, provider: 'nous', provider_label: 'Nous Portal' })) + expect($billingSettingsRequest.get()).toBe(1) + expect(openExternalLink).not.toHaveBeenCalled() +}) + +test('runBillingRecovery deep-links a third-party provider to its billing page', () => { + const block = makeBlock({ billing_url: 'https://openrouter.ai/settings/credits', provider: 'openrouter' }) + runBillingRecovery(block) + expect(openExternalLink).toHaveBeenCalledWith('https://openrouter.ai/settings/credits') + expect($billingSettingsRequest.get()).toBe(0) +}) + +test('runBillingRecovery falls back to in-app settings when a provider has no URL', () => { + runBillingRecovery(makeBlock({ billing_url: null, provider: 'custom' })) + expect(openExternalLink).not.toHaveBeenCalled() + expect($billingSettingsRequest.get()).toBe(1) +}) + +test('requestBillingSettings increments the intent counter', () => { + requestBillingSettings() + requestBillingSettings() + expect($billingSettingsRequest.get()).toBe(2) +}) + +test('billingCtaLabel picks the right verb per route', () => { + const copy = { addCredits: 'Add credits', openBilling: 'Open billing' } + expect(billingCtaLabel(makeBlock({ is_nous: true }), copy)).toBe('Open billing') + expect(billingCtaLabel(makeBlock({ is_nous: false }), copy)).toBe('Add credits') +}) diff --git a/apps/desktop/src/store/billing-block.ts b/apps/desktop/src/store/billing-block.ts new file mode 100644 index 000000000000..4d640302b637 --- /dev/null +++ b/apps/desktop/src/store/billing-block.ts @@ -0,0 +1,79 @@ +import type { BillingBlock } from '@hermes/shared' +import { atom } from 'nanostores' + +import { openExternalLink } from '@/lib/external-link' + +/** + * The active inference billing wall, if any. Set from the gateway + * `message.complete` / `error` event when a turn fails with + * `FailoverReason.billing` (see `agent/billing_links.py`). One global slot: a + * credit wall on the active session's provider is the whole app's problem, and + * the newest block wins. Cleared when a new turn starts or the user dismisses. + */ +export interface ActiveBillingBlock { + block: BillingBlock + sessionId: string + at: number +} + +export const $billingBlock = atom(null) + +/** + * Navigation intent counter. A toast fired outside React (or any surface + * without router context) bumps this to ask the shell — which owns + * `useNavigate` — to open Settings → Billing in-app. See `contrib/wiring.tsx`. + */ +export const $billingSettingsRequest = atom(0) + +export function setBillingBlock(sessionId: string, block: BillingBlock): void { + $billingBlock.set({ at: Date.now(), block, sessionId }) +} + +export function clearBillingBlock(sessionId?: string): void { + const current = $billingBlock.get() + + if (!current) { + return + } + + // A scoped clear (new turn on session X) must not wipe a block raised by a + // different session's provider. + if (sessionId && current.sessionId !== sessionId) { + return + } + + $billingBlock.set(null) +} + +export function requestBillingSettings(): void { + $billingSettingsRequest.set($billingSettingsRequest.get() + 1) +} + +/** + * The single recovery action for a billing wall, shared by the toast and the + * in-chat banner so both behave identically: Nous routes to the in-app + * Settings → Billing surface; a third-party provider deep-links to its own + * billing page (falling back to the in-app surface only if we have no URL). + */ +export function runBillingRecovery(block: BillingBlock): void { + if (block.is_nous) { + requestBillingSettings() + + return + } + + if (block.billing_url) { + openExternalLink(block.billing_url) + + return + } + + requestBillingSettings() +} + +export function billingCtaLabel( + block: BillingBlock, + copy: { addCredits: string; openBilling: string } +): string { + return block.is_nous ? copy.openBilling : copy.addCredits +} diff --git a/apps/shared/src/billing-types.ts b/apps/shared/src/billing-types.ts index d8d7415c7bb3..9a7801c22334 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 b951bc4700e9..09e10e40052e 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/cli.py b/cli.py index 62bab605a24f..352e0217b2d7 100644 --- a/cli.py +++ b/cli.py @@ -12685,6 +12685,41 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): width=self._scrollback_box_width(), )) + # Durable, provider-agnostic billing CTA below the response. The + # response panel carries the full guidance; this pins the single + # action to take (Nous → /topup, other providers → their billing + # page) so it stays visible instead of scrolling away as prose. + if result and result.get("failure_reason") == "billing": + _bb = result.get("billing_block") or {} + _prov_label = _bb.get("provider_label") or "your provider" + if _bb.get("is_nous"): + _cta_lines = [ + "Run [bold]/topup[/] to add credits, or " + "[bold]/subscription[/] to change plan.", + ] + else: + _url = _bb.get("billing_url") + _cta_lines = [ + f"Add credits with {_prov_label}" + + (f": [bold]{_url}[/]" if _url else ".") + ] + _cta_lines.append( + "Or switch providers with " + "[bold]/model --provider [/]." + ) + try: + ChatConsole().print(Panel( + "\n".join(_cta_lines), + title="[#CD7F32 bold]⚡ Out of credits[/]", + title_align="left", + border_style="#CD7F32", + box=rich_box.HORIZONTALS, + padding=(1, 4), + width=self._scrollback_box_width(), + )) + except Exception: + pass + # Play terminal bell when agent finishes (if enabled). # Works over SSH — the bell propagates to the user's terminal. diff --git a/tests/agent/test_billing_links.py b/tests/agent/test_billing_links.py new file mode 100644 index 000000000000..2c17853ea59c --- /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 e2057ff5db3b..7adbd8ba8c46 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -10661,6 +10661,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 diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index fdd12d7b1528..42bf75e74d25 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -94,6 +94,62 @@ describe('createGatewayEventHandler', () => { expect(getTurnState().todos).toEqual([]) }) + it('opens a billing confirm dialog routing Nous to /topup', () => { + const appended: Msg[] = [] + const ctx = buildCtx(appended) + const onEvent = createGatewayEventHandler(ctx) + + onEvent({ + payload: { + billing: { + billing_url: null, + is_nous: true, + message: 'out of credits', + model: 'm', + provider: 'nous', + provider_label: 'Nous Portal' + }, + text: 'Billing or credits exhausted: ...' + }, + type: 'message.complete' + } as any) + + const { confirm } = getOverlayState() + expect(confirm?.title).toContain('Nous') + expect(confirm?.confirmLabel).toBe('Top up') + + confirm!.onConfirm() + expect(ctx.submission.submitRef.current).toHaveBeenCalledWith('/topup') + }) + + it('deep-links a third-party provider billing page from the confirm dialog', () => { + const appended: Msg[] = [] + const ctx = buildCtx(appended) + const onEvent = createGatewayEventHandler(ctx) + openExternalUrlMock.mockClear() + + onEvent({ + payload: { + billing: { + billing_url: 'https://openrouter.ai/settings/credits', + is_nous: false, + message: 'out of credits', + model: 'm', + provider: 'openrouter', + provider_label: 'OpenRouter' + }, + text: 'Billing or credits exhausted: ...' + }, + type: 'message.complete' + } as any) + + const { confirm } = getOverlayState() + expect(confirm?.confirmLabel).toBe('Open billing page') + + confirm!.onConfirm() + expect(openExternalUrlMock).toHaveBeenCalledWith('https://openrouter.ai/settings/credits') + }) + it('archives completed todos into transcript flow at end of turn', () => { const appended: Msg[] = [] const todos = [{ content: 'Serve tiny latte', id: 'serve', status: 'completed' }] diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index fccb499b0e4e..8527e083a580 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -13,6 +13,7 @@ import type { GatewaySkin, SessionMostRecentResponse } from '../gatewayTypes.js' +import { billingDialogCopy } from '../lib/billingDialog.js' import { relativeLuminance } from '../lib/color.js' import { isTodoDone } from '../lib/liveProgress.js' import { openExternalUrl } from '../lib/openExternalUrl.js' @@ -1278,6 +1279,35 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: patchUiState(state => ({ ...state, usage: { ...state.usage, ...ev.payload!.usage } })) } + // Billing wall (out of credits / payment required): open a proper + // confirm dialog with the one recovery action, not a truncating status + // notice. The transcript already carries the full provider guidance; + // this is the actionable layer. Set AFTER recordMessageComplete() so the + // turn-idle resetFlowOverlays() (which clears `confirm`) can't wipe it; + // the top-of-loop guard already scopes this to the active session. + if (ev.payload?.billing) { + const block = ev.payload.billing + const copy = billingDialogCopy(block) + + patchOverlayState({ + confirm: { + cancelLabel: copy.cancelLabel, + confirmLabel: copy.confirmLabel, + detail: copy.detail, + onConfirm: () => { + if (block.is_nous) { + submitRef.current('/topup') + } else if (block.billing_url) { + openExternalUrl(block.billing_url) + } else { + submitRef.current('/model') + } + }, + title: copy.title + } + }) + } + return } diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 8ef5ba46fc89..b9cc8f999cb8 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -1,4 +1,4 @@ -import type { UsageModelData } from '@hermes/shared/billing' +import type { BillingBlock, UsageModelData } from '@hermes/shared/billing' import type { HermesSkin } from '@hermes/shared/skin' import type { SessionInfo, SlashCategory, SubagentStatus, Usage } from './types.js' @@ -46,6 +46,7 @@ export interface SlashExecResponse { // Wire shapes now live in @hermes/shared for reuse by TypeScript clients. export type { BillingAutoReload, + BillingBlock, BillingCardInfo, BillingChargeResponse, BillingChargeStatusResponse, @@ -660,7 +661,15 @@ export type GatewayEvent = type: 'message.interim' } | { - payload?: { reasoning?: string; rendered?: string; response_previewed?: boolean; text?: string; usage?: Usage } + payload?: { + billing?: BillingBlock + failure_reason?: string + reasoning?: string + rendered?: string + response_previewed?: boolean + text?: string + usage?: Usage + } session_id?: string type: 'message.complete' } diff --git a/ui-tui/src/lib/billingDialog.test.ts b/ui-tui/src/lib/billingDialog.test.ts new file mode 100644 index 000000000000..ae30b72b4bd7 --- /dev/null +++ b/ui-tui/src/lib/billingDialog.test.ts @@ -0,0 +1,37 @@ +import type { BillingBlock } from '@hermes/shared/billing' +import { describe, expect, it } from 'vitest' + +import { billingDialogCopy } from './billingDialog.js' + +function makeBlock(overrides: Partial = {}): BillingBlock { + return { + billing_url: 'https://openrouter.ai/settings/credits', + is_nous: false, + message: 'out of credits', + model: 'x', + provider: 'openrouter', + provider_label: 'OpenRouter', + ...overrides + } +} + +describe('billingDialogCopy', () => { + it('routes Nous to the /topup flow', () => { + const copy = billingDialogCopy(makeBlock({ is_nous: true, provider: 'nous', provider_label: 'Nous Portal' })) + expect(copy.title).toContain('Nous') + expect(copy.confirmLabel).toBe('Top up') + expect(copy.cancelLabel).toBe('Dismiss') + }) + + it('offers to open a third-party provider billing page', () => { + const copy = billingDialogCopy(makeBlock()) + expect(copy.title).toContain('OpenRouter') + expect(copy.confirmLabel).toBe('Open billing page') + }) + + it('falls back to switching providers when there is no URL', () => { + const copy = billingDialogCopy(makeBlock({ billing_url: null, provider_label: 'DeepSeek' })) + expect(copy.title).toContain('DeepSeek') + expect(copy.confirmLabel).toBe('Switch provider') + }) +}) diff --git a/ui-tui/src/lib/billingDialog.ts b/ui-tui/src/lib/billingDialog.ts new file mode 100644 index 000000000000..9b7e7b8b4013 --- /dev/null +++ b/ui-tui/src/lib/billingDialog.ts @@ -0,0 +1,36 @@ +import type { BillingBlock } from '@hermes/shared/billing' + +export interface BillingDialogCopy { + cancelLabel: string + confirmLabel: string + detail: string + title: string +} + +/** + * Copy for the out-of-credits confirm dialog (the TUI's billing wall). The + * dialog is the actionable layer — the full provider guidance already lands in + * the transcript — so `detail` stays to one concise, non-truncating line and the + * confirm button carries the recovery: Nous → `/topup`, other providers → their + * billing page (or `/model` to switch when we have no URL). Pure + exported so + * the wording is unit-tested without driving the gateway. + */ +export function billingDialogCopy(block: BillingBlock): BillingDialogCopy { + if (block.is_nous) { + return { + cancelLabel: 'Dismiss', + confirmLabel: 'Top up', + detail: 'Your Nous credit balance is exhausted — top up to keep going.', + title: 'Out of Nous credits' + } + } + + const label = block.provider_label || 'your provider' + + return { + cancelLabel: 'Dismiss', + confirmLabel: block.billing_url ? 'Open billing page' : 'Switch provider', + detail: `${label} reports your credits or billing are exhausted.`, + title: `Out of credits · ${label}` + } +}