mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
feat(billing): shared cross-surface out-of-credits signal
Detect a billing wall once (agent/error_classifier → FailoverReason.billing) and map it to a recovery link + label in one place, then carry that structured BillingBlock to every surface instead of re-parsing free-form error text per surface. - agent/billing_links.py: provider-agnostic slug/host → (label, billing URL) table (single source of truth), Nous-aware (is_nous routes to the in-app flow); unknown providers degrade to a readable label with no invented URL. - conversation_loop: both billing exit paths return a billing_block through one helper; the guidance message carries the derived URL for every provider. - gateway forwards billing_block on message.complete (it was dropped). - @hermes/shared: BillingBlock type shared by desktop + TUI.
This commit is contained in:
parent
54aaff142e
commit
960d339f86
6 changed files with 317 additions and 2 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 "")
|
||||
|
|
@ -300,6 +300,19 @@ def _billing_or_entitlement_message(
|
|||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
# Provider-agnostic billing URL derivation (OpenAI, DeepSeek, xAI, Groq,
|
||||
# OpenRouter, …) so every text surface — CLI, gateway messaging, TUI
|
||||
# transcript — shows the same actionable link, not just OpenRouter.
|
||||
try:
|
||||
from agent.billing_links import build_billing_block
|
||||
|
||||
_link = build_billing_block(provider=provider, base_url=base_url, model=model)
|
||||
if _link.provider_label:
|
||||
provider_label = _link.provider_label
|
||||
billing_url = _link.billing_url
|
||||
except Exception:
|
||||
billing_url = None
|
||||
|
||||
lines = [
|
||||
(
|
||||
f"{provider_label} reported that billing, credits, or account "
|
||||
|
|
@ -307,12 +320,24 @@ def _billing_or_entitlement_message(
|
|||
),
|
||||
"Add credits or update billing with that provider, then retry.",
|
||||
]
|
||||
if base_url_host_matches(str(base_url or ""), "openrouter.ai"):
|
||||
lines.append("OpenRouter credits: https://openrouter.ai/settings/credits")
|
||||
if billing_url:
|
||||
lines.append(f"{provider_label} billing: {billing_url}")
|
||||
lines.append("You can switch providers temporarily with /model <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,
|
||||
*,
|
||||
|
|
@ -4275,6 +4300,31 @@ def run_conversation(
|
|||
final_response=_policy_response,
|
||||
error_detail=_nonretryable_summary,
|
||||
)
|
||||
# Billing walls are the common non-retryable abort: enrich
|
||||
# the result with the same structured recovery descriptor as
|
||||
# the max-retries path so every surface (CLI, TUI, desktop)
|
||||
# renders one consistent billing signal.
|
||||
if classified.reason == FailoverReason.billing:
|
||||
_ce_guidance = _billing_or_entitlement_message(
|
||||
capability="model access",
|
||||
provider=_provider,
|
||||
base_url=str(_base),
|
||||
model=_model,
|
||||
)
|
||||
_ce_final = f"Billing or credits exhausted: {_nonretryable_summary}"
|
||||
if _ce_guidance:
|
||||
_ce_final += f"\n\n{_ce_guidance}"
|
||||
_ce_block = _billing_block_dict(_provider, _base, _model, _ce_guidance)
|
||||
return {
|
||||
"final_response": _ce_final,
|
||||
"messages": messages,
|
||||
"api_calls": api_call_count,
|
||||
"completed": False,
|
||||
"failed": True,
|
||||
"error": _nonretryable_summary,
|
||||
"failure_reason": classified.reason.value,
|
||||
"billing_block": _ce_block,
|
||||
}
|
||||
return {
|
||||
"final_response": _nonretryable_summary,
|
||||
"messages": messages,
|
||||
|
|
@ -4435,10 +4485,14 @@ def run_conversation(
|
|||
api_kwargs, reason="max_retries_exhausted", error=api_error,
|
||||
)
|
||||
agent._persist_session(messages, conversation_history)
|
||||
_billing_block = None
|
||||
if classified.reason == FailoverReason.billing:
|
||||
_final_response = f"Billing or credits exhausted: {_final_summary}"
|
||||
if _billing_guidance:
|
||||
_final_response += f"\n\n{_billing_guidance}"
|
||||
# Structured recovery descriptor so every surface renders
|
||||
# the same link + label from one signal (see helper).
|
||||
_billing_block = _billing_block_dict(_provider, _base, _model, _billing_guidance)
|
||||
else:
|
||||
_final_response = f"API call failed after {max_retries} retries: {_final_summary}"
|
||||
if _is_thinking_timeout:
|
||||
|
|
@ -4478,6 +4532,9 @@ def run_conversation(
|
|||
# different exit code. ``rate_limit`` / ``billing`` here
|
||||
# mean "quota wall, not a task error".
|
||||
"failure_reason": classified.reason.value,
|
||||
# Present only for billing walls: structured recovery
|
||||
# descriptor (provider, billing_url, is_nous, message).
|
||||
"billing_block": _billing_block,
|
||||
}
|
||||
|
||||
# For rate limits, respect the Retry-After header if present
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
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)
|
||||
|
|
@ -10608,6 +10608,13 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
|
|||
payload["warning"] = status_note
|
||||
if result.get("response_previewed"):
|
||||
payload["response_previewed"] = True
|
||||
# Forward the structured billing-wall descriptor (provider,
|
||||
# billing_url, is_nous, message) so the TUI/desktop render a
|
||||
# billing-specific recovery surface instead of re-parsing text.
|
||||
_billing_block = result.get("billing_block") if isinstance(result, dict) else None
|
||||
if _billing_block:
|
||||
payload["billing"] = _billing_block
|
||||
payload["failure_reason"] = result.get("failure_reason")
|
||||
rendered = render_message(raw, cols)
|
||||
if rendered:
|
||||
payload["rendered"] = rendered
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue