From 32a59f3bf7f52dfa6a313d7130c2df10dbd3f6ed Mon Sep 17 00:00:00 2001 From: Brian Le Date: Mon, 7 Sep 2026 02:12:20 -0700 Subject: [PATCH] feat: refresh one pooled OAuth grant from the CLI --- agent/credential_pool.py | 9 +++- hermes_cli/_parser.py | 1 + hermes_cli/auth_commands.py | 50 ++++++++++++++++++- hermes_cli/console_engine.py | 2 +- hermes_cli/subcommands/auth.py | 6 +++ website/docs/reference/cli-commands.md | 3 +- .../user-guide/features/credential-pools.md | 1 + 7 files changed, 67 insertions(+), 5 deletions(-) diff --git a/agent/credential_pool.py b/agent/credential_pool.py index 4fe173a965..a311cae1e3 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -885,6 +885,10 @@ _TOKENS_SINGLETON_PROVIDERS: Dict[str, Tuple[str, str, str, str]] = { "xai-oauth": ("xAI OAuth", "xAI", "refresh_xai_oauth_pure", "_is_terminal_xai_oauth_refresh_error"), } +# Providers whose pooled OAuth entries ``_refresh_entry_impl`` can actually refresh. Any other +# provider is returned unchanged by that path, so callers must not report a refresh for them. +REFRESHABLE_OAUTH_PROVIDERS = frozenset({"anthropic", "nous", *_TOKENS_SINGLETON_PROVIDERS}) + # Providers whose refresh tokens are single-use: the sync -> POST -> write-back # sequence must be serialized across processes under the auth-store flock. _SINGLE_USE_REFRESH_PROVIDERS = ("openai-codex", "xai-oauth", "anthropic") @@ -1592,7 +1596,10 @@ class CredentialPool(CredentialPoolAdminMixin): updated = replace(updated, **_MARK_OK) self._replace_entry(entry, updated) - self._persist() + # Declare the cleared id: a borrowed row carries no access_token on disk, so + # the merge's token-change bypass cannot apply and a plain persist would copy + # the still-binding cooldown back over this success. + self._persist(status_cleared_ids=[updated.id]) # Sync back so _seed_from_singletons() on the next load_pool() sees # fresh state instead of re-seeding consumed tokens. self._sync_device_code_entry_to_auth_store(updated) diff --git a/hermes_cli/_parser.py b/hermes_cli/_parser.py index 97bacacc5e..35a48e2a3c 100644 --- a/hermes_cli/_parser.py +++ b/hermes_cli/_parser.py @@ -75,6 +75,7 @@ Examples: hermes auth remove

Remove pooled credential by index, id, or label hermes auth reset

[t] Clear exhaustion status for a provider, or one credential hermes auth priority

Move a pooled credential to priority n (0 = tried first) + hermes auth refresh

