diff --git a/run_agent.py b/run_agent.py index a9a4f7f5c2..47cf1ac6ae 100644 --- a/run_agent.py +++ b/run_agent.py @@ -5135,6 +5135,41 @@ class AIAgent: return True + def _try_refresh_copilot_client_credentials(self) -> bool: + """Refresh Copilot credentials and rebuild the shared OpenAI client. + + Copilot tokens may remain the same string across refreshes (`gh auth token` + returns a stable OAuth token in many setups). We still rebuild the client + on 401 so retries recover from stale auth/client state without requiring + a session restart. + """ + if self.provider != "copilot": + return False + + try: + from hermes_cli.copilot_auth import resolve_copilot_token + + new_token, token_source = resolve_copilot_token() + except Exception as exc: + logger.debug("Copilot credential refresh failed: %s", exc) + return False + + if not isinstance(new_token, str) or not new_token.strip(): + return False + + new_token = new_token.strip() + + self.api_key = new_token + self._client_kwargs["api_key"] = self.api_key + self._client_kwargs["base_url"] = self.base_url + self._apply_client_headers_for_base_url(str(self.base_url or "")) + + if not self._replace_primary_openai_client(reason="copilot_credential_refresh"): + return False + + logger.info("Copilot credentials refreshed from %s", token_source) + return True + def _try_refresh_anthropic_client_credentials(self) -> bool: if self.api_mode != "anthropic_messages" or not hasattr(self, "_anthropic_api_key"): return False @@ -9375,6 +9410,7 @@ class AIAgent: codex_auth_retry_attempted=False anthropic_auth_retry_attempted=False nous_auth_retry_attempted=False + copilot_auth_retry_attempted=False thinking_sig_retry_attempted = False has_retried_429 = False restart_with_compressed_messages = False @@ -10338,6 +10374,15 @@ class AIAgent: print(f"{self.log_prefix} • Check credits / billing: https://portal.nousresearch.com") print(f"{self.log_prefix} • Verify stored credentials: {_dhh}/auth.json") print(f"{self.log_prefix} • Switch providers temporarily: /model --provider openrouter") + if ( + self.provider == "copilot" + and status_code == 401 + and not copilot_auth_retry_attempted + ): + copilot_auth_retry_attempted = True + if self._try_refresh_copilot_client_credentials(): + self._vprint(f"{self.log_prefix}🔐 Copilot credentials refreshed after 401. Retrying request...") + continue if ( self.api_mode == "anthropic_messages" and status_code == 401 diff --git a/tests/run_agent/test_run_agent_codex_responses.py b/tests/run_agent/test_run_agent_codex_responses.py index 16ab3f02d0..d6567f0ec9 100644 --- a/tests/run_agent/test_run_agent_codex_responses.py +++ b/tests/run_agent/test_run_agent_codex_responses.py @@ -578,6 +578,36 @@ def test_run_conversation_codex_refreshes_after_401_and_retries(monkeypatch): assert result["final_response"] == "Recovered after refresh" +def test_run_conversation_copilot_refreshes_after_401_and_retries(monkeypatch): + agent = _build_copilot_agent(monkeypatch) + calls = {"api": 0, "refresh": 0} + + class _UnauthorizedError(RuntimeError): + def __init__(self): + super().__init__("Error code: 401 - unauthorized") + self.status_code = 401 + + def _fake_api_call(api_kwargs): + calls["api"] += 1 + if calls["api"] == 1: + raise _UnauthorizedError() + return _codex_message_response("Recovered after copilot refresh") + + def _fake_refresh(): + calls["refresh"] += 1 + return True + + monkeypatch.setattr(agent, "_interruptible_api_call", _fake_api_call) + monkeypatch.setattr(agent, "_try_refresh_copilot_client_credentials", _fake_refresh) + + result = agent.run_conversation("Say OK") + + assert calls["api"] == 2 + assert calls["refresh"] == 1 + assert result["completed"] is True + assert result["final_response"] == "Recovered after copilot refresh" + + def test_try_refresh_codex_client_credentials_rebuilds_client(monkeypatch): agent = _build_agent(monkeypatch) closed = {"value": False} @@ -613,6 +643,62 @@ def test_try_refresh_codex_client_credentials_rebuilds_client(monkeypatch): assert isinstance(agent.client, _RebuiltClient) +def test_try_refresh_copilot_client_credentials_rebuilds_client(monkeypatch): + agent = _build_copilot_agent(monkeypatch) + closed = {"value": False} + rebuilt = {"kwargs": None} + + class _ExistingClient: + def close(self): + closed["value"] = True + + class _RebuiltClient: + pass + + def _fake_openai(**kwargs): + rebuilt["kwargs"] = kwargs + return _RebuiltClient() + + monkeypatch.setattr( + "hermes_cli.copilot_auth.resolve_copilot_token", + lambda: ("gho_new_token", "GH_TOKEN"), + ) + monkeypatch.setattr(run_agent, "OpenAI", _fake_openai) + + agent.client = _ExistingClient() + ok = agent._try_refresh_copilot_client_credentials() + + assert ok is True + assert closed["value"] is True + assert rebuilt["kwargs"]["api_key"] == "gho_new_token" + assert rebuilt["kwargs"]["base_url"] == "https://api.githubcopilot.com" + assert rebuilt["kwargs"]["default_headers"]["Copilot-Integration-Id"] == "vscode-chat" + assert isinstance(agent.client, _RebuiltClient) + + +def test_try_refresh_copilot_client_credentials_rebuilds_even_if_token_unchanged(monkeypatch): + agent = _build_copilot_agent(monkeypatch) + rebuilt = {"count": 0} + + class _RebuiltClient: + pass + + def _fake_openai(**kwargs): + rebuilt["count"] += 1 + return _RebuiltClient() + + monkeypatch.setattr( + "hermes_cli.copilot_auth.resolve_copilot_token", + lambda: ("gh-token", "gh auth token"), + ) + monkeypatch.setattr(run_agent, "OpenAI", _fake_openai) + + ok = agent._try_refresh_copilot_client_credentials() + + assert ok is True + assert rebuilt["count"] == 1 + + def test_run_conversation_codex_tool_round_trip(monkeypatch): agent = _build_agent(monkeypatch) responses = [_codex_tool_call_response(), _codex_message_response("done")] diff --git a/website/docs/integrations/providers.md b/website/docs/integrations/providers.md index 1f6f512ea9..eb0eb4e790 100644 --- a/website/docs/integrations/providers.md +++ b/website/docs/integrations/providers.md @@ -216,6 +216,18 @@ The Copilot API does **not** support classic Personal Access Tokens (`ghp_*`). S If your `gh auth token` returns a `ghp_*` token, use `hermes model` to authenticate via OAuth instead. ::: +:::info Copilot auth behavior in Hermes +Hermes sends a supported GitHub token (`gho_*`, `github_pat_*`, or `ghu_*`) directly to `api.githubcopilot.com` and includes Copilot-specific headers (`Editor-Version`, `Copilot-Integration-Id`, `Openai-Intent`, `x-initiator`). + +On HTTP 401, Hermes now performs a one-shot credential recovery before fallback: + +1. Re-resolve token via the normal priority chain (`COPILOT_GITHUB_TOKEN` → `GH_TOKEN` → `GITHUB_TOKEN` → `gh auth token`) +2. Rebuild the shared OpenAI client with refreshed headers +3. Retry the request once + +Some older community proxies use `api.github.com/copilot_internal/v2/token` exchange flows. That endpoint can be unavailable for some account types (returns 404). Hermes therefore keeps direct-token auth as the primary path and relies on runtime credential refresh + retry for robustness. +::: + **API routing**: GPT-5+ models (except `gpt-5-mini`) automatically use the Responses API. All other models (GPT-4o, Claude, Gemini, etc.) use Chat Completions. Models are auto-detected from the live Copilot catalog. **`copilot-acp` — Copilot ACP agent backend**. Spawns the local Copilot CLI as a subprocess: