feat: add provider rotation cooldowns - #31327
Conversation
Persist provider/model cooldowns across turns and sessions so exhausted providers (rate-limit, billing/quota) are skipped at turn start until the cooldown window expires. Changes: - agent/provider_rotation.py: ProviderRotationState (JSON persistence), is_unavailable(), mark_unavailable(), reset(), filter_available_entries(), is_rotation_enabled(), cooldown_for_reason() - hermes_cli/rotation_cmd.py: 'hermes rotation list/reset/clear' commands - agent/chat_completion_helpers.py: mark current provider on rate-limit/billing failure, skip cooled fallback entries when rotation enabled - agent/agent_runtime_helpers.py: turn-start check skips cooled primary and advances to next available provider in fallback chain - hermes_cli/config.py: provider_rotation config schema with defaults (enabled: false, cooldown 6h, billing cooldown 24h) - hermes_cli/main.py: wire 'rotation' subparser and cmd_rotation handler - website/docs/user-guide/features/fallback-providers.md: clarify fallback vs rotation, document provider_rotation config and CLI commands - tests: 31 new tests across test_provider_rotation, test_rotation_cmd, and test_provider_fallback (all pass)
|
Related: #22916 (same-provider auth-profile rotation on rate-limit/overload feature request). Also see merged #4188 (credential pools) and #12554 (fall back on rate limit when pool has no rotation room). This PR adds cross-session provider cooldown state, which is complementary to but distinct from credential pool rotation. |
teknium1
left a comment
There was a problem hiding this comment.
Thanks for pursuing cross-session provider failover; the current 60-second per-agent fallback gate does not cover the persistent capacity case.
Problems
agent/agent_runtime_helpers.py:854-884only consults rotation state when_fallback_activatedis false. After a normal fallback activation, once the existing 60-second gate expires, current restore logic proceeds to restore the primary instead of checking persistent state. This misses the long-lived CLI/gateway-agent case.agent/provider_rotation.py:27-31keys only by provider/model. Current fallback identity includesbase_url(agent/chat_completion_helpers.py:1301-1306), so separate custom endpoints sharing a provider/model would be cooled together.agent/provider_rotation.py:57-63uses a shared fixed temporary pathname with no read-modify-write coordination, which can lose records when separate sessions update cooldowns concurrently.agent/chat_completion_helpers.py:753-765treats every classified rate limit as a long cooldown. Current Nous handling requires evidence of an exhausted account bucket before writing its cross-session breaker (agent/nous_rate_guard.py:192-244); this generic path needs an equivalent distinction for transient/upstream 429s.
Suggested changes
- Apply persisted-primary selection on the restore path and add an expired-60-second/active-persisted-cooldown test.
- Include normalized endpoint identity in records and make state updates concurrency-safe.
- Gate durable cooldowns on provider evidence of durable exhaustion.
Automated hermes-sweeper review.
| primary_provider = ((agent._primary_runtime or {}).get("provider") or getattr(agent, "provider", "") or "").strip() | ||
| primary_model = ((agent._primary_runtime or {}).get("model") or getattr(agent, "model", "") or "").strip() | ||
| if ( | ||
| is_rotation_enabled(rotation_config) |
There was a problem hiding this comment.
This check only runs under if not agent._fallback_activated. After a normal fallback activation, the next long-lived-agent turn bypasses this block; once the existing 60-second _rate_limited_until expires, the code restores the primary without consulting this persisted cooldown. Please apply the check on the restore path too and cover that sequence.
| return (value or "").strip().lower() | ||
|
|
||
|
|
||
| def provider_key(provider: str | None, model: str | None = None) -> str: |
There was a problem hiding this comment.
This key conflates distinct custom endpoints that share provider/model. Current fallback identity includes normalized base_url (agent/chat_completion_helpers.py:1301-1306) specifically to distinguish backend targets. Include the routing endpoint in cooldown identity and test two same-provider/model custom entries with different URLs.
| def save(self) -> None: | ||
| path = state_path() | ||
| path.parent.mkdir(parents=True, exist_ok=True) | ||
| tmp = path.with_suffix(path.suffix + ".tmp") |
There was a problem hiding this comment.
The shared cross-session state uses one fixed .tmp pathname and no read-modify-write coordination. Concurrent sessions can race on this path or overwrite each other's independent records. Use unique temporary files plus a lock or merge-on-write transaction.
|
|
||
| rotation_config = load_config() | ||
| rotation_enabled = is_rotation_enabled(rotation_config) | ||
| if rotation_enabled and reason in {FailoverReason.rate_limit, FailoverReason.billing}: |
There was a problem hiding this comment.
This records a multi-hour cooldown for every rate_limit. Current cross-session Nous handling only trips after proving an exhausted account bucket, to avoid suppressing healthy routes after transient/upstream 429s (agent/nous_rate_guard.py:192-244). Please add comparable durable-exhaustion evidence or provider-specific policy before persisting this state.
Summary
Adds persistent, quota-aware provider rotation so Hermes skips exhausted providers (rate-limit, billing) at the start of each new turn and across sessions.
Problem
The existing
fallback_providersfeature reacts to per-request failures but resets to the primary provider on every new turn. If the primary provider has hit its daily/monthly usage cap, every turn tries it first, fails, then falls back — wasting a round-trip and adding latency.Solution
Introduce
provider_rotationconfig (disabled by default) with a JSON state file (~/.hermes/provider_rotation_state.json) that persists cooldown records across turns and sessions. On a capacity failure the current provider is marked unavailable for a configurable window (6h for rate-limit, 24h for billing by default). Turn-start provider selection and fallback chain traversal both skip cooled providers.Changes
agent/provider_rotation.py—ProviderRotationState(load/save JSON),mark_unavailable(),is_unavailable(),reset(),filter_available_entries(),is_rotation_enabled(),cooldown_for_reason()hermes_cli/rotation_cmd.py—hermes rotation list/reset/clearCLI commandsagent/chat_completion_helpers.py— mark provider on rate-limit/billing failure; skip cooled entries in fallback chainagent/agent_runtime_helpers.py— turn-start check: if primary is cooled and rotation enabled, advance to next available providerhermes_cli/config.py—provider_rotationconfig schema with defaultshermes_cli/main.py— wirerotationsubcommandwebsite/docs/user-guide/features/fallback-providers.md— document rotation vs fallback, config options, CLIConfig
Tests
31 new/updated tests — all pass via
scripts/run_tests.sh:tests/agent/test_provider_rotation.py(3 tests)tests/hermes_cli/test_rotation_cmd.py(3 tests)tests/run_agent/test_provider_fallback.py(3 new rotation tests, 25 existing)Backwards compatibility
provider_rotation.enableddefaults tofalse— zero behavior change for existing installsfallback_providerschain is preserved and reused as the rotation priority order