Files
hermes-agent/agent/credential_pool_plugin.py
teknium1 bce20d0b1f feat: model-provider plugins classify their own API errors via ProviderProfile.classify_api_error
A kind: model-provider plugin is loaded by providers/ discovery and never enters the
PluginManager hook lifecycle, so transform_api_error_classification was unreachable for it
without shipping a second plugin component. The profile now carries an optional
classify_api_error(error, *, status_code, error_code, message, body, model) callable,
consulted as a classifier stage right after the generic plugin hooks and only for the
provider that produced the error. None or an unknown reason leaves the built-in verdict;
built-in providers are untouched (no name table, no lifecycle change).

Also: a plugin refresh_credential returning None/empty was treated as a successful refresh
(row marked ok, stale bearer replayed up to the refresh cap). It now benches the row like a
failed refresh POST, so the loop rotates or falls to the generic sign-in copy.

Part of #116408

(cherry picked from commit b8129fd6fd6a0cf4eeee6d95a5d5d823668a306d)
2026-09-19 21:24:05 -07:00

94 lines
4.6 KiB
Python

"""Plugin-provider refresh support for the credential pool (#116408).
A model-provider plugin makes its pooled OAuth rows refreshable by shipping
``ProviderProfile.refresh_credential(entry) -> Mapping | None``. The pool owns
the locking, persistence and failure classification around that hook; the
plugin owns only the token POST. This sibling module keeps that logic out of
``agent/credential_pool.py`` (near the size cap).
Contract (documented in website/docs/developer-guide/model-provider-plugin.md):
* the hook returns a mapping of rotated values — dataclass field names
(``access_token``, ``refresh_token``, ``expires_at_ms`` …) replace the row's
fields, every other key (``expires_in``, ``token_type``, ``scope`` — the
natural token-endpoint shape) lands in ``entry.extra``; ``None``/empty = could not
rotate and the pool benches the row like a failed refresh POST;
* raising ``AuthError(..., relogin_required=True)`` (or a grant-dead OAuth code)
is terminal: the row goes DEAD with a WARNING naming ``hermes auth add``;
any other exception is transient and only benches the row.
"""
from __future__ import annotations
import logging
from dataclasses import fields, replace
from typing import TYPE_CHECKING, Any, Mapping, Optional, Tuple
from hermes_cli.auth import _OAUTH_GRANT_DEAD_CODES
from hermes_cli.auth_constants import AuthError
if TYPE_CHECKING: # pragma: no cover
from agent.credential_pool import CredentialPool, PooledCredential
logger = logging.getLogger(__name__)
def apply_plugin_refresh_result(entry: "PooledCredential", result: Any) -> "PooledCredential":
"""Merge a ``refresh_credential`` return value into *entry*.
Field names go through ``dataclasses.replace``; everything else is merged into ``extra``
(mirroring ``PooledCredential.from_dict``). Before this split a token-endpoint-shaped mapping
with ``expires_in`` raised ``TypeError`` inside ``replace`` and the pool benched a row whose
single-use refresh token the server had already rotated — the login was lost.
"""
if not result:
return entry
mapping: Mapping[str, Any] = dict(result)
field_names = {f.name for f in fields(type(entry))} - {"provider", "extra"}
field_updates = {k: v for k, v in mapping.items() if k in field_names}
extra_updates = {k: v for k, v in mapping.items() if k not in field_names and k != "provider"}
if extra_updates:
field_updates["extra"] = {**entry.extra, **extra_updates}
return replace(entry, **field_updates) if field_updates else entry
def is_terminal_plugin_refresh_error(exc: BaseException) -> bool:
"""True when retrying the same plugin refresh token cannot succeed.
Plugins have no per-provider code table, so the predicate is the structural one every built-in
flow shares: a structured ``AuthError`` that asks for a re-login, or one carrying a grant-dead
OAuth code. Transport errors, 429/5xx-shaped ``RuntimeError`` and plain ``AuthError`` without
that signal stay transient (EXHAUSTED for one cooldown).
"""
if not isinstance(exc, AuthError):
return False
return bool(exc.relogin_required) or (exc.code or "") in _OAUTH_GRANT_DEAD_CODES
def recover_failed_plugin_refresh(
pool: "CredentialPool", entry: "PooledCredential", exc: Exception,
) -> Tuple[bool, Optional["PooledCredential"]]:
"""Recovery for a plugin hook that raised: adopt a peer's rotation, or quarantine a dead grant.
Returns ``(handled, result)``; ``handled=False`` means the caller should bench the row as a
transient failure. A peer process may have rotated the single-use token between our in-lock
sync and the hook's POST — adopt that pair before classifying the failure.
"""
from agent.credential_pool import _MARK_OK
synced = pool._sync_entry_from_pool_store(entry)
if synced.refresh_token != entry.refresh_token and (synced.access_token or "").strip():
logger.debug("%s refresh failed but the pool store has newer tokens — adopting", pool.provider)
return True, pool._adopt(synced, **_MARK_OK)
if is_terminal_plugin_refresh_error(exc):
# WARNING, not debug: this is the moment a login is lost. Benching for a TTL would replay
# the dead token every cooldown at DEBUG with no trace for the user.
logger.warning(
"%s refresh token for %s is terminally invalid (%s); the credential leaves rotation. "
"Re-run 'hermes auth add %s' to sign in again.",
pool.provider, entry.label or entry.id[:8], exc, pool.provider,
)
pool._mark_dead_refresh_grant(entry, exc)
return True, None
return False, None