{"repo":"NousResearch/hermes-agent","public_id":"20832","display_label":"Auxiliary client Copilot auth refresh on 401","summary":"These issues and pull requests address a bug where auto-routed Copilot credentials are not refreshed in the auxiliary client when tokens expire or 401 errors occur. Multiple fix attempts cover credential refresh, fallback handling, and related auth propagation for auxiliary routes.","subsystem":"agent","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":59837,"merged_at":"2026-07-07T01:12:25+00:00"},"resolution":{"state":"resolved","by":"pr-59837"},"freshness":{"state":"verified","last_verified_at":"2026-07-07T01:12:25+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":20832,"pr_number":59837,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-07T01:12:25+00:00"}],"members":[{"kind":"issue","number":20832,"title":"Auxiliary Copilot token refresh is skipped for provider:auto after IDE token expires","url":null,"state":"closed"},{"kind":"pr","number":20837,"title":"fix(auxiliary): refresh auto-routed Copilot credentials on 401","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#59837","number":59837},{"kind":"pr","value":"#20832","number":20832}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":20978,"title":"fix(auxiliary): refresh auto-routed Copilot credentials","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#59837","number":59837},{"kind":"pr","value":"#20837","number":20837}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":59837,"title":"fix(auxiliary): auth recovery for auto routes and stale fallback candidates","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/auxiliary_client.py","scripts/release.py","tests/agent/test_auxiliary_client.py"],"fix_hunks":[{"file":"agent/auxiliary_client.py","hunks":["@@ -3388,6 +3388,21 @@ def _refresh_provider_credentials(provider: str) -> bool:\n     \"\"\"Refresh short-lived credentials for OAuth-backed auxiliary providers.\"\"\"\n     normalized = _normalize_aux_provider(provider)\n     try:\n+        if normalized == \"copilot\":\n+            from hermes_cli.copilot_auth import (\n+                _jwt_cache,\n+                _token_fingerprint,\n+                exchange_copilot_token,\n+                resolve_copilot_token,\n+            )\n+\n+            raw_token, _source = resolve_copilot_token()\n+            if not str(raw_token or \"\").strip():\n+                return False\n+            _jwt_cache.pop(_token_fingerprint(raw_token), None)\n+            exchange_copilot_token(raw_token)\n+            _evict_cached_clients(normalized)\n+            return True\n         if normalized == \"openai-codex\":\n             from hermes_cli.auth import resolve_codex_runtime_credentials\n ","@@ -3442,6 +3457,152 @@ def _refresh_provider_credentials(provider: str) -> bool:\n     return False\n \n \n+def _auth_refresh_provider_for_route(\n+    resolved_provider: Optional[str],\n+    client_base_url: str,\n+) -> str:\n+    \"\"\"Return the provider whose short-lived credentials should be refreshed.\n+\n+    Auto-routed auxiliary calls keep ``resolved_provider == \"auto\"`` even\n+    after _get_cached_client() selects a concrete backend. Infer the backend\n+    from the selected client's base URL so auth refresh works for auto →\n+    Copilot/Codex/Anthropic/Nous routes too. (#20832)\n+    \"\"\"\n+    normalized = _normalize_aux_provider(resolved_provider)\n+    if normalized and normalized != \"auto\":\n+        return normalized\n+    if base_url_host_matches(client_base_url, \"api.githubcopilot.com\"):\n+        return \"copilot\"\n+    if base_url_host_matches(client_base_url, \"chatgpt.com\"):\n+        return \"openai-codex\"\n+    if base_url_host_matches(client_base_url, \"api.anthropic.com\"):\n+        return \"anthropic\"\n+    if base_url_host_matches(client_base_url, \"inference-api.nousresearch.com\"):\n+        return \"nous\"\n+    return normalized\n+\n+\n+def _call_fallback_candidate_sync(\n+    fb_client: Any,\n+    fb_model: Optional[str],\n+    fb_label: str,\n+    *,\n+    task: Optional[str],\n+    messages: list,\n+    temperature: Optional[float],\n+    max_tokens: [redacted:secret-assignment]\n+    tools: Optional[list],\n+    effective_timeout: float,\n+    effective_extra_body: dict,\n+) -> Optional[Any]:\n+    \"\"\"Call one fallback candidate with stale-credential recovery.\n+\n+    A fallback candidate can itself carry a stale credential (e.g. an expired\n+    ``ANTHROPIC_TOKEN`` picked up by ``_try_anthropic``). Before this helper,\n+    such a 401 propagated out of the fallback site and aborted the auxiliary\n+    task (for compression: a 60s cooldown + context marker) even though other\n+    healthy candidates remained. Live case: a Codex-timeout → Anthropic\n+    fallback 401-looped five times in one session (mattalachia debug dump,\n+    Jul 2026).\n+\n+    On an auth error: refresh the candidate's provider credentials and retry\n+    once with a rebuilt client; if the retry also auth-fails (non-refreshable\n+    expired token), mark the provider unhealthy and return ``None`` so the\n+    caller can continue to the next fallback layer. Non-auth errors raise.\n+    \"\"\"\n+    fb_base = str(getattr(fb_client, \"base_url\", \"\") or \"\")\n+    fb_kwargs = _build_call_kwargs(\n+        fb_label, fb_model, messages,\n+        temperature=temperature, max_tokens=[redacted:secret-assignment]\n+        tools=tools, timeout=effective_timeout,\n+        extra_body=effective_extra_body, base_url=fb_base)\n+    try:\n+        return _validate_llm_response(\n+            fb_client.chat.completions.create(**fb_kwargs), task)\n+    except Exception as fb_err:\n+        if not _is_auth_error(fb_err):\n+            raise\n+        fb_provider = _auth_refresh_provider_for_route(fb_label, fb_base)\n+        if fb_provider not in {\"auto\", \"\", None} and _refresh_provider_credentials(fb_provider):\n+            retry_client, retry_model = _get_cached_client(fb_provider, fb_model)\n+            if retry_client is not None:\n+                retry_kwargs = _build_call_kwargs(\n+                    fb_provider, retry_model or fb_model, messages,\n+                    temperature=temperature, max_tokens=[redacted:secret-assignment]\n+                    tools=tools, timeout=effective_timeout,\n+                    extra_body=effective_extra_body,\n+                    base_url=str(getattr(retry_client, \"base_url\", \"\") or fb_base))\n+                try:\n+                    return _validate_llm_response(\n+                        retry_client.chat.completions.create(**retry_kwargs), task)\n+                except Exception as retry_err:\n+                    if not _is_auth_error(retry_err):\n+                        raise\n+        # Refresh unavailable or the refreshed credential still 401s —\n+        # the token is dead (expired setup token with no refresh token).\n+        # Quarantine the candidate so subsequent chain walks skip it, and\n+        # let the caller move on instead of aborting the whole task.\n+        _mark_provider_unhealthy(fb_provider or fb_label)\n+        logger.warning(\n+            \"Auxiliary %s: fallback candidate %s has a stale/unrefreshable \"\n+            \"credential (%s) — skipping to next fallback\",\n+            task or \"call\", fb_label, fb_err,\n+        )\n+        return None\n+\n+\n+async def _call_fallback_candidate_async(\n+    fb_client: Any,\n+    fb_model: Optional[str],\n+    fb_label: str,\n+    *,\n+    task: Optional[str],\n+    messages: list,\n+    temperature: Optional[float],\n+    max_tokens: [redacted:secret-assignment]\n+    tools: Optional[list],\n+    effective_timeout: float,\n+    effective_extra_body: dict,\n+) -> Optional[Any]:\n+    \"\"\"Async mirror of :func:`_call_fallback_candidate_sync`.\"\"\"\n+    fb_base = str(getattr(fb_client, \"base_url\", \"\") or \"\")\n+    fb_kwargs = _build_call_kwargs(\n+        fb_label, fb_model, messages,\n+        temperature=temperature, max_tokens=[redacted:secret-assignment]\n+        tools=tools, timeout=effective_timeout,\n+        extra_body=effective_extra_body, base_url=fb_base)\n+    try:\n+        return _validate_llm_response(\n+            await fb_client.chat.completions.create(**fb_kwargs), task)\n+    except Exception as fb_err:\n+        if not _is_auth_error(fb_err):\n+            raise\n+        fb_provider = _auth_refresh_provider_for_route(fb_label, fb_base)\n+        if fb_provider not in {\"auto\", \"\", None} and _refresh_provider_credentials(fb_provider):\n+            retry_client, retry_model = _get_cached_client(\n+                fb_provider, fb_model, async_mode=True)\n+            if retry_client is not None:\n+                retry_kwargs = _build_call_kwargs(\n+                    fb_provider, retry_model or fb_model, messages,\n+                    temperature=temperature, max_tokens=[redacted:secret-assignment]\n+                    tools=tools, timeout=effective_timeout,\n+                    extra_body=effective_extra_body,\n+                    base_url=str(getattr(retry_client, \"base_url\", \"\") or fb_base))\n+                try:\n+                    return _validate_llm_response(\n+                        await retry_client.chat.completions.create(**retry_kwargs), task)\n+                except Exception as retry_err:\n+                    if not _is_auth_error(retry_err):\n+                        raise\n+        _mark_provider_unhealthy(fb_provider or fb_label)\n+        logger.warning(\n+            \"Auxiliary %s (async): fallback candidate %s has a stale/unrefreshable \"\n+            \"credential (%s) — skipping to next fallback\",\n+            task or \"call\", fb_label, fb_err,\n+        )\n+        return None\n+\n+\n def _try_payment_fallback(\n     failed_provider: str,\n     task: str = None,","@@ -6464,18 +6625,24 @@ def call_llm(\n                     refreshed_client.chat.completions.create(**kwargs), task)\n \n         # ── Auth refresh retry ───────────────────────────────────────\n+        auth_refresh_provider = _auth_refresh_provider_for_route(\n+            resolved_provider, _base_info)\n         if (_is_auth_error(first_err)\n-                and resolved_provider not in {\"auto\", \"\", None}\n+                and auth_refresh_provider not in {\"auto\", \"\", None}\n                 and not client_is_nous):\n-            if _refresh_provider_credentials(resolved_provider):\n+            if _refresh_provider_credentials(auth_refresh_provider):\n+                if auth_refresh_provider != _normalize_aux_provider(resolved_provider):\n+                    # The stale client is cached under the route label\n+                    # (e.g. \"auto\"), not the concrete backend we refreshed.\n+                    _evict_cached_clients(resolved_provider)\n                 logger.info(\n                     \"Auxiliary %s: refreshed %s credentials after auth error, retrying\",\n-                    task or \"call\", resolved_provider,\n+                    task or \"call\", auth_refresh_provider,\n                 )\n                 return _retry_same_provider_sync(\n                     task=task,\n-                    resolved_provider=resolved_provider,\n-                    resolved_model=resolved_model,\n+                    resolved_provider=auth_refresh_provider,\n+                    resolved_model=resolved_model or final_model,\n                     resolved_base_url=resolved_base_url,\n                     resolved_api_key=[redacted:secret-assignment]\n                     resolved_api_mode=resolved_api_mode,","@@ -6645,14 +6812,28 @@ def call_llm(\n                         resolved_provider, task, reason=reason)\n \n             if fb_client is not None:\n-                fb_kwargs = _build_call_kwargs(\n-                    fb_label, fb_model, messages,\n+                fb_resp = _call_fallback_candidate_sync(\n+                    fb_client, fb_model, fb_label,\n+                    task=task, messages=messages,\n                     temperature=temperature, max_tokens=[redacted:secret-assignment]\n-                    tools=tools, timeout=effective_timeout,\n-                    extra_body=effective_extra_body,\n-                    base_url=str(getattr(fb_client, \"base_url\", \"\") or \"\"))\n-                return _validate_llm_response(\n-                    fb_client.chat.completions.create(**fb_kwargs), task)\n+                    tools=tools, effective_timeout=effective_timeout,\n+                    effective_extra_body=effective_extra_body)\n+                if fb_resp is not None:\n+                    return fb_resp\n+                # The candidate had a stale/unrefreshable credential and was\n+                # quarantined — walk the discovery chain once more; unhealthy\n+                # entries are skipped so the next viable candidate serves.\n+                fb_client, fb_model, fb_label = _try_payment_fallback(\n+                    resolved_provider, task, reason=\"stale fallback credential\")\n+                if fb_client is not None:\n+                    fb_resp = _call_fallback_candidate_sync(\n+                        fb_client, fb_model, fb_label,\n+                        task=task, messages=messages,\n+                        temperature=temperature, max_tokens=[redacted:secret-assignment]\n+                        tools=tools, effective_timeout=effective_timeout,\n+                        effective_extra_body=effective_extra_body)\n+                    if fb_resp is not None:\n+                        return fb_resp\n             # All fallback layers exhausted — emit a single user-visible\n             # warning so the operator knows aux task is about to fail.\n             # (#26882) The error itself is re-raised below.","@@ -6992,18 +7173,24 @@ async def async_call_llm(\n                     await refreshed_client.chat.completions.create(**kwargs), task)\n \n         # ── Auth refresh retry (mirrors sync call_llm) ───────────────\n+        auth_refresh_provider = _auth_refresh_provider_for_route(\n+            resolved_provider, _client_base)\n         if (_is_auth_error(first_err)\n-                and resolved_provider not in {\"auto\", \"\", None}\n+                and auth_refresh_provider not in {\"auto\", \"\", None}\n                 and not client_is_nous):\n-            if _refresh_provider_credentials(resolved_provider):\n+            if _refresh_provider_credentials(auth_refresh_provider):\n+                if auth_refresh_provider != _normalize_aux_provider(resolved_provider):\n+                    # The stale client is cached under the route label\n+                    # (e.g. \"auto\"), not the concrete backend we refreshed.\n+                    _evict_cached_clients(resolved_provider)\n                 logger.info(\n                     \"Auxiliary %s (async): refreshed %s credentials after auth error, retrying\",\n-                    task or \"call\", resolved_provider,\n+                    task or \"call\", auth_refresh_provider,\n                 )\n                 return await _retry_same_provider_async(\n                     task=task,\n-                    resolved_provider=resolved_provider,\n-                    resolved_model=resolved_model,\n+                    resolved_provider=auth_refresh_provider,\n+                    resolved_model=resolved_model or final_model,\n                     resolved_base_url=resolved_base_url,\n                     resolved_api_key=[redacted:secret-assignment]\n                     resolved_api_mode=resolved_api_mode,","@@ -7130,20 +7317,34 @@ async def async_call_llm(\n                         resolved_provider, task, reason=reason)\n \n             if fb_client is not None:\n-                fb_kwargs = _build_call_kwargs(\n-                    fb_label, fb_model, messages,\n-                    temperature=temperature, max_tokens=[redacted:secret-assignment]\n-                    tools=tools, timeout=effective_timeout,\n-                    extra_body=effective_extra_body,\n-                    base_url=str(getattr(fb_client, \"base_url\", \"\") or \"\"))\n                 # Convert sync fallback client to async\n                 async_fb, async_fb_model = _to_async_client(\n                     fb_client, fb_model or \"\", is_vision=(task == \"vision\")\n                 )\n-                if async_fb_model and async_fb_model != fb_kwargs.get(\"model\"):\n-                    fb_kwargs[\"model\"] = async_fb_model\n-                return _validate_llm_response(\n-                    await async_fb.chat.completions.create(**fb_kwargs), task)\n+                fb_resp = await _call_fallback_candidate_async(\n+                    async_fb, async_fb_model or fb_model, fb_label,\n+                    task=task, messages=messages,\n+                    temperature=temperature, max_tokens=[redacted:secret-assignment]\n+                    tools=tools, effective_timeout=effective_timeout,\n+                    effective_extra_body=effective_extra_body)\n+                if fb_resp is not None:\n+                    return fb_resp\n+                # Stale/unrefreshable candidate credential — quarantined; walk\n+                # the discovery chain once more (unhealthy entries skipped).\n+                fb_client, fb_model, fb_label = _try_payment_fallback(\n+                    resolved_provider, task, reason=\"stale fallback credential\")\n+                if fb_client is not None:\n+                    async_fb, async_fb_model = _to_async_client(\n+                        fb_client, fb_model or \"\", is_vision=(task == \"vision\")\n+                    )\n+                    fb_resp = await _call_fallback_candidate_async(\n+                        async_fb, async_fb_model or fb_model, fb_label,\n+                        task=task, messages=messages,\n+                        temperature=temperature, max_tokens=[redacted:secret-assignment]\n+                        tools=tools, effective_timeout=effective_timeout,\n+                        effective_extra_body=effective_extra_body)\n+                    if fb_resp is not None:\n+                        return fb_resp\n             # All fallback layers exhausted — warn before re-raising. (#26882)\n             logger.warning(\n                 \"Auxiliary %s (async): %s on %s and all fallbacks exhausted \""]},{"file":"scripts/release.py","hunks":["@@ -284,6 +284,7 @@\n     \"290859878+synapsesx@users.noreply.github.com\": \"synapsesx\",\n     \"157689911+itsflownium@users.noreply.github.com\": \"itsflownium\",\n     \"dirtyren@users.noreply.github.com\": \"dirtyren\",\n+    \"[redacted:email]\": \"fanyangCS\",\n     \"[redacted:email]\": \"bigstar0920\",\n     \"[redacted:email]\": \"tanmayxchoudhary\",\n     \"285906080+AIalliAI@users.noreply.github.com\": \"AIalliAI\","]},{"file":"tests/agent/test_auxiliary_client.py","hunks":["@@ -2159,6 +2159,136 @@ def test_401_auth_error_no_fallback_with_explicit_provider(self, monkeypatch):\n         mock_fb.assert_not_called()\n \n \n+class TestStaleFallbackCandidateSkip:\n+    \"\"\"A fallback candidate with a stale credential must not abort the task.\n+\n+    Live case (mattalachia debug dump, Jul 2026): Codex compression timed out,\n+    the aux chain fell back to Anthropic using an expired ANTHROPIC_TOKEN, and\n+    the resulting 401 aborted compression with a 60s cooldown — five times in\n+    one session — even though refreshing or skipping the candidate would have\n+    let compression proceed.\n+    \"\"\"\n+\n+    def _timeout_err(self):\n+        # Class name carries \"Timeout\" — matches _is_connection_error's\n+        # type-name detection, like the real Codex stream-deadline error.\n+        class _AuxStreamTimeoutError(Exception):\n+            pass\n+        return _AuxStreamTimeoutError(\n+            \"Codex auxiliary Responses stream exceeded 120.0s total timeout\")\n+\n+    def test_stale_anthropic_fallback_refreshes_and_retries(self, monkeypatch):\n+        \"\"\"401 from the fallback candidate → refresh its creds → retry succeeds.\"\"\"\n+        primary_client = MagicMock()\n+        primary_client.base_url = \"https://chatgpt.com/backend-api/codex\"\n+        primary_client.chat.completions.create.side_effect = self._timeout_err()\n+\n+        stale_fb = MagicMock()\n+        stale_fb.base_url = \"https://api.anthropic.com\"\n+        stale_fb.chat.completions.create.side_effect = _AuxAuth401(\"Invalid bearer token\")\n+\n+        fresh_fb = MagicMock()\n+        fresh_fb.base_url = \"https://api.anthropic.com\"\n+        fresh_fb.chat.completions.create.return_value = _DummyResponse(\"fresh-fallback\")\n+\n+        def _cached_client(provider, model=None, **kw):\n+            if provider == \"anthropic\":\n+                return (fresh_fb, \"claude-haiku-4-5-20251001\")\n+            return (primary_client, \"gpt-5.5\")\n+\n+        with patch(\"agent.auxiliary_client._resolve_task_provider_model\",\n+                   return_value=(\"auto\", None, None, None, None)), \\\n+             patch(\"agent.auxiliary_client._get_cached_client\", side_effect=_cached_client), \\\n+             patch(\"agent.auxiliary_client._try_configured_fallback_chain\",\n+                   return_value=(None, None, \"\")), \\\n+             patch(\"agent.auxiliary_client._try_main_fallback_chain\",\n+                   return_value=(None, None, \"\")), \\\n+             patch(\"agent.auxiliary_client._try_payment_fallback\",\n+                   return_value=(stale_fb, \"claude-haiku-4-5-20251001\", \"anthropic\")), \\\n+             patch(\"agent.auxiliary_client._refresh_provider_credentials\",\n+                   return_value=True) as mock_refresh:\n+            result = call_llm(\n+                task=\"compression\",\n+                messages=[{\"role\": \"user\", \"content\": \"summarize\"}],\n+            )\n+\n+        assert result.choices[0].message.content == \"fresh-fallback\"\n+        mock_refresh.assert_called_once_with(\"anthropic\")\n+        assert stale_fb.chat.completions.create.call_count == 1\n+        assert fresh_fb.chat.completions.create.call_count == 1\n+\n+    def test_unrefreshable_stale_candidate_is_skipped_to_next(self, monkeypatch):\n+        \"\"\"Refresh fails (expired setup token) → candidate quarantined, chain\n+        walked again, next candidate serves the request.\"\"\"\n+        primary_client = MagicMock()\n+        primary_client.base_url = \"https://chatgpt.com/backend-api/codex\"\n+        primary_client.chat.completions.create.side_effect = self._timeout_err()\n+\n+        stale_fb = MagicMock()\n+        stale_fb.base_url = \"https://api.anthropic.com\"\n+        stale_fb.chat.completions.create.side_effect = _AuxAuth401(\"Invalid bearer token\")\n+\n+        healthy_fb = MagicMock()\n+        healthy_fb.base_url = \"https://openrouter.ai/api/v1\"\n+        healthy_fb.chat.completions.create.return_value = _DummyResponse(\"openrouter-serves\")\n+\n+        fb_walks = [\n+            (stale_fb, \"claude-haiku-4-5-20251001\", \"anthropic\"),\n+            (healthy_fb, \"fallback-model\", \"openrouter\"),\n+        ]\n+\n+        with patch(\"agent.auxiliary_client._resolve_task_provider_model\",\n+                   return_value=(\"auto\", None, None, None, None)), \\\n+             patch(\"agent.auxiliary_client._get_cached_client\",\n+                   return_value=(primary_client, \"gpt-5.5\")), \\\n+             patch(\"agent.auxiliary_client._try_configured_fallback_chain\",\n+                   return_value=(None, None, \"\")), \\\n+             patch(\"agent.auxiliary_client._try_main_fallback_chain\",\n+                   return_value=(None, None, \"\")), \\\n+             patch(\"agent.auxiliary_client._try_payment_fallback\",\n+                   side_effect=fb_walks) as mock_fb, \\\n+             patch(\"agent.auxiliary_client._refresh_provider_credentials\",\n+                   return_value=False), \\\n+             patch(\"agent.auxiliary_client._mark_provider_unhealthy\") as mock_mark:\n+            result = call_llm(\n+                task=\"compression\",\n+                messages=[{\"role\": \"user\", \"content\": \"summarize\"}],\n+            )\n+\n+        assert result.choices[0].message.content == \"openrouter-serves\"\n+        assert mock_fb.call_count == 2\n+        assert mock_fb.call_args_list[1].kwargs.get(\"reason\") == \"stale fallback credential\"\n+        mock_mark.assert_called_once_with(\"anthropic\")\n+        assert stale_fb.chat.completions.create.call_count == 1\n+        assert healthy_fb.chat.completions.create.call_count == 1\n+\n+    def test_non_auth_fallback_error_still_raises(self, monkeypatch):\n+        \"\"\"A non-auth error from the fallback candidate propagates unchanged.\"\"\"\n+        primary_client = MagicMock()\n+        primary_client.base_url = \"https://chatgpt.com/backend-api/codex\"\n+        primary_client.chat.completions.create.side_effect = self._timeout_err()\n+\n+        broken_fb = MagicMock()\n+        broken_fb.base_url = \"https://api.anthropic.com\"\n+        broken_fb.chat.completions.create.side_effect = ValueError(\"malformed response\")\n+\n+        with patch(\"agent.auxiliary_client._resolve_task_provider_model\",\n+                   return_value=(\"auto\", None, None, None, None)), \\\n+             patch(\"agent.auxiliary_client._get_cached_client\",\n+                   return_value=(primary_client, \"gpt-5.5\")), \\\n+             patch(\"agent.auxiliary_client._try_configured_fallback_chain\",\n+                   return_value=(None, None, \"\")), \\\n+             patch(\"agent.auxiliary_client._try_main_fallback_chain\",\n+                   return_value=(None, None, \"\")), \\\n+             patch(\"agent.auxiliary_client._try_payment_fallback\",\n+                   return_value=(broken_fb, \"claude-haiku-4-5-20251001\", \"anthropic\")):\n+            with pytest.raises(ValueError, match=\"malformed response\"):\n+                call_llm(\n+                    task=\"compression\",\n+                    messages=[{\"role\": \"user\", \"content\": \"summarize\"}],\n+                )\n+\n+\n class TestAuxiliaryFallbackLayering:\n     \"\"\"Explicit-provider users get layered fallback: configured_chain → main agent → warn.\"\"\"\n ","@@ -3376,6 +3506,69 @@ def test_call_llm_refreshes_codex_on_401_for_non_vision(self):\n         assert stale_client.chat.completions.create.call_count == 1\n         assert fresh_client.chat.completions.create.call_count == 1\n \n+    def test_call_llm_refreshes_copilot_when_auto_routes_to_copilot_on_401(self):\n+        stale_client = MagicMock()\n+        stale_client.base_url = \"https://api.githubcopilot.com\"\n+        stale_client.chat.completions.create.side_effect = _AuxAuth401(\n+            \"IDE token expired: unauthorized: token expired\"\n+        )\n+\n+        fresh_client = MagicMock()\n+        fresh_client.base_url = \"https://api.githubcopilot.com\"\n+        fresh_client.chat.completions.create.return_value = _DummyResponse(\"fresh-auto-copilot\")\n+\n+        with (\n+            patch(\"agent.auxiliary_client._resolve_task_provider_model\", return_value=(\"auto\", None, None, None, None)),\n+            patch(\"agent.auxiliary_client._get_cached_client\", side_effect=[(stale_client, \"gpt-5.5\"), (fresh_client, \"gpt-5.5\")]) as mock_get_client,\n+            patch(\"agent.auxiliary_client._refresh_provider_credentials\", return_value=True) as mock_refresh,\n+            patch(\"agent.auxiliary_client._evict_cached_clients\") as mock_evict,\n+        ):\n+            resp = call_llm(\n+                task=\"title_generation\",\n+                messages=[{\"role\": \"user\", \"content\": \"hi\"}],\n+                main_runtime={\"provider\": \"copilot\", \"model\": \"gpt-5.5\"},\n+            )\n+\n+        assert resp.choices[0].message.content == \"fresh-auto-copilot\"\n+        mock_refresh.assert_called_once_with(\"copilot\")\n+        mock_evict.assert_called_once_with(\"auto\")\n+        assert mock_get_client.call_args_list[0].args[0] == \"auto\"\n+        assert mock_get_client.call_args_list[1].args[0] == \"copilot\"\n+        assert mock_get_client.call_args_list[1].args[1] == \"gpt-5.5\"\n+        assert stale_client.chat.completions.create.call_count == 1\n+        assert fresh_client.chat.completions.create.call_count == 1\n+\n+    def test_call_llm_refreshes_codex_when_auto_routes_to_codex_on_401(self):\n+        # Preflight compression's exact failure (#23670): provider auto →\n+        # Codex OAuth backend 401s → before the fix, no refresh was attempted\n+        # because resolved_provider stayed \"auto\".\n+        stale_client = MagicMock()\n+        stale_client.base_url = \"https://chatgpt.com/backend-api/codex\"\n+        stale_client.chat.completions.create.side_effect = _AuxAuth401(\"User not found.\")\n+\n+        fresh_client = MagicMock()\n+        fresh_client.base_url = \"https://chatgpt.com/backend-api/codex\"\n+        fresh_client.chat.completions.create.return_value = _DummyResponse(\"fresh-auto-codex\")\n+\n+        with (\n+            patch(\"agent.auxiliary_client._resolve_task_provider_model\", return_value=(\"auto\", None, None, None, None)),\n+            patch(\"agent.auxiliary_client._get_cached_client\", side_effect=[(stale_client, \"gpt-5.5\"), (fresh_client, \"gpt-5.5\")]) as mock_get_client,\n+            patch(\"agent.auxiliary_client._refresh_provider_credentials\", return_value=True) as mock_refresh,\n+            patch(\"agent.auxiliary_client._evict_cached_clients\") as mock_evict,\n+        ):\n+            resp = call_llm(\n+                task=\"compression\",\n+                messages=[{\"role\": \"user\", \"content\": \"summarize\"}],\n+                main_runtime={\"provider\": \"openai-codex\", \"model\": \"gpt-5.5\"},\n+            )\n+\n+        assert resp.choices[0].message.content == \"fresh-auto-codex\"\n+        mock_refresh.assert_called_once_with(\"openai-codex\")\n+        mock_evict.assert_called_once_with(\"auto\")\n+        assert mock_get_client.call_args_list[1].args[0] == \"openai-codex\"\n+        assert stale_client.chat.completions.create.call_count == 1\n+        assert fresh_client.chat.completions.create.call_count == 1\n+\n     def test_call_llm_refreshes_anthropic_on_401_for_non_vision(self):\n         stale_client = MagicMock()\n         stale_client.base_url = \"https://api.anthropic.com\"\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}