Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand Down
5 changes: 2 additions & 3 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
35 changes: 24 additions & 11 deletions run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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.
Expand Down
76 changes: 76 additions & 0 deletions tests/test_provider_fallback.py
Original file line number Diff line number Diff line change
@@ -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
81 changes: 43 additions & 38 deletions website/docs/user-guide/features/fallback-providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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` |
Expand Down