From f69e3aadf16f4c3ad172fac0c239072623749160 Mon Sep 17 00:00:00 2001 From: Fan Yang Date: Mon, 6 Jul 2026 13:26:32 -0700 Subject: [PATCH] fix(auxiliary): refresh auto-routed provider credentials on 401 Infer the concrete auxiliary auth provider from the selected client base URL so provider:auto routes can refresh Copilot/Codex/Anthropic/Nous credentials after auth errors, instead of skipping refresh because resolved_provider stayed 'auto'. Adds the copilot branch to _refresh_provider_credentials and evicts the stale auto-route cache before retrying. Fixes #20832. Salvaged from PR #20837, reapplied surgically onto current main (branch predated the _retry_same_provider_sync/async extraction). --- agent/auxiliary_client.py | 72 ++++++++++++++++++++++++---- tests/agent/test_auxiliary_client.py | 63 ++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 10 deletions(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 2a1b5b085c2..bc14c69f541 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -3388,6 +3388,21 @@ def _refresh_provider_credentials(provider: str) -> bool: """Refresh short-lived credentials for OAuth-backed auxiliary providers.""" normalized = _normalize_aux_provider(provider) try: + if normalized == "copilot": + from hermes_cli.copilot_auth import ( + _jwt_cache, + _token_fingerprint, + exchange_copilot_token, + resolve_copilot_token, + ) + + raw_token, _source = resolve_copilot_token() + if not str(raw_token or "").strip(): + return False + _jwt_cache.pop(_token_fingerprint(raw_token), None) + exchange_copilot_token(raw_token) + _evict_cached_clients(normalized) + return True if normalized == "openai-codex": from hermes_cli.auth import resolve_codex_runtime_credentials @@ -3442,6 +3457,31 @@ def _refresh_provider_credentials(provider: str) -> bool: return False +def _auth_refresh_provider_for_route( + resolved_provider: Optional[str], + client_base_url: str, +) -> str: + """Return the provider whose short-lived credentials should be refreshed. + + Auto-routed auxiliary calls keep ``resolved_provider == "auto"`` even + after _get_cached_client() selects a concrete backend. Infer the backend + from the selected client's base URL so auth refresh works for auto → + Copilot/Codex/Anthropic/Nous routes too. (#20832) + """ + normalized = _normalize_aux_provider(resolved_provider) + if normalized and normalized != "auto": + return normalized + if base_url_host_matches(client_base_url, "api.githubcopilot.com"): + return "copilot" + if base_url_host_matches(client_base_url, "chatgpt.com"): + return "openai-codex" + if base_url_host_matches(client_base_url, "api.anthropic.com"): + return "anthropic" + if base_url_host_matches(client_base_url, "inference-api.nousresearch.com"): + return "nous" + return normalized + + def _try_payment_fallback( failed_provider: str, task: str = None, @@ -6464,18 +6504,24 @@ def call_llm( refreshed_client.chat.completions.create(**kwargs), task) # ── Auth refresh retry ─────────────────────────────────────── + auth_refresh_provider = _auth_refresh_provider_for_route( + resolved_provider, _base_info) if (_is_auth_error(first_err) - and resolved_provider not in {"auto", "", None} + and auth_refresh_provider not in {"auto", "", None} and not client_is_nous): - if _refresh_provider_credentials(resolved_provider): + if _refresh_provider_credentials(auth_refresh_provider): + if auth_refresh_provider != _normalize_aux_provider(resolved_provider): + # The stale client is cached under the route label + # (e.g. "auto"), not the concrete backend we refreshed. + _evict_cached_clients(resolved_provider) logger.info( "Auxiliary %s: refreshed %s credentials after auth error, retrying", - task or "call", resolved_provider, + task or "call", auth_refresh_provider, ) return _retry_same_provider_sync( task=task, - resolved_provider=resolved_provider, - resolved_model=resolved_model, + resolved_provider=auth_refresh_provider, + resolved_model=resolved_model or final_model, resolved_base_url=resolved_base_url, resolved_api_key=resolved_api_key, resolved_api_mode=resolved_api_mode, @@ -6992,18 +7038,24 @@ async def async_call_llm( await refreshed_client.chat.completions.create(**kwargs), task) # ── Auth refresh retry (mirrors sync call_llm) ─────────────── + auth_refresh_provider = _auth_refresh_provider_for_route( + resolved_provider, _client_base) if (_is_auth_error(first_err) - and resolved_provider not in {"auto", "", None} + and auth_refresh_provider not in {"auto", "", None} and not client_is_nous): - if _refresh_provider_credentials(resolved_provider): + if _refresh_provider_credentials(auth_refresh_provider): + if auth_refresh_provider != _normalize_aux_provider(resolved_provider): + # The stale client is cached under the route label + # (e.g. "auto"), not the concrete backend we refreshed. + _evict_cached_clients(resolved_provider) logger.info( "Auxiliary %s (async): refreshed %s credentials after auth error, retrying", - task or "call", resolved_provider, + task or "call", auth_refresh_provider, ) return await _retry_same_provider_async( task=task, - resolved_provider=resolved_provider, - resolved_model=resolved_model, + resolved_provider=auth_refresh_provider, + resolved_model=resolved_model or final_model, resolved_base_url=resolved_base_url, resolved_api_key=resolved_api_key, resolved_api_mode=resolved_api_mode, diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index 49c75add205..59ad0286e23 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -3376,6 +3376,69 @@ class TestAuxiliaryAuthRefreshRetry: assert stale_client.chat.completions.create.call_count == 1 assert fresh_client.chat.completions.create.call_count == 1 + def test_call_llm_refreshes_copilot_when_auto_routes_to_copilot_on_401(self): + stale_client = MagicMock() + stale_client.base_url = "https://api.githubcopilot.com" + stale_client.chat.completions.create.side_effect = _AuxAuth401( + "IDE token expired: unauthorized: token expired" + ) + + fresh_client = MagicMock() + fresh_client.base_url = "https://api.githubcopilot.com" + fresh_client.chat.completions.create.return_value = _DummyResponse("fresh-auto-copilot") + + with ( + patch("agent.auxiliary_client._resolve_task_provider_model", return_value=("auto", None, None, None, None)), + patch("agent.auxiliary_client._get_cached_client", side_effect=[(stale_client, "gpt-5.5"), (fresh_client, "gpt-5.5")]) as mock_get_client, + patch("agent.auxiliary_client._refresh_provider_credentials", return_value=True) as mock_refresh, + patch("agent.auxiliary_client._evict_cached_clients") as mock_evict, + ): + resp = call_llm( + task="title_generation", + messages=[{"role": "user", "content": "hi"}], + main_runtime={"provider": "copilot", "model": "gpt-5.5"}, + ) + + assert resp.choices[0].message.content == "fresh-auto-copilot" + mock_refresh.assert_called_once_with("copilot") + mock_evict.assert_called_once_with("auto") + assert mock_get_client.call_args_list[0].args[0] == "auto" + assert mock_get_client.call_args_list[1].args[0] == "copilot" + assert mock_get_client.call_args_list[1].args[1] == "gpt-5.5" + assert stale_client.chat.completions.create.call_count == 1 + assert fresh_client.chat.completions.create.call_count == 1 + + def test_call_llm_refreshes_codex_when_auto_routes_to_codex_on_401(self): + # Preflight compression's exact failure (#23670): provider auto → + # Codex OAuth backend 401s → before the fix, no refresh was attempted + # because resolved_provider stayed "auto". + stale_client = MagicMock() + stale_client.base_url = "https://chatgpt.com/backend-api/codex" + stale_client.chat.completions.create.side_effect = _AuxAuth401("User not found.") + + fresh_client = MagicMock() + fresh_client.base_url = "https://chatgpt.com/backend-api/codex" + fresh_client.chat.completions.create.return_value = _DummyResponse("fresh-auto-codex") + + with ( + patch("agent.auxiliary_client._resolve_task_provider_model", return_value=("auto", None, None, None, None)), + patch("agent.auxiliary_client._get_cached_client", side_effect=[(stale_client, "gpt-5.5"), (fresh_client, "gpt-5.5")]) as mock_get_client, + patch("agent.auxiliary_client._refresh_provider_credentials", return_value=True) as mock_refresh, + patch("agent.auxiliary_client._evict_cached_clients") as mock_evict, + ): + resp = call_llm( + task="compression", + messages=[{"role": "user", "content": "summarize"}], + main_runtime={"provider": "openai-codex", "model": "gpt-5.5"}, + ) + + assert resp.choices[0].message.content == "fresh-auto-codex" + mock_refresh.assert_called_once_with("openai-codex") + mock_evict.assert_called_once_with("auto") + assert mock_get_client.call_args_list[1].args[0] == "openai-codex" + assert stale_client.chat.completions.create.call_count == 1 + assert fresh_client.chat.completions.create.call_count == 1 + def test_call_llm_refreshes_anthropic_on_401_for_non_vision(self): stale_client = MagicMock() stale_client.base_url = "https://api.anthropic.com"