Merge pull request #69655 from NousResearch/bb/out-of-credits-ux

feat(billing): consistent out-of-credits UX across CLI, TUI, and desktop
This commit is contained in:
brooklyn! 2026-07-22 21:03:38 -05:00 committed by GitHub
commit 2c1d585002
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 892 additions and 4 deletions

124
agent/billing_links.py Normal file
View file

@ -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 "")

View file

@ -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 <model> --provider <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

View file

@ -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: <BillingBanner sessionId={sessionId} /> })
}
for (const group of groups) {
sections.push({
key: group.type,

View file

@ -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<string, string>())
// 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)

View file

@ -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)

View file

@ -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 (
<StatusRow
leading={<Codicon aria-hidden className="text-destructive/85" name="credit-card" size="0.8rem" />}
trailing={
<>
<Button
className="text-foreground/90 hover:text-foreground"
onClick={() => runBillingRecovery(block)}
size="micro"
type="button"
variant="text"
>
{billingCtaLabel(block, copy)}
</Button>
<Tip label={copy.dismiss}>
<Button
aria-label={copy.dismiss}
className="size-4 rounded-md text-muted-foreground/60 hover:text-foreground/90"
onClick={() => clearBillingBlock(sessionId)}
size="icon-xs"
type="button"
variant="ghost"
>
<Codicon name="close" size="0.75rem" />
</Button>
</Tip>
</>
}
trailingVisible
>
<span className="min-w-0 truncate text-[0.73rem] leading-4 text-foreground/92">
<span className="font-medium">{title}</span>
{message && <span className="text-muted-foreground/80"> · {message}</span>}
</span>
</StatusRow>
)
}

View file

@ -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',

View file

@ -178,6 +178,15 @@ export const ja = defineLocale({
`ソフトウェアレンダリングが有効です — リモートディスプレイを検出しました(${reason})。ちらつきを防ぐため GPU アクセラレーションは無効化されています。`
},
billingBlock: {
titleNous: 'Nous クレジットが不足しています',
titleProvider: provider => `クレジット不足 — ${provider}`,
fallbackMessage: 'アカウントのクレジットが不足しています。続行するにはクレジットを追加してください。',
openBilling: '請求を開く',
addCredits: 'クレジットを追加',
dismiss: '閉じる'
},
titlebar: {
hideSidebar: 'サイドバーを非表示',
showSidebar: 'サイドバーを表示',

View file

@ -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

View file

@ -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: '顯示側邊欄',

View file

@ -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: '显示侧边栏',

View file

@ -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 {

View file

@ -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> = {}): 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')
})

View file

@ -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<ActiveBillingBlock | null>(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
}

View file

@ -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`). */

View file

@ -6,6 +6,7 @@ export {
} from './billing-policy'
export type {
BillingAutoReload,
BillingBlock,
BillingCardInfo,
BillingChargeResponse,
BillingChargeStatusResponse,

35
cli.py
View file

@ -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 <model> --provider <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.

View file

@ -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)

View file

@ -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

View file

@ -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' }]

View file

@ -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
}

View file

@ -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'
}

View file

@ -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> = {}): 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')
})
})

View file

@ -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}`
}
}