diff --git a/agent/credential_pool_admin.py b/agent/credential_pool_admin.py index 0dd6482043..127970d448 100644 --- a/agent/credential_pool_admin.py +++ b/agent/credential_pool_admin.py @@ -67,6 +67,23 @@ class CredentialPoolAdminMixin: self._current_id = None return removed + def move_entry(self, credential_id: str, priority: int) -> Optional[PooledCredential]: + """Place an entry at a clamped zero-based position and persist contiguous priorities.""" + from agent.credential_pool import _normalize_pool_priorities + + with self._lock: + entry = self._find(lambda e: e.id == credential_id) + if entry is None: + return None + others = [e for e in self._entries if e.id != credential_id] + others.insert(max(0, min(int(priority), len(others))), entry) + entries = [replace(e, priority=p) for p, e in enumerate(others)] + # Apply load-time ordering now so the reported position survives reload. + _normalize_pool_priorities(self.provider, entries) + self._entries = sorted(entries, key=lambda e: e.priority) + self._persist() + return self._find(lambda e: e.id == credential_id) + def resolve_target(self, target: Any) -> Tuple[Optional[int], Optional[PooledCredential], Optional[str]]: raw = str(target or "").strip() if not raw: diff --git a/hermes_cli/_parser.py b/hermes_cli/_parser.py index 37ff7a8e09..97bacacc5e 100644 --- a/hermes_cli/_parser.py +++ b/hermes_cli/_parser.py @@ -74,6 +74,7 @@ Examples: hermes auth list List pooled credentials 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 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 4da1852e19..7daf2f8097 100644 --- a/hermes_cli/auth_commands.py +++ b/hermes_cli/auth_commands.py @@ -349,6 +349,14 @@ def auth_add_command(args) -> None: if not is_custom: _unsuppress_provider_sources(provider) + wanted_priority = getattr(args, "priority", None) + before = {entry.id for entry in pool.entries()} + _add_credential(args, provider, pool, requested_type) + if wanted_priority is not None: + _place_added_credential(provider, before, int(wanted_priority)) + + +def _add_credential(args, provider: str, pool, requested_type: str) -> None: if requested_type == AUTH_TYPE_API_KEY: _add_api_key_credential(args, provider, pool) return @@ -379,6 +387,64 @@ def auth_add_command(args) -> None: print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"') +def _place_added_credential(provider: str, before_ids: set, priority: int) -> None: + """Move the credential `auth add` just created to *priority*. + + Every add path (api key, OAuth spec, Nous) persists through the pool, so the + new row is the one id that was not there before the add. Reloading rather + than reusing the add's pool object keeps this correct for paths that write + their own store. + """ + pool = load_pool(provider) + entries = pool.entries() + added = [entry for entry in entries if entry.id not in before_ids] + if len(added) == 1: + target = added[0] + elif not added and len(entries) == 1: + # The add updated the sole existing row in place (a repeat Nous login). + target = entries[0] + else: + # The credential was saved; only the placement is unresolved, so do not fail. + print(f"note: could not identify the credential just added to {provider}; set its " + f"priority with `hermes auth priority {provider} {priority}`.", + file=sys.stderr) + return + moved = pool.move_entry(target.id, priority) + _report_priority(provider, pool, moved, priority, "Placed", "at") + + +def _report_priority(provider: str, pool, moved, requested: int, verb: str, prep: str) -> None: + """Print the effective priority and say why it differs from the request, if it does.""" + print(f'{verb} {provider} credential "{moved.label}" {prep} priority {moved.priority} ' + f"(#{moved.priority + 1} in `hermes auth list {provider}`)") + size = len(pool.entries()) + if moved.priority != requested: + if requested < 0 or requested >= size: + reason = f"the pool has {size} credentials, so it was clamped" + else: + reason = "anthropic keeps manually added credentials ahead of seeded ones" + print(f"note: requested priority {requested}; effective priority is {moved.priority} " + f"because {reason}.", file=sys.stderr) + strategy = get_pool_strategy(provider) + if strategy != STRATEGY_FILL_FIRST: + print(f"note: {provider} uses the {strategy} strategy; priority only orders " + f"fill_first selection.", file=sys.stderr) + + +def auth_priority_command(args) -> None: + """`hermes auth priority `: reorder one pooled credential.""" + provider = _normalize_provider(getattr(args, "provider", "")) + pool = load_pool(provider) + index, matched, error = pool.resolve_target(getattr(args, "target", None)) + if matched is None or index is None: + raise SystemExit(f"{error} Provider: {provider}.") + requested = int(getattr(args, "priority")) + moved = pool.move_entry(matched.id, requested) + if moved is None: + raise SystemExit(f'No credential matching "{getattr(args, "target", None)}" for provider {provider}.') + _report_priority(provider, pool, moved, requested, "Set", "to") + + def auth_list_command(args) -> None: provider_filter = _normalize_provider(getattr(args, "provider", "") or "") if provider_filter: @@ -665,7 +731,8 @@ def _interactive_strategy() -> None: _AUTH_ACTIONS = { "add": auth_add_command, "list": auth_list_command, "remove": auth_remove_command, - "reset": auth_reset_command, "status": auth_status_command, "logout": auth_logout_command, + "reset": auth_reset_command, "priority": auth_priority_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 db658435bb..1e4778c7b0 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, *add, *remove, *logout, spotify status, *spotify login, " + "list, status, *reset, *priority, *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 62e4d5d151..61cb8c953f 100644 --- a/hermes_cli/subcommands/auth.py +++ b/hermes_cli/subcommands/auth.py @@ -16,6 +16,10 @@ def build_auth_parser(subparsers, *, cmd_auth: Callable) -> None: "--type", dest="auth_type", choices=["oauth", "api-key", "api_key"], help="Credential type to add") auth_add.add_argument("--label", help="Optional display label") + auth_add.add_argument( + "--priority", type=int, + help="Place the new credential at this priority (0 = tried first under fill_first); " + "appends last when omitted") auth_add.add_argument("--api-key", help="API key value (otherwise prompted securely)") auth_add.add_argument("--portal-url", help="Nous portal base URL") auth_add.add_argument("--inference-url", help="Nous inference base URL") @@ -39,6 +43,11 @@ def build_auth_parser(subparsers, *, cmd_auth: Callable) -> None: auth_reset.add_argument( "target", nargs="?", help="Optional credential index, entry id, or exact label; clears every credential when omitted") + auth_priority = auth_subparsers.add_parser( + "priority", help="Move a pooled credential to a priority (0 = tried first under fill_first)") + 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_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 0a48663380..c7c0a3d4b6 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -581,7 +581,9 @@ hermes auth list # Show all pools hermes auth list openrouter # Show specific provider hermes auth add openrouter --api-key sk-or-v1-xxx # Add API key hermes auth add anthropic --type oauth # Add OAuth credential +hermes auth add openai-codex --type oauth --priority 0 # Add an account and try it first 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 status anthropic # Show auth status for a provider @@ -589,7 +591,7 @@ hermes auth logout anthropic # Log out and clear sto hermes auth spotify # Authenticate Hermes with Spotify via PKCE ``` -Subcommands: `add`, `list`, `remove`, `reset`, `status`, `logout`, `spotify`. When called with no subcommand, launches the interactive management wizard. +Subcommands: `add`, `list`, `remove`, `reset`, `priority`, `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 cedaaa89a4..1c9aee9860 100644 --- a/website/docs/user-guide/features/credential-pools.md +++ b/website/docs/user-guide/features/credential-pools.md @@ -116,6 +116,8 @@ Type [1/2]: | `hermes auth add ` | Add a credential (prompts for type and key) | | `hermes auth add --type api-key --api-key ` | Add an API key non-interactively | | `hermes auth add --type oauth` | Add an OAuth credential via browser login | +| `hermes auth add --priority 0` | Add a credential and place it first in the `fill_first` order | +| `hermes auth priority ` | Move a credential to priority `n` (0 = tried first); the rest are renumbered | | `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 | @@ -132,7 +134,7 @@ credential_pool_strategies: | Strategy | Behavior | |----------|----------| -| `fill_first` (default) | Use the first healthy key until it's exhausted, then move to the next | +| `fill_first` (default) | Use the first healthy key until it's exhausted, then move to the next; order is each credential's `priority` (`hermes auth priority` changes it) | | `round_robin` | Cycle through keys evenly, rotating after each selection | | `least_used` | Always pick the key with the lowest request count | | `random` | Random selection among healthy keys |