diff --git a/cli.py b/cli.py index 2b0c4ad82c730..bb975efd71e6b 100755 --- a/cli.py +++ b/cli.py @@ -1141,8 +1141,8 @@ def __init__( self._provider_data_collection = pr.get("data_collection") # Fallback model config β€” tried when primary provider fails after retries - fb = CLI_CONFIG.get("fallback_model") or {} - self._fallback_model = fb if fb.get("provider") and fb.get("model") else None + fb = CLI_CONFIG.get("fallback_providers") or CLI_CONFIG.get("fallback_model") or [] + self._fallback_model = fb # Optional cheap-vs-strong routing for simple turns self._smart_model_routing = CLI_CONFIG.get("smart_model_routing", {}) or {} diff --git a/gateway/run.py b/gateway/run.py index 3791dd6b3251f..04a1d2947c06f 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -808,9 +808,8 @@ def _load_fallback_model() -> dict | None: if cfg_path.exists(): with open(cfg_path, encoding="utf-8") as _f: cfg = _y.safe_load(_f) or {} - fb = cfg.get("fallback_model", {}) or {} - if fb.get("provider") and fb.get("model"): - return fb + fb = cfg.get("fallback_providers") or cfg.get("fallback_model") or [] + return fb except Exception: pass return None diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 3f8ac78c165a3..c9ac18c71af11 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -111,6 +111,7 @@ def ensure_hermes_home(): DEFAULT_CONFIG = { "model": "anthropic/claude-opus-4.6", + "fallback_providers": [], "toolsets": ["hermes-cli"], "agent": { "max_turns": 90, diff --git a/run_agent.py b/run_agent.py index e8bf35c479442..fc785ad02fa57 100644 --- a/run_agent.py +++ b/run_agent.py @@ -617,13 +617,26 @@ def __init__( # Provider fallback β€” a single backup model/provider tried when the # primary is exhausted (rate-limit, overload, connection failure). # Config shape: {"provider": "openrouter", "model": "anthropic/claude-sonnet-4"} - self._fallback_model = fallback_model if isinstance(fallback_model, dict) else None + # Provider fallback sequence β€” ordered list of backup providers tried + # when the primary is exhausted (rate-limit, overload, connection failure). + # Supports both legacy single-dict and new list format. + # Config shape: [{"provider": "openrouter", "model": "claude-sonnet-4"}, ...] + if isinstance(fallback_model, list): + self._fallback_chain = [f for f in fallback_model if isinstance(f, dict) and f.get("provider") and f.get("model")] + elif isinstance(fallback_model, dict) and fallback_model.get("provider") and fallback_model.get("model"): + self._fallback_chain = [fallback_model] + else: + self._fallback_chain = [] + self._fallback_index = 0 self._fallback_activated = False - if self._fallback_model: - fb_p = self._fallback_model.get("provider", "") - fb_m = self._fallback_model.get("model", "") - if fb_p and fb_m and not self.quiet_mode: - print(f"πŸ”„ Fallback model: {fb_m} ({fb_p})") + self._fallback_model = self._fallback_chain[0] if self._fallback_chain else None + if self._fallback_chain and not self.quiet_mode: + if len(self._fallback_chain) == 1: + fb = self._fallback_chain[0] + print(f"πŸ”„ Fallback model: {fb['model']} ({fb['provider']})") + else: + print(f"πŸ”„ Fallback chain ({len(self._fallback_chain)} providers): " + + " β†’ ".join(f"{f['model']} ({f['provider']})" for f in self._fallback_chain)) # Get available tools with filtering self.tools = get_tool_definitions( @@ -3161,15 +3174,15 @@ def _try_activate_fallback(self) -> bool: auth resolution and client construction β€” no duplicated providerβ†’key mappings. """ - if self._fallback_activated or not self._fallback_model: + if self._fallback_index >= len(self._fallback_chain): return False - - fb = self._fallback_model + fb = self._fallback_chain[self._fallback_index] + self._fallback_index += 1 fb_provider = (fb.get("provider") or "").strip().lower() fb_model = (fb.get("model") or "").strip() if not fb_provider or not fb_model: - return False - + return self._try_activate_fallback() + # Use centralized router for client construction. # raw_codex=True because the main agent needs direct responses.stream() # access for Codex providers. diff --git a/tests/test_provider_fallback.py b/tests/test_provider_fallback.py new file mode 100644 index 0000000000000..b85ea10d23c10 --- /dev/null +++ b/tests/test_provider_fallback.py @@ -0,0 +1,76 @@ + """Tests for ordered provider fallback sequence (#1734).""" +import pytest +from unittest.mock import MagicMock, patch + + +def make_agent(fallback_model=None): + with patch("run_agent.get_tool_definitions", return_value=[]), \ + patch("run_agent.load_hermes_dotenv"), \ + patch("run_agent.load_config", return_value={}): + from run_agent import AIAgent + return AIAgent( + model="anthropic/claude-opus-4.6", + quiet_mode=True, + fallback_model=fallback_model, + ) + + +class TestFallbackChainInit: + def test_no_fallback(self): + agent = make_agent(fallback_model=None) + assert agent._fallback_chain == [] + assert agent._fallback_index == 0 + + def test_single_dict_backwards_compat(self): + fb = {"provider": "openai", "model": "gpt-4o"} + agent = make_agent(fallback_model=fb) + assert agent._fallback_chain == [fb] + + def test_list_of_providers(self): + fbs = [ + {"provider": "openai", "model": "gpt-4o"}, + {"provider": "zai", "model": "glm-4.7"}, + ] + agent = make_agent(fallback_model=fbs) + assert len(agent._fallback_chain) == 2 + + def test_invalid_entries_filtered(self): + fbs = [ + {"provider": "openai", "model": "gpt-4o"}, + {"provider": "", "model": "glm-4.7"}, + {"provider": "zai"}, + ] + agent = make_agent(fallback_model=fbs) + assert len(agent._fallback_chain) == 1 + + +class TestFallbackSequence: + def test_try_activate_exhausted(self): + agent = make_agent(fallback_model=None) + assert agent._try_activate_fallback() is False + + def test_try_activate_advances_index(self): + fbs = [ + {"provider": "openai", "model": "gpt-4o"}, + {"provider": "zai", "model": "glm-4.7"}, + ] + agent = make_agent(fallback_model=fbs) + with patch("agent.auxiliary_client.resolve_provider_client") as mock_rc: + mock_client = MagicMock() + mock_client.base_url = "https://api.openai.com/v1" + mock_rc.return_value = (mock_client, "gpt-4o") + result = agent._try_activate_fallback() + assert result is True + assert agent._fallback_index == 1 + assert agent.model == "gpt-4o" + + def test_all_exhausted_returns_false(self): + fbs = [{"provider": "openai", "model": "gpt-4o"}] + agent = make_agent(fallback_model=fbs) + with patch("agent.auxiliary_client.resolve_provider_client") as mock_rc: + mock_client = MagicMock() + mock_client.base_url = "https://api.openai.com/v1" + mock_rc.return_value = (mock_client, "gpt-4o") + agent._try_activate_fallback() + result = agent._try_activate_fallback() + assert result is False diff --git a/website/docs/user-guide/features/fallback-providers.md b/website/docs/user-guide/features/fallback-providers.md index e488c10db8379..dcf784f710e3a 100644 --- a/website/docs/user-guide/features/fallback-providers.md +++ b/website/docs/user-guide/features/fallback-providers.md @@ -20,15 +20,25 @@ When your main LLM provider encounters errors β€” rate limits, server overload, ### Configuration -Add a `fallback_model` section to `~/.hermes/config.yaml`: +Add a `fallback_providers` list to `~/.hermes/config.yaml`: ```yaml -fallback_model: - provider: openrouter - model: anthropic/claude-sonnet-4 +# Single fallback +fallback_providers: + - provider: openrouter + model: anthropic/claude-sonnet-4 + +# Multiple fallbacks β€” tried in order until one succeeds +fallback_providers: + - provider: openrouter + model: anthropic/claude-sonnet-4 + - provider: openai + model: gpt-4o + - provider: zai + model: glm-4.7 ``` -Both `provider` and `model` are **required**. If either is missing, the fallback is disabled. +Each entry requires `provider` and `model`. Invalid entries are skipped automatically. ### Supported Providers @@ -51,11 +61,11 @@ Both `provider` and `model` are **required**. If either is missing, the fallback For a custom OpenAI-compatible endpoint, add `base_url` and optionally `api_key_env`: ```yaml -fallback_model: - provider: custom - model: my-local-model - base_url: http://localhost:8000/v1 - api_key_env: MY_LOCAL_KEY # env var name containing the API key +fallback_providers: + - provider: custom + model: my-local-model + base_url: http://localhost:8000/v1 + api_key_env: MY_LOCAL_KEY # env var name containing the API key ``` ### When Fallback Triggers @@ -77,48 +87,43 @@ When triggered, Hermes: The switch is seamless β€” your conversation history, tool calls, and context are preserved. The agent continues from exactly where it left off, just using a different model. -:::info One-Shot -Fallback activates **at most once** per session. If the fallback provider also fails, normal error handling takes over (retries, then error message). This prevents cascading failover loops. +:::info Fallback Sequence +Hermes tries each fallback provider in order. If all providers in the list fail, normal error handling takes over (retries, then error message). This prevents infinite failover loops. ::: ### Examples -**OpenRouter as fallback for Anthropic native:** +**Single fallback β€” OpenRouter for Anthropic:** ```yaml model: provider: anthropic default: claude-sonnet-4-6 -fallback_model: - provider: openrouter - model: anthropic/claude-sonnet-4 +fallback_providers: + - provider: openrouter + model: anthropic/claude-sonnet-4 ``` -**Nous Portal as fallback for OpenRouter:** +**Multiple fallbacks β€” cloud chain:** ```yaml -model: - provider: openrouter - default: anthropic/claude-opus-4 - -fallback_model: - provider: nous - model: nous-hermes-3 -``` - -**Local model as fallback for cloud:** -```yaml -fallback_model: - provider: custom - model: llama-3.1-70b - base_url: http://localhost:8000/v1 - api_key_env: LOCAL_API_KEY +fallback_providers: + - provider: openrouter + model: anthropic/claude-sonnet-4 + - provider: nous + model: nous-hermes-3 + - provider: openai-codex + model: gpt-5.3-codex ``` -**Codex OAuth as fallback:** +**Local-first with cloud fallbacks:** ```yaml -fallback_model: - provider: openai-codex - model: gpt-5.3-codex +fallback_providers: + - provider: custom + model: llama-3.1-70b + base_url: http://localhost:8000/v1 + api_key_env: LOCAL_API_KEY + - provider: openrouter + model: anthropic/claude-sonnet-4 ``` ### Where Fallback Works @@ -301,7 +306,7 @@ See [Scheduled Tasks (Cron)](/docs/user-guide/features/cron) for full configurat | Feature | Fallback Mechanism | Config Location | |---------|-------------------|----------------| -| Main agent model | `fallback_model` in config.yaml β€” one-shot failover on errors | `fallback_model:` (top-level) | +| Main agent model | `fallback_providers` list β€” tries each in order on errors | `fallback_providers:` (top-level) | | Vision | Auto-detection chain + internal OpenRouter retry | `auxiliary.vision` | | Web extraction | Auto-detection chain + internal OpenRouter retry | `auxiliary.web_extract` | | Context compression | Auto-detection chain, degrades to no-summary if unavailable | `auxiliary.compression` or `compression.summary_provider` |