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