fix(agent): name an exhausted (402) provider pool, not "No LLM provider configured"
Fixes #94785
This commit is contained in:
committed by
Austin Pickett
parent
fb2dded3d1
commit
516535b542
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user