website/docs/user-guide/features/credential-pools.md
Credential pools let you register multiple API keys or OAuth tokens for the same provider. When one key hits a rate limit or billing quota, Hermes automatically rotates to the next healthy key — keeping your session alive without switching providers.
This is different from fallback providers, which switch to a different provider entirely. Credential pools are same-provider rotation; fallback providers are cross-provider failover. Pools are tried first — if all pool keys are exhausted, then the fallback provider activates.
:::warning Key rotation resets the prompt cache Provider-side prompt caches (Anthropic, OpenAI, OpenRouter) are scoped to the account/API key that made the request. When the pool rotates to a different key mid-session, the new key has no cached prefix for your conversation — the next request re-reads the full history at undiscounted input price, and rotating back later is another full re-read unless the earlier key's cache TTL is still alive. Rotation keeps your session running, which is the point, but on long conversations each rotation costs one full-price pass over the context. :::
:::tip Credential pools are mainly for API-key providers (OpenRouter, Anthropic). A single Nous Portal OAuth covers 300+ models, so most users don't need a pool when on Portal. :::
Your request
→ Pick key from pool (round_robin / least_used / fill_first / random)
→ Send to provider
→ 429 rate limit?
→ Plan/usage limit reached (e.g. ChatGPT/Codex "usage limit reached")?
→ Rotate to next pool key immediately (no retry — the cap won't clear on retry)
→ Generic / transient 429?
→ Retry same key once (transient blip)
→ Second 429 → rotate to next pool key
→ All keys exhausted → fallback_model (different provider)
→ 402 billing error?
→ Immediately rotate to next pool key (1h cooldown)
→ 401 auth expired?
→ Try refreshing the token (OAuth)
→ Refresh failed → rotate to next pool key
→ Success → continue normally
If you already have an API key set in .env, Hermes auto-discovers it as a 1-key pool. To benefit from pooling, add more keys:
# Add a second OpenRouter key
hermes auth add openrouter --api-key sk-or-v1-your-second-key
# Add a second Anthropic key
hermes auth add anthropic --type api-key --api-key sk-ant-api03-your-second-key
# Add an Anthropic OAuth credential (requires Claude Max plan + extra usage credits)
hermes auth add anthropic --type oauth
# Opens browser for OAuth login
Check your pools:
hermes auth list
Output:
openrouter (2 credentials):
#1 OPENROUTER_API_KEY api_key id=ab12cd34 priority=0 env:OPENROUTER_API_KEY ←
#2 backup-key api_key id=ef56gh78 priority=1 manual
anthropic (3 credentials):
#1 hermes_pkce oauth id=ab12cd34 priority=0 hermes_pkce ←
#2 claude_code oauth id=cd34ef56 priority=1 claude_code
#3 ANTHROPIC_API_KEY api_key id=ef56gh78 priority=2 env:ANTHROPIC_API_KEY
The ← marks the currently selected credential. id= is the entry id accepted by
hermes auth remove <provider> <target> when a label is ambiguous, and priority= is
the order the pool tries credentials in under the fill_first strategy.
Run hermes auth with no subcommand for an interactive wizard:
hermes auth
This shows your full pool status and offers a menu:
What would you like to do?
1. Add a credential
2. Remove a credential
3. Reset cooldowns for a provider
4. Set rotation strategy for a provider
5. Exit
For providers that support both API keys and OAuth (Anthropic, Nous, Codex), the add flow asks which type:
anthropic supports both API keys and OAuth login.
1. API key (paste a key from the provider dashboard)
2. OAuth login (authenticate via browser)
Type [1/2]:
| Command | Description |
|---|---|
hermes auth | Interactive pool management wizard |
hermes auth list | Show all pools and credentials |
hermes auth list <provider> | Show a specific provider's pool |
hermes auth add <provider> | Add a credential (prompts for type and key) |
hermes auth add <provider> --type api-key --api-key <key> | Add an API key non-interactively |
hermes auth add <provider> --type oauth | Add an OAuth credential via browser login |
hermes auth add <provider> --priority 0 | Add a credential and place it first in the fill_first order |
hermes auth priority <provider> <target> <n> | Move a credential to priority n (0 = tried first); the rest are renumbered |
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) |
For Nous, auth refresh supports only the login's device_code singleton.
Independent Nous pool accounts are rejected before refresh; their tokens and
cooldowns are preserved. Reauthenticate with hermes auth add nous --type oauth
to update the singleton; this does not refresh an independent account. Other
providers retain their existing source-specific refresh support.
Priority positions are zero-based and clamp to the pool's ends; displayed targets
are one-based indices, entry IDs, or unambiguous exact labels. auth add --priority
also places an existing entry updated by reauthentication. Anthropic keeps manual
credentials ahead of seeded credentials, so the command reports the effective
position when that rule changes it. Other strategies may override priority, and
reordering does not rebind credentials already held by a running session.
Every successful pool selection increments request_count, regardless of strategy.
Refresh-only lookups and peeks do not count. These are selection counters, not
billing totals or a count of every inference request: a cached credential can serve
multiple requests. Counts remain in memory until the next existing pool write
(for example rotation, exhaustion, refresh, or an administrative change); this does
not add a disk write per selection.
Configure via hermes auth → "Set rotation strategy" or in config.yaml:
credential_pool_strategies:
openrouter: round_robin
anthropic: least_used
| Strategy | Behavior |
|---|---|
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 |
The pool handles different errors differently:
| Error | Behavior | Cooldown |
|---|---|---|
| 429 Rate Limit | Retry same key once (transient). Second consecutive 429 rotates to next key | 1 hour |
| 402 Billing/Quota | Immediately rotate to next key | 1 hour |
| 401 Auth Expired | Try refreshing the OAuth token first. Rotate only if refresh fails | 5 minutes |
| All keys exhausted | Fall through to fallback_model if configured | — |
Provider-supplied reset_at timestamps override these default cooldowns.
The has_retried_429 flag resets on every successful API call, so a single transient 429 doesn't trigger rotation.
Custom OpenAI-compatible endpoints (Together.ai, RunPod, local servers) get their own pools, keyed by the endpoint name from the providers: dict in config.yaml (or the legacy custom_providers list, which is auto-migrated).
When you set up a custom endpoint via hermes model, it auto-generates a name like "Together.ai" or "Local (localhost:8080)". This name becomes the pool key.
# After setting up a custom endpoint via hermes model:
hermes auth list
# Shows:
# Together.ai (1 credential):
# #1 config key api_key config:Together.ai ←
# Add a second key for the same endpoint:
hermes auth add Together.ai --api-key sk-together-second-key
Custom endpoint pools are stored in auth.json under credential_pool with a custom: prefix:
{
"credential_pool": {
"openrouter": [...],
"custom:together.ai": [...]
}
}
Hermes automatically discovers credentials from multiple sources and seeds the pool on startup:
| Source | Example | Auto-seeded? |
|---|---|---|
| Environment variables | OPENROUTER_API_KEY, ANTHROPIC_API_KEY | Yes |
| OAuth tokens (auth.json) | Codex device code, Nous device code | Yes |
| Claude Code credentials | ~/.claude/.credentials.json | Yes (Anthropic) |
| Hermes PKCE OAuth | ~/.hermes/auth.json | Yes (Anthropic) |
| Custom endpoint config | model.api_key in config.yaml | Yes (custom endpoints) |
| Manual entries | Added via hermes auth add | Persisted in auth.json |
Auto-seeded entries are updated on each pool load — if you remove an env var, its pool entry is automatically pruned. Manual entries (added via hermes auth add) are never auto-pruned.
Borrowed runtime secrets (for example env vars, Bitwarden/Vault/keyring/systemd references, and custom config values) are reference-only at the auth.json boundary. Hermes can use the resolved value in memory for the current run, but it persists only metadata such as the source ref, label, status, request counters, and a non-reversible fingerprint. Manual entries and Hermes-owned OAuth/device-code state keep the durable tokens they need to refresh.
When the agent spawns subagents via delegate_task, the parent's credential pool is automatically shared with children:
This means subagents benefit from the same rate-limit resilience as the parent, with no extra configuration needed. Per-task credential leasing ensures children don't conflict with each other when rotating keys concurrently.
The credential pool uses a threading lock for all state mutations (select(), mark_exhausted_and_rotate(), try_refresh_current(), mark_used()). This ensures safe concurrent access when the gateway handles multiple chat sessions simultaneously.
Across processes (many subagents, a gateway plus a CLI, cron jobs), OAuth refreshes are serialized through a file lock on auth.json. When one shared OAuth grant expires under many concurrent processes, exactly one process performs the refresh; the others detect that the on-disk token no longer matches the one that failed and adopt it instead of rotating the single-use refresh token again. A process that loses the lock race keeps its entry healthy and retries — lock contention is never recorded as a credential failure.
For the full data flow diagram, see docs/credential-pool-flow.excalidraw in the repository.
The credential pool integrates at the provider resolution layer:
agent/credential_pool.py — Pool manager: storage, selection, rotation, cooldowns; agent/credential_pool_admin.py owns locked target resolution, reset, add, removal, and priority mutationshermes_cli/auth_commands.py — CLI commands and interactive wizardhermes_cli/runtime_provider.py — Pool-aware credential resolutionagent/turn_api_error.py — Error recovery: 429/402/401 → pool rotation → fallbackPool state is stored in ~/.hermes/auth.json under the credential_pool key:
{
"version": 1,
"credential_pool": {
"openrouter": [
{
"id": "abc123",
"label": "OPENROUTER_API_KEY",
"auth_type": "api_key",
"priority": 0,
"source": "env:OPENROUTER_API_KEY",
"secret_source": "bitwarden",
"secret_fingerprint": "sha256:12ab34cd56ef7890",
"last_status": "ok",
"request_count": 142
}
],
"anthropic": [
{
"id": "manual1",
"label": "personal-api-key",
"auth_type": "api_key",
"priority": 0,
"source": "manual",
"access_token": "sk-ant-api03-..."
}
]
}
}
The OpenRouter entry above was borrowed from an external source, so the raw key is not stored in auth.json. The manual Anthropic entry was intentionally added to Hermes' credential store, so its token remains persistable.
Strategies are stored in config.yaml (not auth.json):
credential_pool_strategies:
openrouter: round_robin
anthropic: least_used