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

3open issues
5open PRs
27items grouped
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.

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.

Open issues3

  • Issue#25822auxiliary vision: Gemini 503 (UNAVAILABLE) does not trigger provider fallback
  • Issue#50995Vision fallback silently ignored when configured in legacy singular form
  • Issue#51513fix(vision): fallback chain completely non-functional — 5 bugs, complete fix with verification

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

Merged to main. A stamp counts the issues a fix is linked to.

  • #27625 feat(auxiliary): layered fallback (chain → main agent) + capacity-error gate fix

    merged to main

    files: agent/auxiliary_client.py, scripts/release.py, tests/agent/test_auxiliary_client.py, website/docs/user-guide/features/fallback-providers.md

    Show 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)

    merged to main

    files: agent/auxiliary_client.py, agent/conversation_compression.py, scripts/release.py, tests/agent/test_auxiliary_client.py, tests/run_agent/test_compression_feasibility.py

    Show 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)

    merged to main

    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.00 means 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

At a glance

Subsystem
agent
Merged PRs
#27625, #52661, #93455
Closed issues
5
Attempts
11 PRs closed without merging
Fix link checked
24 Aug 2026

For your agent

The same facts, typed.