From 4914331aaffad86ef3931dd0f7ae9958d5e10c8d Mon Sep 17 00:00:00 2001 From: LeonSGP43 Date: Thu, 7 May 2026 10:06:32 +0800 Subject: [PATCH] fix(auxiliary): refresh auto-routed Copilot credentials --- agent/auxiliary_client.py | 108 ++++++++++++++++++++------- tests/agent/test_auxiliary_client.py | 84 +++++++++++++++++++++ 2 files changed, 166 insertions(+), 26 deletions(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 1e3d39c7ba5e..3ff226973ef9 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -1777,7 +1777,13 @@ def _is_auth_error(exc: Exception) -> bool: if status == 401: return True err_lower = str(exc).lower() - return "error code: 401" in err_lower or "authenticationerror" in type(exc).__name__.lower() + return ( + "error code: 401" in err_lower + or "http 401" in err_lower + or "status code 401" in err_lower + or "unauthorized" in err_lower + or "authenticationerror" in type(exc).__name__.lower() + ) def _is_unsupported_parameter_error(exc: Exception, param: str) -> bool: @@ -1844,6 +1850,25 @@ def _evict_cached_clients(provider: str) -> None: _client_cache.pop(key, None) +def _auth_refresh_provider_for_route( + resolved_provider: Optional[str], + client_base_url: str, +) -> str: + """Infer which concrete provider owns credentials for an auth failure.""" + 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 _refresh_provider_credentials(provider: str) -> bool: """Refresh short-lived credentials for OAuth-backed auxiliary providers.""" normalized = _normalize_aux_provider(provider) @@ -1868,6 +1893,21 @@ def _refresh_provider_credentials(provider: str) -> bool: return False _evict_cached_clients(normalized) return True + 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 == "anthropic": from agent.anthropic_adapter import read_claude_code_credentials, _refresh_oauth_token, resolve_anthropic_token @@ -3642,10 +3682,11 @@ def call_llm( first_err = retry_err # ── Nous auth refresh parity with main agent ────────────────── - client_is_nous = ( - resolved_provider == "nous" - or base_url_host_matches(_base_info, "inference-api.nousresearch.com") + auth_refresh_provider = _auth_refresh_provider_for_route( + resolved_provider, + _base_info, ) + client_is_nous = auth_refresh_provider == "nous" if _is_auth_error(first_err) and client_is_nous: refreshed_client, refreshed_model = _refresh_nous_auxiliary_client( cache_provider=resolved_provider or "nous", @@ -3667,22 +3708,25 @@ def call_llm( # ── Auth refresh retry ─────────────────────────────────────── 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): + original_provider = _normalize_aux_provider(resolved_provider) + if _refresh_provider_credentials(auth_refresh_provider): + if auth_refresh_provider != original_provider: + _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, ) retry_client, retry_model = ( resolve_vision_provider_client( - provider=resolved_provider, + provider=auth_refresh_provider, model=final_model, async_mode=False, )[1:] if task == "vision" else _get_cached_client( - resolved_provider, + auth_refresh_provider, resolved_model, base_url=resolved_base_url, api_key=resolved_api_key, @@ -3691,8 +3735,9 @@ def call_llm( ) ) if retry_client is not None: + _retry_base = str(getattr(retry_client, "base_url", "") or "") retry_kwargs = _build_call_kwargs( - resolved_provider, + auth_refresh_provider, retry_model or final_model, messages, temperature=temperature, @@ -3700,10 +3745,9 @@ def call_llm( tools=tools, timeout=effective_timeout, extra_body=effective_extra_body, - base_url=resolved_base_url, + base_url=_retry_base or resolved_base_url, ) - _retry_base = str(getattr(retry_client, "base_url", "") or "") - if _is_anthropic_compat_endpoint(resolved_provider, _retry_base): + if _is_anthropic_compat_endpoint(auth_refresh_provider, _retry_base): retry_kwargs["messages"] = _convert_openai_images_to_anthropic(retry_kwargs["messages"]) return _validate_llm_response( retry_client.chat.completions.create(**retry_kwargs), task) @@ -3820,6 +3864,7 @@ async def async_call_llm( model: str = None, base_url: str = None, api_key: str = None, + main_runtime: Optional[Dict[str, Any]] = None, messages: list, temperature: float = None, max_tokens: int = None, @@ -3868,6 +3913,7 @@ async def async_call_llm( base_url=resolved_base_url, api_key=resolved_api_key, api_mode=resolved_api_mode, + main_runtime=main_runtime, ) if client is None: _explicit = (resolved_provider or "").strip().lower() @@ -3880,7 +3926,11 @@ async def async_call_llm( if 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) + client, final_model = _get_cached_client( + "auto", + async_mode=True, + main_runtime=main_runtime, + ) if client is None: raise RuntimeError( f"No LLM provider configured for task={task} provider={resolved_provider}. " @@ -3948,10 +3998,11 @@ async def async_call_llm( first_err = retry_err # ── Nous auth refresh parity with main agent ────────────────── - client_is_nous = ( - resolved_provider == "nous" - or base_url_host_matches(_client_base, "inference-api.nousresearch.com") + auth_refresh_provider = _auth_refresh_provider_for_route( + resolved_provider, + _client_base, ) + client_is_nous = auth_refresh_provider == "nous" if _is_auth_error(first_err) and client_is_nous: refreshed_client, refreshed_model = _refresh_nous_auxiliary_client( cache_provider=resolved_provider or "nous", @@ -3960,6 +4011,7 @@ async def async_call_llm( base_url=resolved_base_url, api_key=resolved_api_key, api_mode=resolved_api_mode, + main_runtime=main_runtime, is_vision=(task == "vision"), ) if refreshed_client is not None: @@ -3972,31 +4024,36 @@ async def async_call_llm( # ── Auth refresh retry (mirrors sync call_llm) ─────────────── 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): + original_provider = _normalize_aux_provider(resolved_provider) + if _refresh_provider_credentials(auth_refresh_provider): + if auth_refresh_provider != original_provider: + _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, ) if task == "vision": _, retry_client, retry_model = resolve_vision_provider_client( - provider=resolved_provider, + provider=auth_refresh_provider, model=final_model, async_mode=True, ) else: retry_client, retry_model = _get_cached_client( - resolved_provider, + auth_refresh_provider, resolved_model, async_mode=True, base_url=resolved_base_url, api_key=resolved_api_key, api_mode=resolved_api_mode, + main_runtime=main_runtime, ) if retry_client is not None: + _retry_base = str(getattr(retry_client, "base_url", "") or "") retry_kwargs = _build_call_kwargs( - resolved_provider, + auth_refresh_provider, retry_model or final_model, messages, temperature=temperature, @@ -4004,10 +4061,9 @@ async def async_call_llm( tools=tools, timeout=effective_timeout, extra_body=effective_extra_body, - base_url=resolved_base_url, + base_url=_retry_base or resolved_base_url, ) - _retry_base = str(getattr(retry_client, "base_url", "") or "") - if _is_anthropic_compat_endpoint(resolved_provider, _retry_base): + if _is_anthropic_compat_endpoint(auth_refresh_provider, _retry_base): retry_kwargs["messages"] = _convert_openai_images_to_anthropic(retry_kwargs["messages"]) return _validate_llm_response( await retry_client.chat.completions.create(**retry_kwargs), task) diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index 55a7e969e181..7803554dca0d 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -1454,6 +1454,13 @@ async def create(self, **kwargs): class TestAuxiliaryAuthRefreshRetry: + def test_is_auth_error_detects_copilot_http_401_text(self): + from agent.auxiliary_client import _is_auth_error + + assert _is_auth_error( + RuntimeError("HTTP 401: IDE token expired: unauthorized: token expired") + ) + def test_call_llm_refreshes_codex_on_401_for_vision(self): failing_client = MagicMock() failing_client.base_url = "https://chatgpt.com/backend-api/codex" @@ -1532,6 +1539,34 @@ def test_call_llm_refreshes_anthropic_on_401_for_non_vision(self): assert stale_client.chat.completions.create.call_count == 1 assert fresh_client.chat.completions.create.call_count == 1 + def test_call_llm_refreshes_auto_routed_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") + + fresh_client = MagicMock() + fresh_client.base_url = "https://api.githubcopilot.com" + fresh_client.chat.completions.create.return_value = _DummyResponse("fresh-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-4o"), (fresh_client, "gpt-4o")]) as mock_get, + 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": "hi"}], + ) + + assert resp.choices[0].message.content == "fresh-copilot" + mock_refresh.assert_called_once_with("copilot") + mock_evict.assert_called_once_with("auto") + assert mock_get.call_args_list[0].args[0] == "auto" + assert mock_get.call_args_list[1].args[0] == "copilot" + assert stale_client.chat.completions.create.call_count == 1 + assert fresh_client.chat.completions.create.call_count == 1 + @pytest.mark.asyncio async def test_async_call_llm_refreshes_codex_on_401_for_vision(self): failing_client = MagicMock() @@ -1589,6 +1624,21 @@ def test_refresh_provider_credentials_force_refreshes_anthropic_oauth_and_evicts mock_write.assert_called_once_with("fresh-token", "refresh-token-2", 9999999999999) stale_client.close.assert_called_once() + def test_refresh_provider_credentials_refreshes_copilot_jwt_and_evicts_cache(self): + with ( + patch("hermes_cli.copilot_auth.resolve_copilot_token", return_value=("raw-gho-token", "test")), + patch("hermes_cli.copilot_auth._token_fingerprint", return_value="fp"), + patch("hermes_cli.copilot_auth._jwt_cache", {"fp": ("expired-api-token", 0)}), + patch("hermes_cli.copilot_auth.exchange_copilot_token", return_value=("fresh-api-token", 9999999999)) as mock_exchange, + patch("agent.auxiliary_client._evict_cached_clients") as mock_evict, + ): + from agent.auxiliary_client import _refresh_provider_credentials + + assert _refresh_provider_credentials("copilot") is True + + mock_exchange.assert_called_once_with("raw-gho-token") + mock_evict.assert_called_once_with("copilot") + @pytest.mark.asyncio async def test_async_call_llm_refreshes_anthropic_on_401_for_non_vision(self): stale_client = MagicMock() @@ -1616,6 +1666,40 @@ async def test_async_call_llm_refreshes_anthropic_on_401_for_non_vision(self): assert stale_client.chat.completions.create.await_count == 1 assert fresh_client.chat.completions.create.await_count == 1 + @pytest.mark.asyncio + async def test_async_call_llm_refreshes_auto_routed_copilot_on_401(self): + stale_client = MagicMock() + stale_client.base_url = "https://api.githubcopilot.com" + stale_client.chat.completions.create = AsyncMock(side_effect=_AuxAuth401("IDE token expired")) + + fresh_client = MagicMock() + fresh_client.base_url = "https://api.githubcopilot.com" + fresh_client.chat.completions.create = AsyncMock(return_value=_DummyResponse("fresh-async-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-4o"), (fresh_client, "gpt-4o")]) as mock_get, + patch("agent.auxiliary_client._refresh_provider_credentials", return_value=True) as mock_refresh, + patch("agent.auxiliary_client._evict_cached_clients") as mock_evict, + ): + resp = await async_call_llm( + task="session_search", + main_runtime={"provider": "copilot", "model": "gpt-4o"}, + messages=[{"role": "user", "content": "hi"}], + ) + + assert resp.choices[0].message.content == "fresh-async-copilot" + mock_refresh.assert_called_once_with("copilot") + mock_evict.assert_called_once_with("auto") + assert mock_get.call_args_list[0].args[0] == "auto" + assert mock_get.call_args_list[0].kwargs["main_runtime"] == { + "provider": "copilot", + "model": "gpt-4o", + } + assert mock_get.call_args_list[1].args[0] == "copilot" + assert stale_client.chat.completions.create.await_count == 1 + assert fresh_client.chat.completions.create.await_count == 1 + class TestCodexAdapterReasoningTranslation: """Verify _CodexCompletionsAdapter translates extra_body.reasoning