feat: refresh one pooled OAuth grant from the CLI

This commit is contained in:
Brian Le
2026-09-07 02:12:20 -07:00
committed by Teknium
parent 1a4bb74a40
commit 32a59f3bf7
7 changed files with 67 additions and 5 deletions

View File

@@ -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)

View File

@@ -75,6 +75,7 @@ Examples:
hermes auth remove <p> <t> Remove pooled credential by index, id, or label
hermes auth reset <p> [t] Clear exhaustion status for a provider, or one credential
hermes auth priority <p> <t> <n> Move a pooled credential to priority n (0 = tried first)
hermes auth refresh <p> [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`)

View File

@@ -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 <provider> [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}

View File

@@ -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"),

View File

@@ -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(

View File

@@ -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`

View File

@@ -121,6 +121,7 @@ Type [1/2]:
| `hermes auth remove <provider> <index>` | Remove credential by 1-based index |
| `hermes auth reset <provider>` | Clear all cooldowns/exhaustion status |
| `hermes auth reset <provider> <target>` | Clear the cooldown on one credential by index, id, or label |
| `hermes auth refresh <provider> [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