feat: refresh one pooled OAuth grant from the CLI
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user