mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-23 16:36:23 +00:00
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:
commit
2c1d585002
24 changed files with 892 additions and 4 deletions
124
agent/billing_links.py
Normal file
124
agent/billing_links.py
Normal 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 "")
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
72
apps/desktop/src/components/billing-banner.tsx
Normal file
72
apps/desktop/src/components/billing-banner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -178,6 +178,15 @@ export const ja = defineLocale({
|
|||
`ソフトウェアレンダリングが有効です — リモートディスプレイを検出しました(${reason})。ちらつきを防ぐため GPU アクセラレーションは無効化されています。`
|
||||
},
|
||||
|
||||
billingBlock: {
|
||||
titleNous: 'Nous クレジットが不足しています',
|
||||
titleProvider: provider => `クレジット不足 — ${provider}`,
|
||||
fallbackMessage: 'アカウントのクレジットが不足しています。続行するにはクレジットを追加してください。',
|
||||
openBilling: '請求を開く',
|
||||
addCredits: 'クレジットを追加',
|
||||
dismiss: '閉じる'
|
||||
},
|
||||
|
||||
titlebar: {
|
||||
hideSidebar: 'サイドバーを非表示',
|
||||
showSidebar: 'サイドバーを表示',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: '顯示側邊欄',
|
||||
|
|
|
|||
|
|
@ -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: '显示侧边栏',
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
86
apps/desktop/src/store/billing-block.test.ts
Normal file
86
apps/desktop/src/store/billing-block.test.ts
Normal 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')
|
||||
})
|
||||
79
apps/desktop/src/store/billing-block.ts
Normal file
79
apps/desktop/src/store/billing-block.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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`). */
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export {
|
|||
} from './billing-policy'
|
||||
export type {
|
||||
BillingAutoReload,
|
||||
BillingBlock,
|
||||
BillingCardInfo,
|
||||
BillingChargeResponse,
|
||||
BillingChargeStatusResponse,
|
||||
|
|
|
|||
35
cli.py
35
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 <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.
|
||||
|
|
|
|||
103
tests/agent/test_billing_links.py
Normal file
103
tests/agent/test_billing_links.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' }]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
37
ui-tui/src/lib/billingDialog.test.ts
Normal file
37
ui-tui/src/lib/billingDialog.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
36
ui-tui/src/lib/billingDialog.ts
Normal file
36
ui-tui/src/lib/billingDialog.ts
Normal 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}`
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue