Fallback chain fixes for rate limits and errors
NousResearch/hermes-agent · cluster 25822 · agent
Data as of
8 open items. Fix merged in #27625. Check it covers your case.
The short answer
- Known?
- Yes. 27 related items grouped here, 5 issues already closed.
- Fix event
- A fix was merged to main in PR #27625. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PR #27625. That is separate from the fix event. - Still open
- 3 issues and 5 PRs.
Next step: read #27625, then check whether your report is one of the 3 still open.
Read #27625Pick up a Lab taskThese issues and PRs address multiple bugs in fallback logic across agent, auxiliary, and vision components, focusing on cases where 429 rate-limit/quota-exhaustion errors and 5xx server errors fail to trigger provider fallback chains. Several entries also address legacy configuration formats not being recognized by fallback logic.
Open issues3
Open PRs5
- PR#25843fix(auxiliary): trigger provider fallback on 5xx server errors (#25822)
- PR#26152fix(auxiliary): fall back to alternative provider on 5xx server errors
- PR#36280fix(agent): trigger fallback on quota-exhaustion 429s (e.g. Kimi monthly limit)
- PR#51002fix(auxiliary): accept legacy fallback_provider/fallback_model in auxiliary task fallback chain
- PR#57115fix(vision): make fallback chain iterate all entries instead of stopping at first
Fixes on record
-
#27625 feat(auxiliary): layered fallback (chain → main agent) + capacity-error gate fix
files:
agent/auxiliary_client.py,scripts/release.py,tests/agent/test_auxiliary_client.py,website/docs/user-guide/features/fallback-providers.mdShow the diff
-
agent/auxiliary_client.py@@ -2087,7 +2087,13 @@ def _is_payment_error(exc: Exception) -> bool: """Detect payment/credit/quota exhaustion errors. Returns True for HTTP 402 (Payment Required) and for 429/other errors - whose message indicates billing exhaustion rather than rate limiting. + whose message indicates billing exhaustion or daily quota exhaustion + rather than transient rate limiting. + + Daily token quota errors (e.g. Bedrock "Too many tokens per day", + Vertex AI "quota exceeded") are functionally equivalent to credit + exhaustion — the provider cannot serve the request until the quota + resets — and should trigger the same provider-fallback logic. """ status = getattr(exc, "status_code", None) if status == 402: @@ -2095,10 +2101,19 @@ def _is_payment_error(exc: Exception) -> bool: err_lower = str(exc).lower() # OpenRouter and other providers include "credits" or "afford" in 402 bodies, # but sometimes wrap them in 429 or other codes. + # Daily quota exhaustion from Bedrock, Vertex AI, and similar providers + # uses different language but is semantically identical to credit exhaustion. if status in {402, 429, None}: - if any(kw in err_lower for kw in ("credits", "insufficient funds", - "can only afford", "billing", - "payment required")): + if any(kw in err_lower for kw in ( + "credits", "insufficient funds", + "can only afford", "billing", + "payment required", + # Daily / monthly quota exhaustion keywords + "quota exceeded", "quota_exceeded", + "too many tokens per day", "daily limit", + "tokens per day", "daily quota", + "resource exhausted", # Vertex AI / gRPC quota errors + )): return True return False @@ -2579,6 +2594,133 @@ def _try_payment_fallback( return None, None, "" +def _try_main_agent_model_fallback( + failed_provider: str, + task: str = None, + reason: str = "error", +) -> Tuple[Optional[Any], Optional[str], str]: + """Last-resort fallback to the user's main agent provider + model. + + Used after the configured fallback_chain is exhausted (or empty) for + users with an explicit auxiliary provider. This is the "safety net" + layer: if nothing the user asked for can serve the request, try the + main chat model before giving up. + + Skips when the failed provider already IS the main provider (no point + retrying the same backend that just failed). + + Returns: + (client, model, provider_label) or (None, None, "") if no fallback. + """ + main_provider = (_read_main_provider() or "").strip() + main_model = (_read_main_model() or "").strip() + if not main_provider or not main_model or main_provider.lower() in {"auto", ""}: + return None, None, "" + + skip = (failed_provider or "").lower().strip() + if main_provider.lower() == skip: + # The thing that failed IS the main model — nothing to fall back to. + return None, None, "" + if _is_provider_unhealthy(main_provider): + _log_skip_unhealthy(main_provider, task) + return None, None, "" + + try: + client, resolved_model = resolve_provider_client( + provider=main_provider, model=main_model, + ) + except Exception: + client, resolved_model = None, None + + if client is None: + return None, None, "" + + label = f"main-agent({main_provider})" + logger.info( + "Auxiliary %s: %s on %s — falling back to main agent model %s (%s)", + task or "call", reason, failed_provider, label, resolved_model or main_model, + ) + return client, resolved_model or main_model, label + + +def _try_configured_fallback_chain( + task: str, + failed_provider: str, + reason: str = "error", +) -> Tuple[Optional[Any], Optional[str], str]: + """Try user-configured fallback_chain for a specific auxiliary task. + + Reads auxiliary.<task>.fallback_chain from config.yaml and tries each + entry in order. Each entry must have at least ``provider``; ``model``, + ``base_url``, and ``api_key`` are optional. + + Returns: + (client, model, provider_label) or (None, None, "") if no fallback. + """ + if not task: + return None, None, "" + + task_config = _get_auxiliary_task_config(task) + chain = task_config.get("fallback_chain") + if not chain or not isinstance(chain, list): + return None, None, "" + + skip = failed_provider.lower().strip() + tried = [] + + for i, entry in enumerate(chain): + if not isinstance(entry, dict): + continue + fb_provider = str(entry.get("provider", "")).strip() + if not fb_provider or fb_provider.lower() == skip: + continue + fb_model = str(entry.get("model", "")).strip() or None + fb_base_url = str(entry.get("base_url", "")).strip() or None + fb_api_key = [redacted:secret-assignment]"api_key", "")).strip() or None + + label = f"fallback_chain[{i}]({fb_provider})" + + try: + fb_client = _resolve_single_provider( + fb_provider, fb_model, fb_base_url, fb_api_key) + except Exception: + fb_client = None + + if fb_client is not None: + logger.info( + "Auxiliary %s: %s on %s — configured fallback to %s (%s)", + task, reason, failed_provider, label, fb_model or "default", + ) + return fb_client, fb_model, label + tried.append(label) + + if tried: + logger.debug( + "Auxiliary %s: configured fallback_chain exhausted (tried: %s)", + task, ", ".join(tried), + ) + return None, None, "" + + +def _resolve_single_provider( + provider: str, + model: Optional[str] = None, + base_url: Optional[str] = None, + api_key: [redacted:secret-assignment] = None, +) -> Optional[Any]: + """Resolve a single provider entry from fallback_chain to an OpenAI client. + + Uses the existing provider resolution infrastructure where possible. + """ + # Reuse resolve_provider_client which handles provider→client mapping + client, resolved_model = resolve_provider_client( + provider=provider, + model=model, + base_url=base_url, + api_key=[redacted:secret-assignment] + ) + return client + def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Optional[OpenAI], Optional[str]]: """Full auto-detection chain. @@ -4526,11 +4668,17 @@ def call_llm( or _is_connection_error(first_err) or _is_rate_limit_error(first_err) ) - # Only try alternative providers when the user didn't explicitly - # configure this task's provider. Explicit provider = hard constraint; - # auto (the default) = best-effort fallback chain. (#7559) + # Respect explicit provider choice for transient errors (auth, request + # validation, etc.) but allow fallback when the provider clearly cannot + # serve the request due to capacity: payment/quota exhaustion and + # connection failures are capacity problems, not request constraints. + # See #26803: daily token quota (429 + "too many tokens per day") must + # fall back just like a 402 credit error. is_auto = resolved_provider in {"auto", "", None} - if should_fallback and is_auto: + # Capacity errors bypass the explicit-provider gate: the provider + # literally cannot serve this request regardless of user intent. + is_capacity_error = _is_payment_error(first_err) or _is_connection_error(first_err) + if should_fallback and (is_auto or is_capacity_error): if _is_payment_error(first_err): reason = "payment error" # Resolve the actual provider label (resolved_provider may be @@ -4546,8 +4694,24 @@ def call_llm( reason = "connection error" logger.info("Auxiliary %s: %s on %s (%s), trying fallback", task or "call", reason, resolved_provider, first_err) - fb_client, fb_model, fb_label = _try_payment_fallback( - resolved_provider, task, reason=reason) + + # Fallback order (#26882, #26803): + # 1. User-configured fallback_chain (per-task) if set + # 2. Main agent model (last-resort safety net) + # For auto users (no explicit aux provider), use the full + # auto-detection chain instead — its Step 1 IS the main agent + # model, so users on `auto` already get main-model fallback. + fb_client, fb_model, fb_label = (None, None, "") + if is_auto: + fb_client, fb_model, fb_label = _try_payment_fallback( + resolved_provider, task, reason=reason) + else: + fb_client, fb_model, fb_label = _try_configured_fallback_chain( + task, resolved_provider or "auto", reason=reason) + if fb_client is None: + fb_client, fb_model, fb_label = _try_main_agent_model_fallback( + resolved_provider, task, reason=reason) + if fb_client is not None: fb_kwargs = _build_call_kwargs( fb_label, fb_model, messages, @@ -4557,6 +4721,14 @@ def call_llm( base_url=str(getattr(fb_client, "base_url", "") or "")) return _validate_llm_response( fb_client.chat.completions.create(**fb_kwargs), task) + # All fallback layers exhausted — emit a single user-visible + # warning so the operator knows aux task is about to fail. + # (#26882) The error itself is re-raised below. + logger.warning( + "Auxiliary %s: %s on %s and all fallbacks exhausted " + "(fallback_chain + main agent model). Raising original error.", + task or "call", reason, resolved_provider, + ) # Connection/timeout errors leave the cached client poisoned (closed # httpx transport, half-read stream, dead async loop). Drop it from # the cache regardless of whether we found a fallback above so the @@ -4858,8 +5030,12 @@ async def async_call_llm( or _is_connection_error(first_err) or _is_rate_limit_error(first_err) ) + # Capacity errors (payment/quota/connection) bypass the explicit-provider + # gate — the provider cannot serve the request regardless of user intent. + # See #26803: daily token quota must fall back like a 402 credit error. is_auto = resolved_provider in {"auto", "", None} - if should_fallback and is_auto: + is_capacity_error = _is_payment_error(first_err) or _is_connection_error(first_err) + if should_fallback and (is_auto or is_capacity_error): if _is_payment_error(first_err): reason = "payment error" _mark_provider_unhealthy( @@ -4871,8 +5047,23 @@ async def async_call_llm( reason = "connection error" logger.info("Auxiliary %s (async): %s on %s (%s), trying fallback", task or "call", reason, resolved_provider, first_err) - fb_client, fb_model, fb_label = _try_payment_fallback( - resolved_provider, task, reason=reason) + + # Fallback order (#26882, #26803): + # 1. User-configured fallback_chain (per-task) if set + # 2. Main agent model (last-resort safety net) + # Auto users get the full auto-detection chain instead — its + # Step 1 IS the main agent model. + fb_client, fb_model, fb_label = (None, None, "") + if is_auto: + fb_client, fb_model, fb_label = _try_payment_fallback( + resolved_provider, task, reason=reason) + else: + fb_client, fb_model, fb_label = _try_configured_fallback_chain( + task, resolved_provider or "auto", reason=reason) + if fb_client is None: + fb_client, fb_model, fb_label = _try_main_agent_model_fallback( + resolved_provider, task, reason=reason) + if fb_client is not None: fb_kwargs = _build_call_kwargs( fb_label, fb_model, messages, @@ -4888,6 +5079,12 @@ async def async_call_llm( fb_kwargs["model"] = async_fb_model return _validate_llm_response( await async_fb.chat.completions.create(**fb_kwargs), task) + # All fallback layers exhausted — warn before re-raising. (#26882) + logger.warning( + "Auxiliary %s (async): %s on %s and all fallbacks exhausted " + "(fallback_chain + main agent model). Raising original error.", + task or "call", reason, resolved_provider, + ) # Mirror the sync path: drop poisoned clients on connection/timeout # so the next aux call rebuilds. See issue #23432. if _is_connection_error(first_err): -
scripts/release.py@@ -184,6 +184,7 @@ "[redacted:email]": "npmisantosh", "[redacted:email]": "luarss", "[redacted:email]": "zccyman", + "[redacted:email]": "zccyman", # PR #26998 (auxiliary fallback chain) "[redacted:email]": "Osraka", "[redacted:email]": "Osraka", "[redacted:email]": "ryptotalent", -
tests/agent/test_auxiliary_client.py@@ -923,6 +923,44 @@ def test_no_status_code_no_message(self): exc = Exception("connection reset") assert _is_payment_error(exc) is False + # ── Daily / monthly quota exhaustion (#26803) ──────────────────────────── + + def test_429_quota_exceeded(self): + """Cloud provider quota exhaustion (e.g. Vertex AI) is a payment error.""" + exc = Exception("RESOURCE_EXHAUSTED: quota exceeded for project") + exc.status_code = 429 + assert _is_payment_error(exc) is True + + def test_429_too_many_tokens_per_day(self): + """Bedrock / LiteLLM daily token limit is a payment error.""" + exc = Exception("Too many tokens per day: 1000000 used, 1000000 limit") + exc.status_code = 429 + assert _is_payment_error(exc) is True + + def test_429_daily_limit_phrase(self): + """Generic 'daily limit' phrasing is a payment error.""" + exc = Exception("You have exceeded your daily limit.") + exc.status_code = 429 + assert _is_payment_error(exc) is True + + def test_429_resource_exhausted_grpc(self): + """Vertex AI gRPC RESOURCE_EXHAUSTED maps to payment error.""" + exc = Exception("resource exhausted") + exc.status_code = 429 + assert _is_payment_error(exc) is True + + def test_429_daily_quota_phrase(self): + """'daily quota' phrasing is a payment error.""" + exc = Exception("Daily quota of 500 requests reached.") + exc.status_code = 429 + assert _is_payment_error(exc) is True + + def test_429_transient_rate_limit_not_quota(self): + """Transient 429 rate limit without quota keywords is NOT a payment error.""" + exc = Exception("Rate limit exceeded. Retry after 10s.") + exc.status_code = 429 + assert _is_payment_error(exc) is False + class TestIsRateLimitError: """_is_rate_limit_error detects 429 rate-limit errors warranting fallback.""" @@ -1111,6 +1149,140 @@ def test_429_rate_limit_triggers_fallback(self, monkeypatch): # Fallback client should have been used assert fallback_client.chat.completions.create.called + +class TestAuxiliaryFallbackLayering: + """Explicit-provider users get layered fallback: configured_chain → main agent → warn.""" + + def _make_payment_err(self): + exc = Exception("Payment Required: insufficient credits") + exc.status_code = 402 + return exc + + def test_explicit_provider_uses_configured_chain_first(self, monkeypatch, caplog): + """When a user has fallback_chain configured, it's tried BEFORE the main agent model.""" + monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") + + primary_client = MagicMock() + primary_client.chat.completions.create.side_effect = self._make_payment_err() + + chain_client = MagicMock() + chain_client.chat.completions.create.return_value = MagicMock(choices=[ + MagicMock(message=MagicMock(content="from configured chain")) + ]) + + main_called = MagicMock() + + with patch("agent.auxiliary_client._get_cached_client", + return_value=(primary_client, "glm-4v-flash")), \ + patch("agent.auxiliary_client._resolve_task_provider_model", + return_value=("glm", "glm-4v-flash", None, None, None)), \ + patch("agent.auxiliary_client._try_configured_fallback_chain", + return_value=(chain_client, "gpt-4o-mini", "fallback_chain[0](openai)")), \ + patch("agent.auxiliary_client._try_main_agent_model_fallback", + side_effect=main_called): + result = call_llm( + task="vision", + messages=[{"role": "user", "content": "hello"}], + ) + + assert chain_client.chat.completions.create.called + # Main agent fallback should NOT have been consulted — chain succeeded first + main_called.assert_not_called() + + def test_explicit_provider_falls_back_to_main_when_chain_exhausted(self, monkeypatch): + """If configured fallback_chain returns nothing, main agent model is tried next.""" + monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") + + primary_client = MagicMock() + primary_client.chat.completions.create.side_effect = self._make_payment_err() + + main_client = MagicMock() + main_client.chat.completions.create.return_value = MagicMock(choices=[ + MagicMock(message=MagicMock(content="from main agent")) + ]) + + with patch("agent.auxiliary_client._get_cached_client", + return_value=(primary_client, "glm-4v-flash")), \ + patch("agent.auxiliary_client._resolve_task_provider_model", + return_value=("glm", "glm-4v-flash", None, None, None)), \ + patch("agent.auxiliary_client._try_configured_fallback_chain", + return_value=(None, None, "")), \ + patch("agent.auxiliary_client._try_main_agent_model_fallback", + return_value=(main_client, "claude-sonnet-4", "main-agent(openrouter)")): + result = call_llm( + task="vision", + messages=[{"role": "user", "content": "hello"}], + ) + + assert main_client.chat.completions.create.called + + def test_warning_emitted_when_all_fallbacks_exhausted(self, monkeypatch, caplog): + """When chain AND main model both fail, a user-visible warning fires before re-raise.""" + monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") + + primary_client = MagicMock() + primary_client.chat.completions.create.side_effect = self._make_payment_err() + + with patch("agent.auxiliary_client._get_cached_client", + return_value=(primary_client, "glm-4v-flash")), \ + patch("agent.auxiliary_client._resolve_task_provider_model", + return_value=("glm", "glm-4v-flash", None, None, None)), \ + patch("agent.auxiliary_client._try_configured_fallback_chain", + return_value=(None, None, "")), \ + patch("agent.auxiliary_client._try_main_agent_model_fallback", + return_value=(None, None, "")), \ + caplog.at_level("WARNING", logger="agent.auxiliary_client"): + with pytest.raises(Exception, match="Payment Required"): + call_llm( + task="vision", + messages=[{"role": "user", "content": "hello"}], + ) + + assert any( + "all fallbacks exhausted" in r.message for r in caplog.records + ), f"Expected exhaustion warning, got: {[r.message for r in caplog.records]}" + + +class TestTryMainAgentModelFallback: + """_try_main_agent_model_fallback resolves the user's main provider+model as a safety net.""" + + def test_returns_none_when_main_provider_is_auto(self): + from agent.auxiliary_client import _try_main_agent_model_fallback + with patch("agent.auxiliary_client._read_main_provider", return_value="auto"), \ + patch("agent.auxiliary_client._read_main_model", return_value="some-model"): + client, model, label = _try_main_agent_model_fallback("glm", task="vision") + assert client is None and model is None and label == "" + + def test_returns_none_when_failed_provider_equals_main(self): + """If the thing that failed IS the main model, no point retrying it.""" + from agent.auxiliary_client import _try_main_agent_model_fallback + with patch("agent.auxiliary_client._read_main_provider", return_value="openrouter"), \ + patch("agent.auxiliary_client._read_main_model", return_value="anthropic/claude-sonnet-4"): + client, model, label = _try_main_agent_model_fallback("openrouter", task="vision") + assert client is None and label == "" + + def test_resolves_main_provider_client(self): + from agent.auxiliary_client import _try_main_agent_model_fallback + fake_client = MagicMock() + with patch("agent.auxiliary_client._read_main_provider", return_value="openrouter"), \ + patch("agent.auxiliary_client._read_main_model", return_value="anthropic/claude-sonnet-4"), \ + patch("agent.auxiliary_client._is_provider_unhealthy", return_value=False), \ + patch("agent.auxiliary_client.resolve_provider_client", + return_value=(fake_client, "anthropic/claude-sonnet-4")): + client, model, label = _try_main_agent_model_fallback("glm", task="vision") + assert client is fake_client + assert model == "anthropic/claude-sonnet-4" + assert label == "main-agent(openrouter)" + + def test_skips_when_main_provider_is_unhealthy(self): + from agent.auxiliary_client import _try_main_agent_model_fallback + with patch("agent.auxiliary_client._read_main_provider", return_value="openrouter"), \ + patch("agent.auxiliary_client._read_main_model", return_value="anthropic/claude-sonnet-4"), \ + patch("agent.auxiliary_client._is_provider_unhealthy", return_value=True): + client, model, label = _try_main_agent_model_fallback("glm", task="vision") + assert client is None + + # --------------------------------------------------------------------------- # Gate: _resolve_api_key_provider must skip anthropic when not configured # --------------------------------------------------------------------------- @@ -2349,10 +2521,13 @@ def close(self): def test_call_llm_evicts_on_connection_error_with_explicit_provider(self): """Connection error on an explicit provider must drop the cached client. - This is the exact reporter scenario: ``auxiliary.compression.provider: - main`` (resolves to ``openai-codex``) → no fallback chain runs (not - auto), but the cached client was poisoned by a prior timeout and must - be evicted so the next call rebuilds. + Reporter scenario: ``auxiliary.compression.provider: main`` (resolves + to ``openai-codex``). After #26803, capacity errors (payment/quota/ + connection) DO trigger fallback even on explicit providers — so we + also stub ``_try_payment_fallback`` to ``(None, None, "")`` so the + connection error re-raises after eviction instead of escaping into + a real network call. The contract under test is cache eviction, + not the fallback gate. """ from agent.auxiliary_client import _client_cache, _client_cache_lock @@ -2372,6 +2547,9 @@ def test_call_llm_evicts_on_connection_error_with_explicit_provider(self): ), patch( "agent.auxiliary_client._get_cached_client", return_value=(poisoned, "gpt-5.5"), + ), patch( + "agent.auxiliary_client._try_payment_fallback", + return_value=(None, None, ""), ): with pytest.raises(ConnectionError): call_llm( @@ -2405,6 +2583,9 @@ async def test_async_call_llm_evicts_on_connection_error_with_explicit_provider( ), patch( "agent.auxiliary_client._get_cached_client", return_value=(poisoned, "gpt-5.5"), + ), patch( + "agent.auxiliary_client._try_payment_fallback", + return_value=(None, None, ""), ): with pytest.raises(ConnectionError): await async_call_llm( -
website/docs/user-guide/features/fallback-providers.md@@ -320,6 +320,55 @@ auxiliary: --- +## Auxiliary Capacity-Error Fallback + +When you set an explicit auxiliary provider (e.g. `auxiliary.vision.provider: glm`), Hermes treats that as your preferred choice — but if the provider literally cannot serve the request because of a **capacity error** (HTTP 402 payment required, HTTP 429 daily-quota exhaustion, connection failure), Hermes falls back through a layered chain instead of failing silently: + +1. **Primary aux provider** — the one you configured (tried first, always) +2. **`auxiliary.<task>.fallback_chain`** — your per-task override list, if you wrote one +3. **Main agent provider + model** — last-resort safety net (always tried, even if you didn't write a chain) +4. **Warn + re-raise** — if every layer fails, Hermes logs `Auxiliary <task>: ... all fallbacks exhausted` at WARNING level and re-raises the original error + +Transient HTTP 429 rate limits (`Retry-After: ...`) are treated as request constraints, not capacity problems — they respect your explicit provider choice and do **not** trigger the fallback ladder. Only daily/monthly quota exhaustion, payment errors, and connection failures bypass the explicit-provider gate. + +For users on `provider: auto` (no explicit aux provider), the existing auto-detection chain runs in place of steps 2–3. Its first step is already the main agent model, so `auto` users get the same outcome with zero config. + +### Optional: per-task fallback chain + +If you want a different fallback ordering than "main agent model first", configure `fallback_chain` explicitly. Each entry needs at least `provider`; `model`, `base_url`, and `api_key` are optional. + +```yaml +auxiliary: + vision: + provider: glm + model: glm-4v-flash + fallback_chain: + - provider: openrouter + model: google/gemini-3-flash-preview + - provider: nous + model: anthropic/claude-sonnet-4 + + compression: + provider: openrouter + fallback_chain: + - provider: openai + model: gpt-4o-mini +``` + +You do **not** need to configure `fallback_chain` to get fallback — the main-agent safety net runs regardless. Use it only when you specifically want a different order than the default. + +### Provider quota errors that trigger fallback + +Hermes recognizes these as capacity-equivalent to 402 credit exhaustion (not transient rate limits): + +- Bedrock / LiteLLM: `Too many tokens per day`, `daily limit`, `tokens per day` +- Vertex AI / GCP: `quota exceeded`, `resource exhausted`, `RESOURCE_EXHAUSTED` +- Generic: `daily quota`, `quota_exceeded` + +If your provider returns a different phrase for daily-quota exhaustion and Hermes doesn't trigger fallback, that's a bug — open an issue with the exact error string. + +--- + ## Context Compression Fallback Context compression uses the `auxiliary.compression` config block to control which model and provider handles summarization: @@ -378,14 +427,16 @@ 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 — per-turn failover on errors (primary restored each turn) | `fallback_model:` (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` | -| Session search | Auto-detection chain | `auxiliary.session_search` | -| Skills hub | Auto-detection chain | `auxiliary.skills_hub` | -| MCP helpers | Auto-detection chain | `auxiliary.mcp` | -| Approval classification | Auto-detection chain | `auxiliary.approval` | -| Title generation | Auto-detection chain | `auxiliary.title_generation` | -| Triage specifier | Auto-detection chain | `auxiliary.triage_specifier` | +| Auxiliary tasks (any) — auto users | Full auto-detection chain (main agent model first, then provider chain) on capacity errors | `auxiliary.<task>.provider: auto` | +| Auxiliary tasks (any) — explicit provider | `fallback_chain` (if set) → main agent model → warn + raise, on capacity errors only | `auxiliary.<task>.fallback_chain` | +| Vision | Layered (see above) + internal OpenRouter retry | `auxiliary.vision` | +| Web extraction | Layered (see above) + internal OpenRouter retry | `auxiliary.web_extract` | +| Context compression | Layered (see above); degrades to no-summary if all layers unavailable | `auxiliary.compression` | +| Session search | Layered (see above) | `auxiliary.session_search` | +| Skills hub | Layered (see above) | `auxiliary.skills_hub` | +| MCP helpers | Layered (see above) | `auxiliary.mcp` | +| Approval classification | Layered (see above) | `auxiliary.approval` | +| Title generation | Layered (see above) | `auxiliary.title_generation` | +| Triage specifier | Layered (see above) | `auxiliary.triage_specifier` | | Delegation | Provider override only (no automatic fallback) | `delegation.provider` / `delegation.model` | | Cron jobs | Per-job provider override only (no automatic fallback) | Per-job `provider` / `model` |
-
-
#52661 fix(auxiliary): make compression survive a bad/limited fallback (salvage #49076 #52230 #51835 #52421)
files:
agent/auxiliary_client.py,agent/conversation_compression.py,scripts/release.py,tests/agent/test_auxiliary_client.py,tests/run_agent/test_compression_feasibility.pyShow the diff
-
agent/auxiliary_client.py@@ -101,6 +101,7 @@ def __repr__(self): OpenAI = _OpenAIProxy() # module-level name, resolves lazily on call/isinstance from agent.credential_pool import load_pool +from agent.model_metadata import MINIMUM_CONTEXT_LENGTH, get_model_context_length from hermes_cli.config import get_hermes_home from hermes_constants import OPENROUTER_BASE_URL from utils import base_url_host_matches, base_url_hostname, env_float, model_forces_max_completion_tokens, normalize_proxy_env_vars @@ -2470,7 +2471,7 @@ def _is_payment_error(exc: Exception) -> bool: # but sometimes wrap them in 429 or other codes. # Daily quota exhaustion from Bedrock, Vertex AI, and similar providers # uses different language but is semantically identical to credit exhaustion. - if status in {402, 404, 429, None}: + if status in {402, 403, 404, 429, None}: if any(kw in err_lower for kw in ( "credits", "insufficient funds", "can only afford", "billing", @@ -2479,6 +2480,8 @@ def _is_payment_error(exc: Exception) -> bool: "balance_depleted", "no usable credits", "model_not_supported_on_free_tier", "not available on the free tier", + "requires a subscription", "upgrade for access", + "upgrade for higher limits", "reached your session usage limit", # Daily / monthly / weekly quota exhaustion keywords "quota exceeded", "quota_exceeded", "too many tokens per day", "daily limit", @@ -2697,6 +2700,60 @@ def _is_model_not_found_error(exc: Exception) -> bool: )) +def _is_model_incompatible_error(exc: Exception) -> bool: + """Detect "this route cannot serve this model" 400s (capability mismatch). + + Distinct from :func:`_is_model_not_found_error` (the model does not exist + anywhere): here the model name is valid but the *current provider/account* + is structurally unable to run it. The canonical case is a configured + fallback that cannot run the main model — e.g. an ``openai-codex`` / + ChatGPT-account fallback asked to compress a ``glm-5.2`` conversation:: + + Error code: 400 - {'detail': "The 'glm-5.2' model is not supported + when using Codex with a ChatGPT account."} + + The candidate authenticates fine and builds a client, so the auth and + payment predicates don't fire and the call would otherwise raise and + abort the whole auxiliary task (commonly compression — which then drops + middle turns and churns the session, destroying the prompt cache). + Treating it as a fallback-worthy capability error lets the chain skip the + incapable route and continue to the next candidate, mirroring the + context-window feasibility screen (#52392). + + Billing/quota 400s belong to :func:`_is_payment_error`; "model does not + exist" 400s belong to :func:`_is_model_not_found_error`. This predicate + explicitly excludes both so the three don't overlap. + """ + status = getattr(exc, "status_code", None) + if status not in {400, None}: + return False + err_lower = str(exc).lower() + # Not-found 400s ("invalid model ID", "model does not exist") are owned by + # _is_model_not_found_error. Billing/free-tier 400s are owned by the + # payment path — key on the billing keywords directly here rather than + # calling _is_payment_error(), because that predicate is status-gated + # ({402,403,404,429,None}) and would not recognise a 400-coded billing + # body, letting it leak into this capability bucket. + if _is_model_not_found_error(exc): + return False + if any(kw in err_lower for kw in ( + "credits", "insufficient funds", "billing", "out of funds", + "balance_depleted", "no usable credits", "payment required", + "free tier", "free-tier", "not available on the free tier", + "model_not_supported_on_free_tier", "quota", + )): + return False + return any(kw in err_lower for kw in ( + "is not supported when using", # codex/ChatGPT-account model gating + "model is not supported", + "not supported with this", + "not supported for this account", + "model_not_supported", + "does not support this model", + "unsupported model", + )) + + def _evict_cached_clients(provider: str) -> None: """Drop cached auxiliary clients for a provider so fresh creds are used.""" normalized = _normalize_aux_provider(provider) @@ -3147,6 +3204,88 @@ def _try_main_agent_model_fallback( return client, resolved_model or main_model, label +# ── Context-window screening for runtime fallback chains (issue #52392) ── +# +# When the runtime auxiliary fallback chain selects a candidate that is +# reachable but has a context window smaller than the compression task +# requires, the call errors out instead of continuing to the next, viable +# candidate. The startup feasibility check in +# ``agent.conversation_compression.check_compression_model_feasibility`` +# already filters too-small auxiliary models at startup, but the runtime +# fallback chain (``_try_configured_fallback_chain`` and +# ``_try_main_fallback_chain``) does not apply the same filter, so +# compression can stop at the first alive door even if the room behind it +# is too small. +# +# The helpers below screen each candidate by its effective context window +# before it is returned. ``None`` results from ``get_model_context_length`` +# are passed through (we cannot prove a model is too small, so we do not +# block it). This preserves the existing fallback surface for +# unrecognised/custom models while closing the gap on the well-known ones. + +def _task_minimum_context_length(task: Optional[str]) -> Optional[int]: + """Return the minimum context length required for an auxiliary task. + + Only ``compression`` carries an explicit minimum today (the same + ``MINIMUM_CONTEXT_LENGTH`` (64K) floor that + ``check_compression_model_feasibility`` already enforces at startup). + Other tasks (``vision``, ``title_generation``, ``web_extract``, + ``skills_hub``, ``mcp``, ``session_search``) return ``None`` — they + have no per-task context floor and the runtime chain must remain + permissive for them. + + Returns ``None`` for an empty/``None`` task name so the helper is a + safe no-op when called from generic sites. + """ + if not task: + return None + if task == "compression": + return MINIMUM_CONTEXT_LENGTH + return None + + +def _candidate_context_window( + provider: str, + model: str, + base_url: str = "", + api_key: str = "", +) -> Optional[int]: + """Resolve the effective context window for a fallback candidate. + + Thin wrapper around :func:`agent.model_metadata.get_model_context_length` + that swallows probe failures (returns ``None``). Callers treat + ``None`` as "unknown — pass through" so the existing fallback + surface is preserved when the context-length resolver chain cannot + determine a value (custom endpoints, models not in the registry, + offline endpoints). + + Best-effort, never raises — the runtime fallback chain must keep + moving even if the resolver hits a probe error. + """ + if not model: + return None + try: + ctx = get_model_context_length( + model, + base_url=base_url, + api_key=[redacted:secret-assignment] + provider=provider, + ) + except Exception as exc: + logger.debug( + "Auxiliary fallback: could not resolve context window for %s/%s: %s", + provider, model, exc, + ) + return None + # ``get_model_context_length`` returns an int (with a 256K default + # fallback when nothing else matches). We still propagate ``None`` if + # a future change returns ``Optional[int]`` — being explicit is + # cheap and the test suite covers both shapes. + if isinstance(ctx, int) and ctx > 0: + return ctx + return None + + def _try_configured_fallback_chain( task: str, failed_provider: str, @@ -3171,6 +3310,7 @@ def _try_configured_fallback_chain( skip = failed_provider.lower().strip() tried = [] + min_ctx = _task_minimum_context_length(task) for i, entry in enumerate(chain): if not isinstance(entry, dict): @@ -3188,6 +3328,20 @@ def _try_configured_fallback_chain( fb_client, resolved_model = None, None if fb_client is not None: + if min_ctx is not None and resolved_model: + fb_ctx = _candidate_context_window( + fb_provider, + resolved_model, + base_url=str(entry.get("base_url") or ""), + api_key=[redacted:secret-assignment] or "", + ) + if fb_ctx is not None and fb_ctx < min_ctx: + logger.info( + "Auxiliary %s: skipping %s (%s context=%d < min=%d), continuing chain", + task, label, resolved_model, fb_ctx, min_ctx, + ) + tried.append(f"{label} (context too small: {fb_ctx}<{min_ctx})") + continue logger.info( "Auxiliary %s: %s on %s — configured fallback to %s (%s)", task, reason, failed_provider, label, resolved_model or fb_model or "default", @@ -3203,6 +3357,28 @@ def _try_configured_fallback_chain( return None, None, "" +def _try_configured_fallback_for_unavailable_client( + task: Optional[str], + failed_provider: str, +) -> Tuple[Optional[Any], Optional[str], str]: + """Try task fallback_chain when an explicit aux provider cannot build. + + This covers the "no client" case before any request is sent: missing + raw env key, unavailable OAuth/pool credentials, or provider resolver + returning ``(None, None)``. It deliberately stops at the configured + per-task fallback chain; the main-agent model remains the last-resort + runtime fallback for request-time capacity errors. + """ + explicit = (failed_provider or "").strip().lower() + if not task or not explicit or explicit in {"auto"}: + return None, None, "" + return _try_configured_fallback_chain( + task, + explicit, + reason="provider unavailable", + ) + + def _fallback_entry_api_key(entry: Dict[str, Any]) -> Optional[str]: """Resolve inline or env-backed API key from a fallback-chain entry.""" explicit = str(entry.get("api_key") or "").strip() @@ -3261,6 +3437,7 @@ def _try_main_fallback_chain( main_norm = (_read_main_provider() or "").strip().lower() skip = {p for p in (failed_norm, main_norm, "auto") if p} tried: List[str] = [] + min_ctx = _task_minimum_context_length(task) for i, entry in enumerate(chain): if not isinstance(entry, dict): @@ -3284,6 +3461,20 @@ def _try_main_fallback_chain( logger.debug("Auxiliary %s: main fallback %s failed to resolve: %s", task or "call", label, exc) fb_client, resolved_model = None, None if fb_client is not None: + if min_ctx is not None: + fb_ctx = _candidate_context_window( + fb_provider, + resolved_model or fb_model, + base_url=str(entry.get("base_url") or ""), + api_key=[redacted:secret-assignment] or "", + ) + if fb_ctx is not None and fb_ctx < min_ctx: + logger.info( + "Auxiliary %s: skipping %s (context=%d < min=%d), continuing chain", + task or "call", label, fb_ctx, min_ctx, + ) + tried.append(f"{label} (context too small: {fb_ctx}<{min_ctx})") + continue logger.info( "Auxiliary %s: %s on %s — main fallback chain to %s (%s)", task or "call", reason, failed_provider or "auto", label, @@ -5344,21 +5535,30 @@ def call_llm( ) if client is None: # When the user explicitly chose a non-OpenRouter provider but no - # credentials were found, fail fast instead of silently routing - # through OpenRouter (which causes confusing 404s). + # credentials were found, honor the task fallback_chain before + # raising. Missing raw env keys are recoverable for auxiliary + # tasks because fallback entries may use OAuth / credential-pool + # auth (for example openai-codex). _explicit = (resolved_provider or "").strip().lower() if _explicit and _explicit not in {"auto", "openrouter", "custom"}: - raise RuntimeError( - f"Provider '{_explicit}' is set in config.yaml but no API key " - f"was found. Set the {_explicit.upper()}_API_KEY environment " - f"variable, or switch to a different provider with `hermes model`." + fb_client, fb_model, fb_label = _try_configured_fallback_for_unavailable_client( + task, _explicit, ) + if fb_client is not None: + client, final_model = fb_client, fb_model + resolved_provider = fb_label or resolved_provider + else: + raise RuntimeError( + f"Provider '{_explicit}' is set in config.yaml but no API key " + f"was found. Set the {_explicit.upper()}_API_KEY environment " + f"variable, or switch to a different provider with `hermes model`." + ) # For auto/custom with no credentials, try the full auto chain # rather than hardcoding OpenRouter (which may be depleted). # Pass model=None so each provider uses its own default — # resolved_model may be an OpenRouter-format slug that doesn't # work on other providers. - if not resolved_base_url: + if client is None and not resolved_base_url: logger.info("Auxiliary %s: provider %s unavailable, trying auto-detection chain", task or "call", resolved_provider) client, final_model = _get_cached_client("auto", main_runtime=main_runtime, task=task) @@ -5657,6 +5857,7 @@ def call_llm( _is_payment_error(first_err) or _is_connection_error(first_err) or _is_rate_limit_error(first_err) + or _is_model_incompatible_error(first_err) ) # Respect explicit provider choice for transient errors (auth, request # validation, etc.) but allow fallback when the provider clearly cannot @@ -5667,7 +5868,19 @@ def call_llm( is_auto = resolved_provider in {"auto", "", None} # Capacity errors bypass the explicit-provider gate: the provider # literally cannot serve this request regardless of user intent. - is_capacity_error = _is_payment_error(first_err) or _is_connection_error(first_err) + # Rate limits are included: after retries are exhausted, a 429 means + # the provider cannot serve this request — fall back. See #52228. + # Model-incompatibility 400s are also a hard capability mismatch (the + # route cannot run this model at all — e.g. a codex/ChatGPT-account + # fallback asked to compress a glm-5.2 conversation), so they bypass + # the explicit-provider gate and continue to the next candidate + # instead of aborting the auxiliary task and churning the session. + is_capacity_error = ( + _is_payment_error(first_err) + or _is_connection_error(first_err) + or _is_rate_limit_error(first_err) + or _is_model_incompatible_error(first_err) + ) if should_fallback and (is_auto or is_capacity_error): if _is_payment_error(first_err): reason = "payment error" @@ -5680,6 +5893,8 @@ def call_llm( ) elif _is_rate_limit_error(first_err): reason = "rate limit" + elif _is_model_incompatible_error(first_err): + reason = "model incompatible with route" else: reason = "connection error" logger.info("Auxiliary %s: %s on %s (%s), trying fallback", @@ -5854,12 +6069,21 @@ async def async_call_llm( if client is None: _explicit = (resolved_provider or "").strip().lower() if _explicit and _explicit not in {"auto", "openrouter", "custom"}: - raise RuntimeError( - f"Provider '{_explicit}' is set in config.yaml but no API key " - f"was found. Set the {_explicit.upper()}_API_KEY environment " - f"variable, or switch to a different provider with `hermes model`." + fb_client, fb_model, fb_label = _try_configured_fallback_for_unavailable_client( + task, _explicit, ) - if not resolved_base_url: + if fb_client is not None: + client, final_model = _to_async_client( + fb_client, fb_model or "", is_vision=(task == "vision") + ) + resolved_provider = fb_label or resolved_provider + else: + raise RuntimeError( + f"Provider '{_explicit}' is set in config.yaml but no API key " + f"was found. Set the {_explicit.upper()}_API_KEY environment " + f"variable, or switch to a different provider with `hermes model`." + ) + if client is None and not resolved_base_url: logger.info("Auxiliary %s: provider %s unavailable, trying auto-detection chain", task or "call", resolved_provider) client, final_model = _get_cached_client("auto", async_mode=True, main_runtime=main_runtime, task=task) @@ -6109,12 +6333,22 @@ async def async_call_llm( _is_payment_error(first_err) or _is_connection_error(first_err) or _is_rate_limit_error(first_err) + or _is_model_incompatible_error(first_err) ) - # Capacity errors (payment/quota/connection) bypass the explicit-provider - # gate — the provider cannot serve the request regardless of user intent. + # Capacity errors (payment/quota/connection/rate-limit) bypass the + # explicit-provider gate — the provider cannot serve the request + # regardless of user intent. Rate limits are included: after retries + # are exhausted, a 429 means the provider is at capacity. See #52228. # See #26803: daily token quota must fall back like a 402 credit error. + # Model-incompatibility 400s (route cannot run this model at all) + # bypass the gate too — see the sync call_llm() path for rationale. is_auto = resolved_provider in {"auto", "", None} - is_capacity_error = _is_payment_error(first_err) or _is_connection_error(first_err) + is_capacity_error = ( + _is_payment_error(first_err) + or _is_connection_error(first_err) + or _is_rate_limit_error(first_err) + or _is_model_incompatible_error(first_err) + ) if should_fallback and (is_auto or is_capacity_error): if _is_payment_error(first_err): reason = "payment error" @@ -6123,6 +6357,8 @@ async def async_call_llm( ) elif _is_rate_limit_error(first_err): reason = "rate limit" + elif _is_model_incompatible_error(first_err): + reason = "model incompatible with route" else: reason = "connection error" logger.info("Auxiliary %s (async): %s on %s (%s), trying fallback", -
agent/conversation_compression.py@@ -90,6 +90,7 @@ def check_compression_model_feasibility(agent: Any) -> None: try: from agent.auxiliary_client import ( _resolve_task_provider_model, + _try_configured_fallback_for_unavailable_client, get_text_auxiliary_client, ) from agent.model_metadata import ( @@ -97,10 +98,6 @@ def check_compression_model_feasibility(agent: Any) -> None: get_model_context_length, ) - client, aux_model = get_text_auxiliary_client( - "compression", - main_runtime=agent._current_main_runtime(), - ) # Best-effort aux provider label for the warning message. The # configured provider may be "auto", in which case we fall back # to the client's base_url hostname so the user can still tell @@ -109,6 +106,19 @@ def check_compression_model_feasibility(agent: Any) -> None: _aux_cfg_provider, _, _, _, _ = _resolve_task_provider_model("compression") except Exception: _aux_cfg_provider = "" + client, aux_model = get_text_auxiliary_client( + "compression", + main_runtime=agent._current_main_runtime(), + ) + if client is None or not aux_model: + fb_client, fb_model, fb_label = _try_configured_fallback_for_unavailable_client( + "compression", + _aux_cfg_provider, + ) + if fb_client is not None and fb_model: + client, aux_model = fb_client, fb_model + if "(" in fb_label and fb_label.endswith(")"): + _aux_cfg_provider = fb_label.rsplit("(", 1)[1][:-1] if client is None or not aux_model: if _aux_cfg_provider and _aux_cfg_provider != "auto": msg = ( -
scripts/release.py@@ -45,6 +45,8 @@ # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { + "[redacted:email]": "herbalizer404", # PR #49076 + #51835 salvage (auxiliary compression fallback: 403/session-usage payment errors + honor fallback chain when aux provider auth unavailable) + "[redacted:email]": "pyxl-dev", # PR #52230 salvage (include rate-limit in auxiliary capacity-error fallback gate; #52228) "[redacted:email]": "benbenlijie", # PR #47205 salvage (named custom-provider extra_body + Z.AI Coding overload adaptive backoff; #50663) "[redacted:email]": "Danamove", # PR #46726 salvage (kill venv-resident pythonw gateway before recreating venv on Windows; #47036/#47557/#47910) "[redacted:email]": "wgu9", # PR #51468 salvage (WSL/no-systemd orphan gateway tracking, #51325) -
tests/agent/test_auxiliary_client.py@@ -23,6 +23,7 @@ _is_payment_error, _is_rate_limit_error, _is_model_not_found_error, + _is_model_incompatible_error, _refresh_nous_recommended_model, _normalize_aux_provider, _try_payment_fallback, @@ -1406,6 +1407,21 @@ def test_404_free_tier_model_block_is_payment(self): exc.status_code = 404 assert _is_payment_error(exc) is True + def test_403_subscription_required_is_payment(self): + exc = Exception( + "this model requires a subscription, upgrade for access: " + "https://ollama.com/upgrade" + ) + setattr(exc, "status_code", 403) + assert _is_payment_error(exc) is True + + def test_429_session_usage_limit_is_payment(self): + exc = Exception( + "you have reached your session usage limit, upgrade for higher limits" + ) + setattr(exc, "status_code", 429) + assert _is_payment_error(exc) is True + def test_404_generic_not_found_is_not_payment(self): exc = Exception("Not Found") exc.status_code = 404 @@ -1527,6 +1543,60 @@ def test_500_is_not_model_not_found(self): assert _is_model_not_found_error(exc) is False +class TestIsModelIncompatibleError: + """_is_model_incompatible_error detects 400s where the route cannot run + the model at all (capability mismatch), distinct from not-found and + payment errors.""" + + def test_codex_chatgpt_account_model_gating(self): + """The exact incident: an openai-codex/ChatGPT-account fallback asked + to compress a glm-5.2 conversation.""" + exc = Exception( + "Error code: 400 - {'detail': \"The 'glm-5.2' model is not " + "supported when using Codex with a ChatGPT account.\"}" + ) + exc.status_code = 400 + assert _is_model_incompatible_error(exc) is True + + def test_model_is_not_supported_phrasing(self): + exc = Exception("This model is not supported for this endpoint") + exc.status_code = 400 + assert _is_model_incompatible_error(exc) is True + + def test_unsupported_model_keyword(self): + exc = Exception("unsupported model for this account tier") + exc.status_code = 400 + assert _is_model_incompatible_error(exc) is True + + def test_not_found_is_not_incompatible(self): + """A model-does-not-exist 400 belongs to _is_model_not_found_error — + the two predicates must not overlap.""" + exc = Exception("openrouter/foo/bar is not a valid model ID") + exc.status_code = 400 + assert _is_model_incompatible_error(exc) is False + assert _is_model_not_found_error(exc) is True + + def test_payment_400_is_not_incompatible(self): + """A billing 400 that also contains capability-ish phrasing must be + rejected here — billing keywords win so the payment path owns it and + the two buckets don't overlap.""" + exc = Exception("insufficient credits: model is not supported on free tier") + exc.status_code = 400 + assert _is_model_incompatible_error(exc) is False + + def test_wrong_status_is_not_incompatible(self): + exc = Exception("model is not supported") # right phrase, wrong status + exc.status_code = 500 + assert _is_model_incompatible_error(exc) is False + + def test_generic_400_is_not_incompatible(self): + """A plain request-validation 400 without capability phrasing must not + trigger fallback (we respect explicit-provider choice for those).""" + exc = Exception("invalid value for parameter temperature") + exc.status_code = 400 + assert _is_model_incompatible_error(exc) is False + + class TestRefreshNousRecommendedModel: """_refresh_nous_recommended_model picks a fresh model after a stale 404.""" @@ -1870,6 +1940,44 @@ def test_explicit_provider_falls_back_to_main_when_chain_exhausted(self, monkeyp assert main_client.chat.completions.create.called + def test_explicit_provider_rate_limit_triggers_fallback(self, monkeypatch): + """429 rate-limit on an explicit provider must trigger fallback (not be ignored). + + Regression test for #52228: rate limits were excluded from + ``is_capacity_error``, so explicit-provider auxiliary calls never + fell back on 429 — only auto-provider calls did. + """ + monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") + + primary_client = MagicMock() + rate_err = Exception("Rate limit exceeded, try again in 60 seconds") + rate_err.status_code = 429 + primary_client.chat.completions.create.side_effect = rate_err + + fallback_client = MagicMock() + fallback_client.chat.completions.create.return_value = MagicMock(choices=[ + MagicMock(message=MagicMock(content="from fallback chain")) + ]) + + with patch("agent.auxiliary_client._get_cached_client", + return_value=(primary_client, "gpt-5.5")), \ + patch("agent.auxiliary_client._resolve_task_provider_model", + return_value=("openai-codex", "gpt-5.5", None, None, None)), \ + patch("agent.auxiliary_client._try_configured_fallback_chain", + return_value=(fallback_client, "deepseek-v4-pro", "fallback_chain[0](opencode-go)")) as mock_chain, \ + patch("agent.auxiliary_client._try_main_agent_model_fallback") as mock_main: + result = call_llm( + task="kanban_decomposer", + messages=[{"role": "user", "content": "decompose this"}], + ) + + # Fallback chain MUST be tried for rate-limit on explicit provider + mock_chain.assert_called() + assert fallback_client.chat.completions.create.called + # Main agent fallback should NOT be needed when chain succeeds + mock_main.assert_not_called() + + def test_warning_emitted_when_all_fallbacks_exhausted(self, monkeypatch, caplog): """When chain AND main model both fail, a user-visible warning fires before re-raise.""" monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") @@ -1896,6 +2004,81 @@ def test_warning_emitted_when_all_fallbacks_exhausted(self, monkeypatch, caplog) "all fallbacks exhausted" in r.message for r in caplog.records ), f"Expected exhaustion warning, got: {[r.message for r in caplog.records]}" + def test_explicit_provider_no_client_uses_configured_chain_before_error(self, monkeypatch): + """Missing primary credentials should still honor auxiliary fallback_chain.""" + chain_client = MagicMock() + chain_client.chat.completions.create.return_value = MagicMock(choices=[ + MagicMock(message=MagicMock(content="from configured chain")) + ]) + + with patch("agent.auxiliary_client._get_cached_client", + return_value=(None, None)), \ + patch("agent.auxiliary_client._resolve_task_provider_model", + return_value=("ollama-cloud", "deepseek-v4-flash:cloud", None, None, None)), \ + patch("agent.auxiliary_client._try_configured_fallback_chain", + return_value=(chain_client, "gpt-5.4-mini", "fallback_chain[0](openai-codex)")) as mock_chain: + result = call_llm( + task="compression", + messages=[{"role": "user", "content": "hello"}], + ) + + assert chain_client.chat.completions.create.called + assert result.choices[0].message.content == "from configured chain" + mock_chain.assert_called_once_with( + "compression", + "ollama-cloud", + reason="provider unavailable", + ) + + def test_explicit_provider_no_client_without_chain_keeps_clear_error(self, monkeypatch): + """No fallback configured: keep the existing actionable missing-key error.""" + with patch("agent.auxiliary_client._get_cached_client", + return_value=(None, None)), \ + patch("agent.auxiliary_client._resolve_task_provider_model", + return_value=("ollama-cloud", "deepseek-v4-flash:cloud", None, None, None)), \ + patch("agent.auxiliary_client._try_configured_fallback_chain", + return_value=(None, None, "")) as mock_chain: + with pytest.raises(RuntimeError, match="Provider 'ollama-cloud'.*no API key"): + call_llm( + task="compression", + messages=[{"role": "user", "content": "hello"}], + ) + + mock_chain.assert_called_once_with( + "compression", + "ollama-cloud", + reason="provider unavailable", + ) + + def test_fallback_entry_openai_codex_uses_oauth_pool_without_inline_key(self): + """Configured Codex fallback resolves through Hermes auth / credential pool.""" + from agent.auxiliary_client import _resolve_fallback_entry + + pool_entry = MagicMock() + pool_entry.id = "codex-pool-1" + pool_entry.runtime_api_key = "[redacted:secret-assignment]" + pool_entry.access_token = "[redacted:secret-assignment]" + pool_entry.runtime_base_url = "https://chatgpt.com/backend-api/codex" + + real_client = MagicMock() + real_client.api_key = "[redacted:secret-assignment]" + real_client.base_url = "https://chatgpt.com/backend-api/codex" + + with patch("agent.auxiliary_client._select_pool_entry", + return_value=(True, pool_entry)), \ + patch("agent.auxiliary_client._read_codex_access_token", + side_effect=AssertionError("should use pool token")), \ + patch("agent.auxiliary_client.OpenAI", return_value=real_client) as mock_openai: + client, model = _resolve_fallback_entry({ + "provider": "openai-codex", + "model": "gpt-5.4-mini", + }) + + assert client is not None + assert model == "gpt-5.4-mini" + mock_openai.assert_called_once() + assert mock_openai.call_args.kwargs["api_key"] == "codex-oauth-token" + class TestTryMainAgentModelFallback: """_try_main_agent_model_fallback resolves the user's main provider+model as a safety net.""" @@ -4070,3 +4253,257 @@ def test_empty_model_falls_back_to_url_only(self): ): assert auxiliary_max_tokens_param(4096, model="") == {"max_tokens": 4096} assert auxiliary_max_tokens_param(4096, model=None) == {"max_tokens": 4096} + + +# ── Regression tests for issue #52392 ───────────────────────────────────── +# Compression fallback chain currently picks the first reachable candidate +# without checking whether the candidate's context window is large enough. +# When the chosen candidate is reachable but too small for the compression +# task, the call errors out instead of continuing through the chain. + +class TestCompressionFallbackContextFilter: + """Aux fallback chains must skip candidates whose context window is + smaller than the task minimum, then continue to the next candidate. + + Layer coverage: + L2: _try_configured_fallback_chain skips too-small candidates + L3: _try_main_fallback_chain skips too-small candidates + L4: candidates with unknown context (None) are passed through + L5: backward compat — first viable candidate still wins + """ + + @staticmethod + def _make_chain_entry(provider, model, base_url="https://example.com/v1", + api_key="k"): + return { + "provider": provider, + "model": model, + "base_url": base_url, + "api_key": api_key, + } + + def _mock_resolve(self, entry): + """Mock _resolve_fallback_entry to return a (client, model) per entry.""" + client = MagicMock() + client.base_url = entry.get("base_url", "") + return client, entry["model"] + + # ── L2: configured fallback chain ───────────────────────────────── + + def test_configured_chain_skips_too_small_candidate_for_compression(self, monkeypatch): + """When entry[0] is reachable but too small and entry[1] is large enough, + _try_configured_fallback_chain must return entry[1], not entry[0].""" + from agent.auxiliary_client import ( + _try_configured_fallback_chain, + ) + + small_client = MagicMock(name="small_client") + large_client = MagicMock(name="large_client") + entries = [ + self._make_chain_entry("small-provider", "tiny-8k"), + self._make_chain_entry("big-provider", "huge-1m"), + ] + + def fake_resolve(entry): + if entry is entries[0]: + return small_client, "tiny-8k" + return large_client, "huge-1m" + + # tiny-8k resolves to 8K (below 64K floor); huge-1m resolves to 1M + def fake_ctx(model, base_url="", api_key="", **kwargs): + return {"tiny-8k": 8192, "huge-1m": 1_048_576}.get(model, 256_000) + + monkeypatch.setattr( + "agent.auxiliary_client._get_auxiliary_task_config", + lambda task: {"fallback_chain": entries} if task == "compression" else {}, + ) + + with patch("agent.auxiliary_client._resolve_fallback_entry", + side_effect=fake_resolve), \ + patch("agent.auxiliary_client.get_model_context_length", + side_effect=fake_ctx): + client, model, label = _try_configured_fallback_chain( + task="compression", failed_provider="auto") + + assert client is large_client, ( + f"Expected large_client (1M context), got {client}. " + "L2 bug: chain returned the first reachable candidate without " + "screening by context window.") + assert model == "huge-1m" + assert "big-provider" in label + + def test_configured_chain_continues_after_skipping_too_small(self, monkeypatch): + """When all small candidates are skipped and only the last is large enough, + the chain still returns it (does not stop after first filter).""" + from agent.auxiliary_client import _try_configured_fallback_chain + + small_client_a = MagicMock(name="small_a") + small_client_b = MagicMock(name="small_b") + large_client = MagicMock(name="large") + entries = [ + self._make_chain_entry("p1", "small-a-32k"), + self._make_chain_entry("p2", "small-b-48k"), + self._make_chain_entry("p3", "large-512k"), + ] + + def fake_resolve(entry): + if entry is entries[0]: + return small_client_a, "small-a-32k" + if entry is entries[1]: + return small_client_b, "small-b-48k" + return large_client, "large-512k" + + def fake_ctx(model, base_url="", api_key="", **kwargs): + return {"small-a-32k": 32_000, + "small-b-48k": 48_000, + "large-512k": 512_000}.get(model, 256_000) + + monkeypatch.setattr( + "agent.auxiliary_client._get_auxiliary_task_config", + lambda task: {"fallback_chain": entries} if task == "compression" else {}, + ) + + with patch("agent.auxiliary_client._resolve_fallback_entry", + side_effect=fake_resolve), \ + patch("agent.auxiliary_client.get_model_context_length", + side_effect=fake_ctx): + client, model, label = _try_configured_fallback_chain( + task="compression", failed_provider="auto") + + assert client is large_client + assert model == "large-512k" + + # ── L3: main fallback chain ──────────────────────────────────────── + + def test_main_chain_skips_too_small_candidate_for_compression(self, monkeypatch): + """Same behaviour for the top-level main-agent fallback chain.""" + from agent.auxiliary_client import ( + _try_main_fallback_chain, + ) + + small_client = MagicMock(name="small_main") + large_client = MagicMock(name="large_main") + + # Mock load_config + get_fallback_chain to return our controlled chain + chain = [ + self._make_chain_entry("p-small", "tiny-16k"), + self._make_chain_entry("p-large", "huge-1m"), + ] + + def fake_resolve(entry): + if entry is chain[0]: + return small_client, "tiny-16k" + return large_client, "huge-1m" + + def fake_ctx(model, base_url="", api_key="", **kwargs): + return {"tiny-16k": 16_384, "huge-1m": 1_048_576}.get(model, 256_000) + + monkeypatch.setattr( + "hermes_cli.fallback_config.get_fallback_chain", + lambda cfg: chain, + ) + + with patch("agent.auxiliary_client._resolve_fallback_entry", + side_effect=fake_resolve), \ + patch("agent.auxiliary_client.get_model_context_length", + side_effect=fake_ctx), \ + patch("agent.auxiliary_client._is_provider_unhealthy", + return_value=False): + client, model, label = _try_main_fallback_chain( + task="compression", failed_provider="auto") + + assert client is large_client, ( + f"Expected large_client (1M), got {client}. " + "L3 bug: main chain returned the first reachable candidate " + "without screening by context window.") + assert model == "huge-1m" + + # ── L4: unknown context passthrough ──────────────────────────────── + + def test_configured_chain_passes_through_unknown_context(self, monkeypatch): + """When get_model_context_length returns None (cannot probe), + the candidate is NOT filtered — the existing behaviour of using + the default 256K fallback in the resolver chain is preserved.""" + from agent.auxiliary_client import _try_configured_fallback_chain + + unknown_client = MagicMock(name="unknown_client") + entries = [self._make_chain_entry("unknown-provider", "unprobed-model")] + + def fake_resolve(entry): + return unknown_client, "unprobed-model" + + def fake_ctx(model, base_url="", api_key="", **kwargs): + return None # cannot determine context length + + monkeypatch.setattr( + "agent.auxiliary_client._get_auxiliary_task_config", + lambda task: {"fallback_chain": entries} if task == "compression" else {}, + ) + + with patch("agent.auxiliary_client._resolve_fallback_entry", + side_effect=fake_resolve), \ + patch("agent.auxiliary_client.get_model_context_length", + side_effect=fake_ctx): + client, model, label = _try_configured_fallback_chain( + task="compression", failed_provider="auto") + + assert client is unknown_client, ( + "L4 bug: candidates with unknown context must be passed through, " + "not blocked. Being unsure is not the same as being too small.") + assert model == "unprobed-model" + + # ── L5: backward compat — non-compression tasks unchanged ────────── + + def test_non_compression_task_does_not_filter_by_context(self, monkeypatch): + """For tasks without a context floor (e.g. title_generation, vision), + the chain behaviour is unchanged: first reachable candidate wins.""" + from agent.auxiliary_client import _try_configured_fallback_chain + + small_client = MagicMock(name="small") + entries = [self._make_chain_entry("p", "tiny-4k")] + + def fake_resolve(entry): + return small_client, "tiny-4k" + + def fake_ctx(model, base_url="", api_key="", **kwargs): + return 4_096 # small — but title_generation has no floor + + monkeypatch.setattr( + "agent.auxiliary_client._get_auxiliary_task_config", + lambda task: {"fallback_chain": entries} if task == "title_generation" else {}, + ) + + with patch("agent.auxiliary_client._resolve_fallback_entry", + side_effect=fake_resolve), \ + patch("agent.auxiliary_client.get_model_context_length", + side_effect=fake_ctx): + client, model, label = _try_configured_fallback_chain( + task="title_generation", failed_provider="auto") + + assert client is small_client, ( + "L5 regression: non-compression tasks must not be filtered " + "by context window. The first reachable candidate should win.") + assert model == "tiny-4k" + + # ── End-to-end: configured chain skips too-small for vision too ── + # vision has its own implicit context requirements; test that the + # compression-specific filter does NOT affect vision chains. + + def test_compression_task_uses_minimum_context_constant(self): + """The task minimum for compression must equal MINIMUM_CONTEXT_LENGTH + so the runtime fallback stays consistent with the startup feasibility + check in agent/conversation_compression.py.""" + from agent.auxiliary_client import _task_minimum_context_length + from agent.model_metadata import MINIMUM_CONTEXT_LENGTH + + assert _task_minimum_context_length("compression") == MINIMUM_CONTEXT_LENGTH + # Non-compression tasks have no minimum (None) + assert _task_minimum_context_length("vision") is None + assert _task_minimum_context_length("title_generation") is None + assert _task_minimum_context_length("web_extract") is None + assert _task_minimum_context_length("skills_hub") is None + assert _task_minimum_context_length("mcp") is None + assert _task_minimum_context_length("session_search") is None + # Empty / unknown tasks have no minimum + assert _task_minimum_context_length("") is None + assert _task_minimum_context_length(None) is None -
tests/run_agent/test_compression_feasibility.py@@ -306,6 +306,39 @@ def test_warns_when_no_auxiliary_provider(mock_get_client): assert agent._compression_warning is not None +def test_no_unavailable_warning_when_configured_fallback_chain_resolves(): + """Primary compression provider can be down if configured fallback works.""" + agent = _make_agent(main_context=200_000, threshold_percent=0.50) + fallback_client = MagicMock() + fallback_client.base_url = "https://chatgpt.com/backend-api/codex" + fallback_client.api_key = "[redacted:secret-assignment]" + + messages = [] + agent._emit_status = lambda msg: messages.append(msg) + + with patch( + "agent.auxiliary_client._resolve_task_provider_model", + return_value=("ollama-cloud", "deepseek-v4-flash:cloud", None, None, None), + ), patch( + "agent.auxiliary_client.get_text_auxiliary_client", + return_value=(None, None), + ), patch( + "agent.auxiliary_client._try_configured_fallback_for_unavailable_client", + return_value=(fallback_client, "gpt-5.4-mini", "fallback_chain[0](openai-codex)"), + ) as mock_fallback, patch( + "agent.model_metadata.get_model_context_length", + return_value=200_000, + ) as mock_ctx_len: + agent._check_compression_model_feasibility() + + assert messages == [] + assert agent._compression_warning is None + mock_fallback.assert_called_once_with("compression", "ollama-cloud") + mock_ctx_len.assert_called_once() + assert mock_ctx_len.call_args.args == ("gpt-5.4-mini",) + assert mock_ctx_len.call_args.kwargs["provider"] == "openai-codex" + + def test_skips_check_when_compression_disabled(): """No check performed when compression is disabled.""" agent = _make_agent(compression_enabled=False)
-
-
#93455 fix(classifier): 429 quota walls route to billing across providers; reset signals stay retryable (consolidates #39441, #63021, #74785)
files:
agent/error_classifier.py,tests/agent/test_error_classifier.py
11 PRs closed without merging
From the close comments: superseded by another PR 5 · merged via another change 3 · done elsewhere 1 · duplicate 1 · other 1
-
#26002 fix: add server error (5xx) detection to auxiliary vision fallback · superseded by another PR →
#25843 -
#26809 fix(auxiliary): detect quota keywords in _is_payment_error and allow fallback for explicit providers · superseded by another PR →
#27625,#26811 -
#26811 fix(auxiliary): detect quota exhaustion as payment error; allow capacity-error fallback for explicit providers · merged via another change →
24c209f,#27625,#26998 -
#39441 fix(agent): classify quota-exhaustion 429s as billing · superseded by another PR →
0c3a507,#93455 -
#49589 fix(vision): fail-proof fallback chain — survive quota limits, VPN glitches, and provider outages · done elsewhere →
a574246,#51513
Show 6 more
-
#52230 fix: include rate-limit in auxiliary capacity-error fallback gate · merged via another change →
751adfa,#52661 -
#52233 fix(agent): fall back on explicit auxiliary rate limits · superseded by another PR →
#52230,#52228,#52251 -
#52251 fix(agent): include rate-limit errors in auxiliary capacity-error gate · duplicate →
#52230,#52233,#52228 -
#52456 fix(auxiliary): fall back on rate limits for explicit providers · other →
#52228,#52230,#52251 -
#65633 fix: classify 429 quota-exhaustion errors as non-retryable for fallback · superseded by another PR →
0c3a507,#93455,#39441 -
#97572 fix(auxiliary): skip same-provider retry on a vision full-budget timeout · merged via another change →
c4e394c,f50b5bb,#101570
Closed issues · 5
- Closed#26803Auxiliary call_llm fallback doesn't trigger on provider rate limits (429 daily quota)
- Closed#36276fix(agent): HTTP 429 quota-exhaustion errors retried 3 times instead of failing fast
- Closed#49586bug(vision): fallback_chain crash — 'await' on sync OpenAI client makes fallback path non-functional
- Closed#52228Auxiliary rate-limit fallback bypassed for explicit providers
- Closed#65563Ollama Cloud "session usage limit" 429 error not triggering fallback_providers chain
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.06: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab