Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions agent/billing_links.py
Original file line number Diff line number Diff line change
@@ -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 "")
61 changes: 59 additions & 2 deletions agent/conversation_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -300,19 +300,44 @@ 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 "
f"entitlement is exhausted for {model_label}."
),
"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,
*,
Expand Down Expand Up @@ -4275,6 +4300,31 @@ def _perform_api_call(next_api_kwargs):
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,
Expand Down Expand Up @@ -4435,10 +4485,14 @@ def _perform_api_call(next_api_kwargs):
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:
Expand Down Expand Up @@ -4478,6 +4532,9 @@ def _perform_api_call(next_api_kwargs):
# 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
Expand Down
10 changes: 10 additions & 0 deletions apps/desktop/src/app/chat/composer/status-stack/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ 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'
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,
Expand Down Expand Up @@ -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] ?? []) : []),
Expand Down Expand Up @@ -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,
Expand Down
18 changes: 18 additions & 0 deletions apps/desktop/src/app/contrib/wiring.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -126,14 +127,31 @@ 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.
const actionsRef = useRef<WiringActions | null>(null)

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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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)

Expand Down
Loading
Loading