fix(agent): name an exhausted (402) provider pool, not "No LLM provider configured"

Fixes #94785
This commit is contained in:
austinpickett
2026-09-27 04:09:11 -04:00
committed by Austin Pickett
parent fb2dded3d1
commit 516535b542
3 changed files with 336 additions and 1 deletions

View File

@@ -887,6 +887,7 @@ def _routed_client_kwargs(agent, fallback_model, _provider_timeout) -> Optional[
# A burned credential pool (#119533) is otherwise indistinguishable from missing config,
# so name it even when no fallback entries are configured.
_pool_exhausted = False
_pool = None
if _explicit and _explicit != "auto":
with suppress(Exception):
from agent.credential_pool import load_pool
@@ -901,6 +902,25 @@ def _routed_client_kwargs(agent, fallback_model, _provider_timeout) -> Optional[
"credential pool exhausted" if _pool_exhausted else "no usable credentials",
"; ".join(f"{_p} ({_r})" for _p, _r in _refused_entries) or "none configured",
)
# A fully-exhausted pool is a billing/quota failure, NOT a config problem: name it for EVERY
# explicit provider. ``openrouter`` / ``custom`` have no provider-specific missing-credentials
# branch, so a burned override pool there fell through to the generic "No LLM provider
# configured" setup message even though the config default was fine (#94785). Prefer a billing
# verdict (402 / classifier "billing") and fall back to the existing cooldown wording (which
# names the 429 reset time, #56810); raise before the missing-credentials branch so a genuine
# 402 is never described as a missing key or a transient rate limit.
if _pool_exhausted:
from agent.auxiliary_unavailable import (
ProviderCredentialsExhaustedError,
pool_billing_message,
pool_cooldown_message,
)
_exhausted_message = (
pool_billing_message(_explicit, model=agent.model, pool=_pool)
or pool_cooldown_message(_explicit)
)
if _exhausted_message:
raise ProviderCredentialsExhaustedError(_exhausted_message, provider=_explicit)
if _explicit and _explicit not in {"auto", "openrouter", "custom"}:
# Explicit non-OpenRouter provider with no creds and no usable fallback: fail fast.
from agent.auxiliary_unavailable import missing_provider_credentials_message

View File

@@ -12,7 +12,7 @@ import logging
import threading
import time
from datetime import datetime
from typing import Optional
from typing import Any, Optional
from hermes_time import safe_strftime
@@ -90,6 +90,93 @@ def pool_cooldown_message(provider_id: str) -> Optional[str]:
"different provider with `hermes model`.")
class ProviderCredentialsExhaustedError(RuntimeError):
"""An explicit provider's credential pool is fully exhausted (402 out of credits, quota bench).
Subclasses ``RuntimeError`` so every existing ``except RuntimeError`` keeps working; the distinct
type lets a caller (gateway, Desktop) route a billing/quota failure to billing guidance instead
of the provider-setup flow a plain "No LLM provider configured" would trigger (#94785).
"""
def __init__(self, message: str, *, provider: Optional[str] = None):
super().__init__(message)
self.provider = provider
def pool_billing_message(
provider_id: str,
*,
model: Optional[str] = None,
pool: Optional[Any] = None,
) -> Optional[str]:
"""The "Provider 'X' … is out of usable credentials (402)" error for a billing-burned pool.
``resolve_provider_client()`` returns ``None`` both when no credential exists and when every
pool entry has been marked exhausted — a 402 out-of-credits account, a 429/quota bench. The
``openrouter`` / ``custom`` branch of ``agent_init`` has no provider-specific missing-credentials
message, so a burned pool there fell through to the generic "No LLM provider configured" setup
message: actively misleading when a *different* provider is configured and working and only the
session's override is out of credits (#94785). Name the provider, the session's model, the last
error code and the provider's own message instead.
Only a genuine billing verdict — a 402, or the classifier's ``billing`` reason on a 403 spending
limit — is named here; a plain 429/quota bench keeps the existing cooldown wording (with its
reset time, #56810), which the caller falls back to when this returns ``None``.
*pool* is an already-loaded ``CredentialPool`` (preferred — it carries the live in-memory
exhaustion state); otherwise the persisted rows are read (no seeding, no writes, mirroring
``pool_cooldown_message``). ``None`` when the pool is empty, a credential is usable, no entry is
currently benched, or none carries a billing verdict.
"""
from agent.credential_pool import (
FAILURE_REASON_BILLING,
STATUS_DEAD,
STATUS_EXHAUSTED,
PooledCredential,
_exhausted_until,
)
entries: list = []
with contextlib.suppress(Exception):
if pool is not None:
entries = list(pool.entries())
else:
from hermes_cli.auth import read_credential_pool
entries = [PooledCredential.from_dict(provider_id, e)
for e in read_credential_pool(provider_id) if isinstance(e, dict)]
live = [e for e in entries if getattr(e, "last_status", None) != STATUS_DEAD]
if not live:
return None
now = time.time()
benched = [
e for e in live
if getattr(e, "last_status", None) == STATUS_EXHAUSTED
and (_exhausted_until(e, sole_credential=len(live) == 1) or 0) > now
]
if len(benched) != len(live):
# A credential is still usable (or merely cooling down on one model): not our case.
return None
billing = [
e for e in benched
if getattr(e, "last_error_code", None) == 402
or getattr(e, "failure_reason", None) == FAILURE_REASON_BILLING
]
if not billing:
return None
pick = billing[0]
code = getattr(pick, "last_error_code", None)
detail = str(getattr(pick, "last_error_message", None) or "").strip()
which = "its only credential is" if len(live) == 1 else f"all {len(live)} credentials are"
scope = f" for model '{model}'" if model else ""
code_text = f" (last error {code}{': ' + detail if detail else ''})" if code else ""
return (
f"Provider '{provider_id}'{scope} is out of usable credentials: {which} exhausted"
f"{code_text}. Add credits or update billing with that provider, then retry, or switch "
f"to a different provider with `hermes model`."
)
def missing_provider_credentials_message(provider_id: str) -> str:
"""The "Provider 'X' is set in config.yaml but …" error for an explicit provider with no credentials.