[t] Refresh a pooled OAuth credential and clear its cooldown hermes model Select default model hermes fallback [list] Show fallback provider chain hermes fallback add Add a fallback provider (same picker as `hermes model`) diff --git a/hermes_cli/auth_commands.py b/hermes_cli/auth_commands.py index 7daf2f8097..f77ffbec68 100644 --- a/hermes_cli/auth_commands.py +++ b/hermes_cli/auth_commands.py @@ -14,7 +14,7 @@ import uuid from agent.credential_pool import ( AUTH_TYPE_API_KEY, AUTH_TYPE_OAUTH, CUSTOM_POOL_PREFIX, SOURCE_MANUAL, SOURCE_MANUAL_DEVICE_CODE, STATUS_EXHAUSTED, STRATEGY_FILL_FIRST, STRATEGY_ROUND_ROBIN, - STRATEGY_RANDOM, STRATEGY_LEAST_USED, PooledCredential, _exhausted_until, + STRATEGY_RANDOM, STRATEGY_LEAST_USED, PooledCredential, REFRESHABLE_OAUTH_PROVIDERS, _exhausted_until, _normalize_custom_pool_name, get_pool_strategy, label_from_token, list_custom_pool_providers, load_pool) import hermes_cli.auth as auth_mod @@ -527,6 +527,52 @@ def auth_reset_command(args) -> None: print(f"Reset status on {provider} credential #{index} ({cleared.label})") +def auth_refresh_command(args) -> None: + """`hermes auth refresh [target]`: force one pooled OAuth entry to refresh. + + A successful refresh rotates the stored tokens and clears the entry's local + exhaustion block, returning it to rotation before its persisted + ``last_error_reset_at`` elapses. It proves the grant is alive, not that the + provider's quota is back: if the account is still capped, the next request + 429s and benches it again. Failure leaves the pool's own verdict in place. + """ + provider = _normalize_provider(getattr(args, "provider", "")) + target = getattr(args, "target", None) + pool = load_pool(provider) + entries = pool.entries() + if not entries: + raise SystemExit(f"No {provider} credentials in the pool.") + if target is None or not str(target).strip(): + if len(entries) != 1: + raise SystemExit( + f"{provider} has {len(entries)} credentials; pass an index, entry id, or exact " + f"label (see `hermes auth list {provider}`).") + index, matched = 1, entries[0] + else: + index, matched, error = pool.resolve_target(target) + if matched is None or index is None: + raise SystemExit(f"{error} Provider: {provider}.") + if (provider not in REFRESHABLE_OAUTH_PROVIDERS or matched.auth_type != AUTH_TYPE_OAUTH + or not matched.refresh_token): + raise SystemExit( + f"{provider} credential #{index} ({matched.label}) is not a refreshable OAuth " + f"credential.") + refreshed = pool.try_refresh_matching(credential_id=matched.id) + if refreshed is None: + after = next((e for e in pool.entries() if e.id == matched.id), None) + state = "removed from pool" if after is None else (after.last_status or "unknown") + raise SystemExit( + f"Refresh failed for {provider} credential #{index} ({matched.label}); " + f"status now: {state}.") + status = refreshed.last_status or "ok" + if status == "ok": + print(f"Refreshed {provider} credential #{index} ({refreshed.label}); status: ok") + else: + # A peer already rotated this grant and the pool adopted it without clearing status. + print(f"Adopted current tokens for {provider} credential #{index} ({refreshed.label}); " + f"status still: {status}") + + def auth_status_command(args) -> None: provider = _normalize_provider(getattr(args, "provider", "") or "") if not provider: @@ -731,7 +777,7 @@ def _interactive_strategy() -> None: _AUTH_ACTIONS = { "add": auth_add_command, "list": auth_list_command, "remove": auth_remove_command, - "reset": auth_reset_command, "priority": auth_priority_command, "status": auth_status_command, + "reset": auth_reset_command, "priority": auth_priority_command, "refresh": auth_refresh_command, "status": auth_status_command, "logout": auth_logout_command, "spotify": auth_spotify_command} diff --git a/hermes_cli/console_engine.py b/hermes_cli/console_engine.py index 1e4778c7b0..6d6b23380e 100644 --- a/hermes_cli/console_engine.py +++ b/hermes_cli/console_engine.py @@ -292,7 +292,7 @@ _CLI_FAMILIES: dict[str, tuple[_CliSurface, str]] = { "memory": (_sub("memory", "build_memory_parser", "cmd_memory"), "status, *off, *reset"), "auth": ( _sub("auth", "build_auth_parser", "cmd_auth"), - "list, status, *reset, *priority, *add, *remove, *logout, spotify status, *spotify login, " + "list, status, *reset, *priority, *refresh, *add, *remove, *logout, spotify status, *spotify login, " "*spotify logout"), "pairing": ( _sub("pairing", "build_pairing_parser", "cmd_pairing"), diff --git a/hermes_cli/subcommands/auth.py b/hermes_cli/subcommands/auth.py index 61cb8c953f..70df5fec23 100644 --- a/hermes_cli/subcommands/auth.py +++ b/hermes_cli/subcommands/auth.py @@ -48,6 +48,12 @@ def build_auth_parser(subparsers, *, cmd_auth: Callable) -> None: auth_priority.add_argument("provider", help="Provider id") auth_priority.add_argument("target", help="Credential index, entry id, or exact label") auth_priority.add_argument("priority", type=int, help="New priority; others are renumbered") + auth_refresh = auth_subparsers.add_parser( + "refresh", help="Refresh a pooled OAuth credential's tokens and clear its cooldown") + auth_refresh.add_argument("provider", help="Provider id") + auth_refresh.add_argument( + "target", nargs="?", + help="Credential index, entry id, or exact label (required when the pool holds more than one)") auth_status = auth_subparsers.add_parser("status", help="Show auth status for a provider") auth_status.add_argument("provider", help="Provider id") auth_logout = auth_subparsers.add_parser( diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index c7c0a3d4b6..b5e6093e6e 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -586,12 +586,13 @@ hermes auth remove openrouter 2 # Remove by index hermes auth priority openrouter backup-key 0 # Move a credential to the front of fill_first order hermes auth reset openrouter # Clear cooldowns hermes auth reset openrouter 2 # Clear the cooldown on one credential +hermes auth refresh openai-codex work # Refresh one OAuth credential and clear its cooldown hermes auth status anthropic # Show auth status for a provider hermes auth logout anthropic # Log out and clear stored auth state hermes auth spotify # Authenticate Hermes with Spotify via PKCE ``` -Subcommands: `add`, `list`, `remove`, `reset`, `priority`, `status`, `logout`, `spotify`. When called with no subcommand, launches the interactive management wizard. +Subcommands: `add`, `list`, `remove`, `reset`, `priority`, `refresh`, `status`, `logout`, `spotify`. When called with no subcommand, launches the interactive management wizard. ## `hermes status` diff --git a/website/docs/user-guide/features/credential-pools.md b/website/docs/user-guide/features/credential-pools.md index 1c9aee9860..d256693698 100644 --- a/website/docs/user-guide/features/credential-pools.md +++ b/website/docs/user-guide/features/credential-pools.md @@ -121,6 +121,7 @@ Type [1/2]: | `hermes auth remove ` | Remove credential by 1-based index | | `hermes auth reset ` | Clear all cooldowns/exhaustion status | | `hermes auth reset ` | Clear the cooldown on one credential by index, id, or label | +| `hermes auth refresh [target]` | Refresh one OAuth credential's tokens and return it to rotation (proves the grant is alive; the next request re-checks quota) | ## Rotation Strategies