From 35178a7f8ef35ac100e14570d414669fb2a5f566 Mon Sep 17 00:00:00 2001 From: Adam Krawczyk Date: Tue, 4 Aug 2026 11:09:55 +0200 Subject: [PATCH] fix(copilot): support GitHub Enterprise endpoints Route OAuth, token exchange, model discovery, inference, refresh, and auxiliary clients through configured or exchange-discovered enterprise endpoints while preserving public GitHub defaults. Builds on the enterprise support proposed by @HearthCore in #6468. Refs #11442 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- agent/agent_init.py | 8 +- agent/agent_runtime_helpers.py | 3 +- agent/auxiliary_client.py | 22 +++--- agent/chat_completion_helpers.py | 5 +- agent/credential_pool.py | 10 ++- hermes_cli/auth.py | 2 +- hermes_cli/copilot_auth.py | 48 +++++++++++- hermes_cli/models.py | 31 ++++++-- run_agent.py | 35 ++++----- tests/agent/test_auxiliary_main_first.py | 25 +++++++ tests/agent/test_credential_pool.py | 27 +++++++ tests/hermes_cli/test_api_key_providers.py | 24 +++++- tests/hermes_cli/test_copilot_context.py | 68 ++++++++++++++++- .../hermes_cli/test_copilot_token_exchange.py | 26 +++++++ .../test_copilot_native_vision_headers.py | 42 ++++++++++- .../test_run_agent_codex_responses.py | 74 ++++++++++++++++++- tests/test_copilot_initiator.py | 11 ++- website/docs/integrations/providers.md | 16 ++++ .../docs/reference/environment-variables.md | 1 + 19 files changed, 423 insertions(+), 55 deletions(-) diff --git a/agent/agent_init.py b/agent/agent_init.py index 649d5338a9965..ad0fbdc0fcea0 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -53,6 +53,7 @@ from hermes_cli.timeouts import get_provider_request_timeout from hermes_constants import get_hermes_home from utils import base_url_host_matches, is_truthy_value +from hermes_cli.copilot_auth import is_copilot_api_url # Use the same logger name as run_agent so tests patching ``run_agent.logger`` # capture our warnings. (run_agent.py also does @@ -1178,7 +1179,12 @@ def init_agent( client_kwargs["default_headers"] = build_nvidia_nim_headers(effective_base) elif base_url_host_matches(effective_base, "api.routermint.com"): client_kwargs["default_headers"] = _ra()._routermint_headers() - elif base_url_host_matches(effective_base, "githubcopilot.com"): + elif ( + is_copilot_api_url( + effective_base, + provider=getattr(agent, "provider", "") or "", + ) + ): from hermes_cli.models import copilot_default_headers client_kwargs["default_headers"] = copilot_default_headers() diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 1bdce7a4989d4..4879bcd8566db 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -33,6 +33,7 @@ from typing import Any, Dict, List, Optional, Tuple from hermes_cli.timeouts import get_provider_request_timeout +from hermes_cli.copilot_auth import is_copilot_api_url from agent.prompt_builder import format_steer_marker from agent.tool_dispatch_helpers import _trajectory_normalize_msg, make_tool_result_message from agent.trajectory import convert_scratchpad_to_think @@ -2329,7 +2330,7 @@ def create_openai_client(agent, client_kwargs: dict, *, reason: str, shared: boo # missing Copilot headers here closes the whole class. We only ADD missing # keys — never override headers a caller deliberately set. try: - if base_url_host_matches(str(client_kwargs.get("base_url", "")), "githubcopilot.com"): + if is_copilot_api_url(str(client_kwargs.get("base_url", ""))): from hermes_cli.models import copilot_default_headers existing = dict(client_kwargs.get("default_headers") or {}) existing_lower = {k.lower() for k in existing} diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index aacc2c2a39438..84ba5d3005522 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -61,6 +61,7 @@ from types import SimpleNamespace from typing import Any, Callable, Dict, List, NamedTuple, Optional, Tuple, TYPE_CHECKING from urllib.parse import urlparse, parse_qs, urlunparse +from hermes_cli.copilot_auth import is_copilot_api_url # NOTE: `from openai import OpenAI` is deliberately NOT at module top — the # openai SDK pulls a large type tree (~240 ms cold, including responses/*, @@ -1208,7 +1209,7 @@ def create(self, **kwargs) -> Any: # through this adapter instead of agent/transports/codex.py's # build_kwargs, so they need the same guard applied independently. _host_for_input = str(getattr(self._client, "base_url", "") or "") - _is_github_for_input = base_url_host_matches(_host_for_input, "githubcopilot.com") + _is_github_for_input = is_copilot_api_url(_host_for_input) input_items = _chat_messages_to_responses_input( replay_messages, is_github_responses=_is_github_for_input, ) @@ -1324,7 +1325,7 @@ def create(self, **kwargs) -> Any: _host_src = str(getattr(self._client, "base_url", "") or "") _is_xai = base_url_host_matches(_host_src, "x.ai") or base_url_host_matches(_host_src, "api.x.ai") _is_github = ( - base_url_host_matches(_host_src, "githubcopilot.com") + is_copilot_api_url(_host_src) or base_url_host_matches(_host_src, "models.github.ai") ) if not _is_xai and not _is_github and "prompt_cache_key" not in resp_kwargs: @@ -2357,7 +2358,7 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]: extra = {} if base_url_host_matches(base_url, "api.kimi.com"): extra["default_headers"] = {"User-Agent": "claude-code/0.1.0"} - elif base_url_host_matches(base_url, "githubcopilot.com"): + elif is_copilot_api_url(base_url, provider=provider_id): from hermes_cli.models import copilot_default_headers extra["default_headers"] = copilot_default_headers() @@ -2397,7 +2398,7 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]: extra = {} if base_url_host_matches(base_url, "api.kimi.com"): extra["default_headers"] = {"User-Agent": "claude-code/0.1.0"} - elif base_url_host_matches(base_url, "githubcopilot.com"): + elif is_copilot_api_url(base_url, provider=provider_id): from hermes_cli.models import copilot_default_headers extra["default_headers"] = copilot_default_headers() @@ -4170,7 +4171,7 @@ def _recoverable_pool_provider( return "nous" if base_url_host_matches(base, "api.anthropic.com"): return "anthropic" - if base_url_host_matches(base, "githubcopilot.com"): + if is_copilot_api_url(base): return "copilot" if base_url_host_matches(base, "api.kimi.com"): return "kimi-coding" @@ -4493,7 +4494,7 @@ def _auth_refresh_provider_for_route( normalized = _normalize_aux_provider(resolved_provider) if normalized and normalized != "auto": return normalized - if base_url_host_matches(client_base_url, "api.githubcopilot.com"): + if is_copilot_api_url(client_base_url): return "copilot" if base_url_host_matches(client_base_url, "chatgpt.com"): return "openai-codex" @@ -5600,7 +5601,7 @@ def _to_async_client(sync_client, model: str, is_vision: bool = False): sync_base_url = str(sync_client.base_url) if base_url_host_matches(sync_base_url, "openrouter.ai"): async_kwargs["default_headers"] = build_or_headers() - elif base_url_host_matches(sync_base_url, "githubcopilot.com"): + elif is_copilot_api_url(sync_base_url): from hermes_cli.copilot_auth import copilot_request_headers async_kwargs["default_headers"] = copilot_request_headers( @@ -5981,7 +5982,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", extra["default_query"] = _dq if base_url_host_matches(custom_base, "api.kimi.com"): extra["default_headers"] = {"User-Agent": "claude-code/0.1.0"} - elif base_url_host_matches(custom_base, "githubcopilot.com"): + elif is_copilot_api_url(custom_base): from hermes_cli.copilot_auth import copilot_request_headers extra["default_headers"] = copilot_request_headers( is_agent_turn=True, is_vision=is_vision @@ -6238,7 +6239,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", headers = {} if base_url_host_matches(base_url, "api.kimi.com"): headers["User-Agent"] = "claude-code/0.1.0" - elif base_url_host_matches(base_url, "githubcopilot.com"): + elif is_copilot_api_url(base_url, provider=provider): from hermes_cli.copilot_auth import copilot_request_headers headers.update(copilot_request_headers( @@ -6874,8 +6875,7 @@ def auxiliary_max_tokens_param(value: int, *, model: Optional[str] = None) -> di and _read_nous_auth() is None and ( _custom_host == "api.openai.com" - or _custom_host == "api.githubcopilot.com" - or _custom_host.endswith(".githubcopilot.com") + or is_copilot_api_url(custom_base) )): return {"max_completion_tokens": value} # ...and for any caller serving a newer OpenAI-family model by name. diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 3b7d8b0361981..ffd2180302412 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -28,6 +28,7 @@ from typing import Any, Dict, Optional from hermes_cli.timeouts import get_provider_request_timeout, get_provider_stale_timeout +from hermes_cli.copilot_auth import is_copilot_api_url from hermes_constants import PARTIAL_STREAM_STUB_ID, FINISH_REASON_LENGTH from agent.error_classifier import FailoverReason from agent.errors import EmptyStreamError @@ -1172,7 +1173,7 @@ def build_api_kwargs(agent, api_messages: list, tools_for_api: list | None = Non _ct = agent._get_transport() is_github_responses = ( base_url_host_matches(agent.base_url, "models.github.ai") - or base_url_host_matches(agent.base_url, "githubcopilot.com") + or is_copilot_api_url(agent.base_url, provider=agent.provider) ) is_codex_backend = ( agent.provider == "openai-codex" @@ -1243,7 +1244,7 @@ def build_api_kwargs(agent, api_messages: list, tools_for_api: list | None = Non _is_or = agent._is_openrouter_url() _is_gh = ( base_url_host_matches(agent._base_url_lower, "models.github.ai") - or base_url_host_matches(agent._base_url_lower, "githubcopilot.com") + or is_copilot_api_url(agent._base_url_lower, provider=agent.provider) ) _is_nous = "nousresearch" in agent._base_url_lower _is_nvidia = "integrate.api.nvidia.com" in agent._base_url_lower diff --git a/agent/credential_pool.py b/agent/credential_pool.py index 8917b49d8d597..6e0803b93ebbc 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -2566,10 +2566,12 @@ def _env_val(key: str) -> str: ) active_sources.add(source_name) pconfig = PROVIDER_REGISTRY.get(provider) - # Use enterprise base URL from token exchange if available, - # otherwise fall back to the provider's default. - effective_base_url = enterprise_base_url or ( - pconfig.inference_base_url if pconfig else "" + # An explicit endpoint is authoritative; otherwise use exchange + # metadata before falling back to the public provider default. + effective_base_url = ( + os.getenv("COPILOT_API_BASE_URL", "").strip().rstrip("/") + or enterprise_base_url + or (pconfig.inference_base_url if pconfig else "") ) changed |= _upsert_entry( entries, diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index bf7b00f203138..c9e8dbb897026 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -7137,7 +7137,7 @@ def resolve_api_key_provider_credentials(provider_id: str) -> Dict[str, Any]: if raw_token: _, resolved = get_copilot_api_token(raw_token) resolved = (resolved or "").strip() - if resolved: + if resolved and not env_url: base_url = resolved except Exception as exc: logger.debug("Copilot base URL resolution fell back to default: %s", exc) diff --git a/hermes_cli/copilot_auth.py b/hermes_cli/copilot_auth.py index 8416f99d226c0..af257d9bbe44b 100644 --- a/hermes_cli/copilot_auth.py +++ b/hermes_cli/copilot_auth.py @@ -150,7 +150,7 @@ def _try_gh_cli_token() -> Optional[str]: subprocess environment so ``gh`` reads from its own credential store (hosts.yml) instead of just echoing the env var back. """ - hostname = os.getenv("COPILOT_GH_HOST", "").strip() + hostname = _normalize_copilot_host(os.getenv("COPILOT_GH_HOST", "")) # Build a clean env so gh doesn't short-circuit on GITHUB_TOKEN / GH_TOKEN clean_env = {k: v for k, v in os.environ.items() @@ -180,9 +180,40 @@ def _try_gh_cli_token() -> Optional[str]: # ─── OAuth Device Code Flow ──────────────────────────────────────────────── +def _normalize_copilot_host(value: str) -> str: + """Return a bare host[:port] from a hostname or URL.""" + from urllib.parse import urlsplit + + candidate = str(value or "").strip().rstrip("/") + if not candidate: + return "" + parsed = urlsplit(candidate if "://" in candidate else f"//{candidate}") + return parsed.netloc or parsed.path.split("/", 1)[0] + + +def resolve_copilot_github_host() -> str: + """Resolve the GitHub host used for OAuth, CLI auth, and token exchange.""" + return _normalize_copilot_host(os.getenv("COPILOT_GH_HOST", "")) or "github.com" + + +def is_copilot_api_url(base_url: str, *, provider: str = "") -> bool: + """Return whether a URL is a public or configured Copilot inference API.""" + from utils import base_url_hostname + + if provider.strip().lower() in {"copilot", "github-copilot", "github"}: + return True + hostname = base_url_hostname(base_url) + if not hostname: + return False + if hostname == "api.githubcopilot.com" or hostname.endswith(".githubcopilot.com"): + return True + configured = os.getenv("COPILOT_API_BASE_URL", "").strip() + return bool(configured and hostname == base_url_hostname(configured)) + + def copilot_device_code_login( *, - host: str = "github.com", + host: Optional[str] = None, timeout_seconds: float = 300, ) -> Optional[str]: """Run the GitHub OAuth device code flow for Copilot. @@ -195,7 +226,7 @@ def copilot_device_code_login( import urllib.request import urllib.parse - domain = host.rstrip("/") + domain = _normalize_copilot_host(host or resolve_copilot_github_host()) device_code_url = f"https://{domain}/login/device/code" access_token_url = f"https://{domain}/login/oauth/access_token" @@ -315,6 +346,15 @@ def copilot_device_code_login( _EDITOR_VERSION = "vscode/1.104.1" _EXCHANGE_USER_AGENT = "GitHubCopilotChat/0.26.7" + +def resolve_copilot_token_exchange_url() -> str: + """Return the public or GHE token-exchange endpoint.""" + host = resolve_copilot_github_host() + if host not in {"github.com", "api.github.com"}: + return f"https://{host}/api/v3/copilot_internal/v2/token" + return _TOKEN_EXCHANGE_URL + + # Transient-failure hardening for the token exchange. Gateway startup often # races network readiness (launchd relaunch, DHCP/VPN settling); a single-shot # exchange that fails there silently degrades to the RAW GitHub token, which the @@ -528,7 +568,7 @@ def exchange_copilot_token(raw_token: str, *, timeout: float = 10.0) -> tuple[st ) req = urllib.request.Request( - _TOKEN_EXCHANGE_URL, + resolve_copilot_token_exchange_url(), method="GET", headers={ "Authorization": f"token {raw_token}", diff --git a/hermes_cli/models.py b/hermes_cli/models.py index e2e6c967f9d22..8113875fd34f5 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -31,7 +31,6 @@ _HERMES_USER_AGENT = f"hermes-cli/{_HERMES_VERSION}" COPILOT_BASE_URL = "https://api.githubcopilot.com" -COPILOT_MODELS_URL = f"{COPILOT_BASE_URL}/models" COPILOT_EDITOR_VERSION = "vscode/1.104.1" COPILOT_REASONING_EFFORTS_GPT5 = ["minimal", "low", "medium", "high"] COPILOT_REASONING_EFFORTS_O_SERIES = ["low", "medium", "high"] @@ -3476,11 +3475,29 @@ def _copilot_catalog_item_is_text_model(item: dict[str, Any]) -> bool: # clock so wall-clock adjustments can't extend the TTL. Lock-free like the # other module caches here — a racing thread at worst duplicates one fetch. _github_model_catalog_cache: Optional[list[dict[str, Any]]] = None -_github_model_catalog_cache_key: Optional[str] = None +_github_model_catalog_cache_key: Optional[tuple[Optional[str], str]] = None _github_model_catalog_cache_time: float = 0.0 _GITHUB_MODEL_CATALOG_CACHE_TTL = 300 # 5 minutes +def _resolve_copilot_catalog_base_url() -> str: + configured = os.getenv("COPILOT_API_BASE_URL", "").strip().rstrip("/") + if configured: + return configured + + try: + from hermes_cli.auth import resolve_api_key_provider_credentials + + credentials = resolve_api_key_provider_credentials("copilot") + resolved = str(credentials.get("base_url", "")).strip().rstrip("/") + if resolved: + return resolved + except Exception as exc: + logger.debug("Copilot catalog endpoint resolution fell back to default: %s", exc) + + return COPILOT_BASE_URL + + def fetch_github_model_catalog( api_key: Optional[str] = None, timeout: float = 5.0 ) -> Optional[list[dict[str, Any]]]: @@ -3488,9 +3505,13 @@ def fetch_github_model_catalog( global _github_model_catalog_cache, _github_model_catalog_cache_key global _github_model_catalog_cache_time + base_url = _resolve_copilot_catalog_base_url() + models_url = f"{base_url}/models" + cache_key = (api_key, models_url) + if ( _github_model_catalog_cache is not None - and _github_model_catalog_cache_key == api_key + and _github_model_catalog_cache_key == cache_key and (time.monotonic() - _github_model_catalog_cache_time) < _GITHUB_MODEL_CATALOG_CACHE_TTL ): # Deep copy: catalog items are dicts, and a shallow copy would let @@ -3506,7 +3527,7 @@ def fetch_github_model_catalog( attempts.append(copilot_default_headers()) for headers in attempts: - req = urllib.request.Request(COPILOT_MODELS_URL, headers=headers) + req = urllib.request.Request(models_url, headers=headers) try: with _urlopen_model_catalog_request(req, timeout=timeout) as resp: data = json.loads(resp.read().decode()) @@ -3523,7 +3544,7 @@ def fetch_github_model_catalog( models.append(item) if models: _github_model_catalog_cache = copy.deepcopy(models) - _github_model_catalog_cache_key = api_key + _github_model_catalog_cache_key = cache_key _github_model_catalog_cache_time = time.monotonic() return models except Exception: diff --git a/run_agent.py b/run_agent.py index 60b3c1db0e692..653042f9d5c3c 100644 --- a/run_agent.py +++ b/run_agent.py @@ -1352,15 +1352,13 @@ def _is_azure_openai_url(self, base_url: str = None) -> bool: def _is_github_copilot_url(self, base_url: str = None) -> bool: """Return True when a base URL targets GitHub Copilot's OpenAI-compatible API.""" - if base_url is not None: - hostname = base_url_hostname(base_url) - else: - hostname = getattr(self, "_base_url_hostname", "") or base_url_hostname( - getattr(self, "_base_url_lower", "") - ) - if not hostname: - return False - return hostname == "api.githubcopilot.com" or hostname.endswith(".githubcopilot.com") + from hermes_cli.copilot_auth import is_copilot_api_url + + url = base_url if base_url is not None else getattr(self, "_base_url_lower", "") + return is_copilot_api_url( + url, + provider=getattr(self, "provider", "") or "", + ) def _resolved_api_call_timeout(self) -> float: """Resolve the effective per-call request timeout in seconds. @@ -1502,9 +1500,8 @@ def _is_openrouter_url(self) -> bool: def _is_copilot_url(self) -> bool: """Return True when the base URL targets GitHub Copilot or GitHub Models.""" - return ( - "api.githubcopilot.com" in self._base_url_lower - or "models.github.ai" in self._base_url_lower + return self._is_github_copilot_url() or base_url_host_matches( + self._base_url_lower, "models.github.ai" ) def _is_copilot_provider(self) -> bool: @@ -4987,7 +4984,7 @@ def _create_request_openai_client(self, *, reason: str, api_kwargs: Optional[dic # unaffected (they don't go through here). request_kwargs["max_retries"] = 0 if ( - base_url_host_matches(str(request_kwargs.get("base_url", "")), "githubcopilot.com") + self._is_github_copilot_url(str(request_kwargs.get("base_url", ""))) and self._api_kwargs_have_image_parts(api_kwargs or {}) ): request_kwargs["default_headers"] = self._copilot_headers_for_request(is_vision=True) @@ -5473,7 +5470,9 @@ def _try_refresh_copilot_client_credentials(self) -> bool: api_token, enterprise_base_url = get_copilot_api_token(new_token) if isinstance(api_token, str) and api_token.strip(): new_token = api_token.strip() - if enterprise_base_url: + if enterprise_base_url and not os.getenv( + "COPILOT_API_BASE_URL", "" + ).strip(): self.base_url = enterprise_base_url.rstrip("/") except Exception as exc: logger.debug("Copilot 401 re-exchange failed, using resolved token: %s", exc) @@ -5543,7 +5542,9 @@ def _try_recover_stale_copilot_credential(self) -> bool: return False self.api_key = api_token.strip() - if enterprise_base_url: + if enterprise_base_url and not os.getenv( + "COPILOT_API_BASE_URL", "" + ).strip(): self.base_url = enterprise_base_url.rstrip("/") self._client_kwargs["api_key"] = self.api_key self._client_kwargs["base_url"] = self.base_url @@ -5626,7 +5627,7 @@ def _apply_client_headers_for_base_url( self._client_kwargs["default_headers"] = build_nvidia_nim_headers(base_url) elif base_url_host_matches(base_url, "api.routermint.com"): self._client_kwargs["default_headers"] = _routermint_headers() - elif base_url_host_matches(base_url, "githubcopilot.com"): + elif self._is_github_copilot_url(base_url): from hermes_cli.models import copilot_default_headers self._client_kwargs["default_headers"] = copilot_default_headers() @@ -6826,7 +6827,7 @@ def _supports_reasoning_extra_body(self) -> bool: return True if ( base_url_host_matches(self._base_url_lower, "models.github.ai") - or base_url_host_matches(self._base_url_lower, "githubcopilot.com") + or self._is_github_copilot_url() ): try: from hermes_cli.models import github_model_reasoning_efforts diff --git a/tests/agent/test_auxiliary_main_first.py b/tests/agent/test_auxiliary_main_first.py index 6b19337d0fdca..dbc531ce2dbb9 100644 --- a/tests/agent/test_auxiliary_main_first.py +++ b/tests/agent/test_auxiliary_main_first.py @@ -421,6 +421,31 @@ def fake_headers(*, is_agent_turn=False, is_vision=False): assert captured == {"is_agent_turn": True, "is_vision": False} assert "default_headers" not in mock_openai.call_args.kwargs + def test_copilot_exchange_endpoint_receives_copilot_headers(self, monkeypatch): + monkeypatch.delenv("COPILOT_API_BASE_URL", raising=False) + monkeypatch.setenv("COPILOT_GITHUB_TOKEN", "ghu_test-token") + + with patch( + "agent.auxiliary_client.OpenAI", + ) as mock_openai, patch( + "hermes_cli.auth.resolve_api_key_provider_credentials", + return_value={ + "provider": "copilot", + "api_key": "copilot-api-token", + "base_url": "https://copilot-api.ghe.example.com", + }, + ), patch( + "hermes_cli.copilot_auth.copilot_request_headers", + return_value={"Copilot-Integration-Id": "vscode-chat"}, + ): + from agent.auxiliary_client import resolve_provider_client + + resolve_provider_client("copilot", "gpt-5-mini") + + assert mock_openai.call_args.kwargs["default_headers"] == { + "Copilot-Integration-Id": "vscode-chat" + } + diff --git a/tests/agent/test_credential_pool.py b/tests/agent/test_credential_pool.py index 8d7e78c74ee87..32831667c3084 100644 --- a/tests/agent/test_credential_pool.py +++ b/tests/agent/test_credential_pool.py @@ -1352,6 +1352,33 @@ def test_load_pool_seeds_copilot_via_gh_auth_token(tmp_path, monkeypatch): assert entries[0].base_url == "https://api.githubcopilot.com" +def test_load_pool_prefers_explicit_copilot_api_base_url(tmp_path, monkeypatch): + """A configured GHE endpoint must override exchange response metadata.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + monkeypatch.setenv( + "COPILOT_API_BASE_URL", + "https://copilot-api.ghe.example.com/", + ) + _write_auth_store(tmp_path, {"version": 1, "credential_pool": {}}) + monkeypatch.setattr( + "hermes_cli.copilot_auth.resolve_copilot_token", + lambda: ("ghu_enterprise", "COPILOT_GITHUB_TOKEN"), + ) + monkeypatch.setattr( + "hermes_cli.copilot_auth.get_copilot_api_token", + lambda token: ( + "tid=exchanged", + "https://api.enterprise.githubcopilot.com", + ), + ) + + from agent.credential_pool import load_pool + + pool = load_pool("copilot") + + assert pool.entries()[0].base_url == "https://copilot-api.ghe.example.com" + + def test_load_pool_skips_exchange_for_suppressed_copilot(tmp_path, monkeypatch): """A suppressed copilot source must NOT run the token exchange. diff --git a/tests/hermes_cli/test_api_key_providers.py b/tests/hermes_cli/test_api_key_providers.py index 619cc74c45acc..76bb0544073bd 100644 --- a/tests/hermes_cli/test_api_key_providers.py +++ b/tests/hermes_cli/test_api_key_providers.py @@ -366,6 +366,29 @@ def _fake_run(cmd, **kwargs): assert _try_gh_cli_token() == "gh-cli-secret" assert calls == [["/opt/homebrew/bin/gh", "auth", "token"]] + def test_try_gh_cli_token_normalizes_enterprise_url_hostname(self, monkeypatch): + monkeypatch.setenv("COPILOT_GH_HOST", "https://ghe.example.com/") + monkeypatch.setattr( + "hermes_cli.copilot_auth._gh_cli_candidates", + lambda: ["/usr/bin/gh"], + ) + calls = [] + + class _Result: + returncode = 0 + stdout = "gh-cli-secret\n" + + def _fake_run(cmd, **kwargs): + calls.append(cmd) + return _Result() + + monkeypatch.setattr("hermes_cli.copilot_auth.subprocess.run", _fake_run) + + assert _try_gh_cli_token() == "gh-cli-secret" + assert calls == [ + ["/usr/bin/gh", "auth", "token", "--hostname", "ghe.example.com"] + ] + def test_resolve_stepfun_with_key(self, monkeypatch): @@ -1213,4 +1236,3 @@ def test_profile_registered_with_alias_and_aux(self): # Fallback list intentionally empty — live catalog is the source # of truth. Pin the shape only, not contents. assert isinstance(profile.fallback_models, tuple) - diff --git a/tests/hermes_cli/test_copilot_context.py b/tests/hermes_cli/test_copilot_context.py index 8914537de8873..085b601ab323c 100644 --- a/tests/hermes_cli/test_copilot_context.py +++ b/tests/hermes_cli/test_copilot_context.py @@ -125,6 +125,72 @@ def read(self): assert [item["id"] for item in third] == ["gpt-4.1"] assert mock_urlopen.call_count == 1 + @patch("hermes_cli.models._urlopen_model_catalog_request") + def test_fetch_github_model_catalog_uses_enterprise_base_url( + self, mock_urlopen, monkeypatch + ): + import json as _json + import hermes_cli.models as mod + + monkeypatch.setenv( + "COPILOT_API_BASE_URL", + "https://copilot-api.ghe.example.com/", + ) + mod._github_model_catalog_cache = None + mod._github_model_catalog_cache_key = None + mock_urlopen.return_value.__enter__.return_value.read.return_value = ( + _json.dumps( + { + "data": [ + { + "id": "gpt-5.4", + "model_picker_enabled": True, + "supported_endpoints": ["/responses"], + } + ] + } + ).encode() + ) + + assert mod.fetch_github_model_catalog(api_key="token") + req = mock_urlopen.call_args[0][0] + assert req.full_url == "https://copilot-api.ghe.example.com/models" + + @patch("hermes_cli.models._urlopen_model_catalog_request") + def test_fetch_github_model_catalog_uses_exchange_base_url( + self, mock_urlopen, monkeypatch + ): + import json as _json + import hermes_cli.models as mod + + monkeypatch.delenv("COPILOT_API_BASE_URL", raising=False) + monkeypatch.setattr( + "hermes_cli.auth.resolve_api_key_provider_credentials", + lambda _provider: { + "api_key": "token", + "base_url": "https://copilot-api.ghe.example.com", + }, + ) + mod._github_model_catalog_cache = None + mod._github_model_catalog_cache_key = None + mock_urlopen.return_value.__enter__.return_value.read.return_value = ( + _json.dumps( + { + "data": [ + { + "id": "gpt-5.4", + "model_picker_enabled": True, + "supported_endpoints": ["/responses"], + } + ] + } + ).encode() + ) + + assert mod.fetch_github_model_catalog(api_key="token") + req = mock_urlopen.call_args[0][0] + assert req.full_url == "https://copilot-api.ghe.example.com/models" + @patch("hermes_cli.models._urlopen_model_catalog_request") def test_fetch_github_model_catalog_cache_expires_after_ttl(self, mock_urlopen): import json as _json @@ -214,5 +280,3 @@ def test_copilot_provider_uses_live_api(self, mock_fetch): ctx = get_model_context_length("claude-opus-4.6-1m", provider="copilot") assert ctx == 1_000_000 - - diff --git a/tests/hermes_cli/test_copilot_token_exchange.py b/tests/hermes_cli/test_copilot_token_exchange.py index 3b448f30d5379..a4439256d05bc 100644 --- a/tests/hermes_cli/test_copilot_token_exchange.py +++ b/tests/hermes_cli/test_copilot_token_exchange.py @@ -51,6 +51,20 @@ def test_exchanges_token_successfully(self, mock_urlopen): assert req.get_header("Authorization") == "token gho_test123" assert "GitHubCopilotChat" in req.get_header("User-agent") + @patch("urllib.request.urlopen") + def test_uses_enterprise_host_for_token_exchange(self, mock_urlopen, monkeypatch): + from hermes_cli.copilot_auth import exchange_copilot_token + + monkeypatch.setenv("COPILOT_GH_HOST", "https://ghe.example.com/") + mock_urlopen.return_value = self._mock_urlopen() + + exchange_copilot_token("ghu_enterprise") + + req = mock_urlopen.call_args[0][0] + assert req.full_url == ( + "https://ghe.example.com/api/v3/copilot_internal/v2/token" + ) + @patch("urllib.request.urlopen") @@ -81,6 +95,18 @@ def test_returns_exchanged_token(self, mock_exchange): assert base_url is None +class TestEnterpriseDeviceLogin: + @patch("urllib.request.urlopen", side_effect=OSError("stop after first request")) + def test_uses_configured_github_host(self, mock_urlopen, monkeypatch): + from hermes_cli.copilot_auth import copilot_device_code_login + + monkeypatch.setenv("COPILOT_GH_HOST", "https://ghe.example.com/") + + assert copilot_device_code_login() is None + req = mock_urlopen.call_args[0][0] + assert req.full_url == "https://ghe.example.com/login/device/code" + + class TestTokenFingerprint: """Tests for _token_fingerprint().""" diff --git a/tests/run_agent/test_copilot_native_vision_headers.py b/tests/run_agent/test_copilot_native_vision_headers.py index 09c3258450c8d..f18019c15c5ec 100644 --- a/tests/run_agent/test_copilot_native_vision_headers.py +++ b/tests/run_agent/test_copilot_native_vision_headers.py @@ -3,12 +3,12 @@ from run_agent import AIAgent -def _make_copilot_agent(): +def _make_copilot_agent(base_url="https://api.githubcopilot.com"): with patch("run_agent.OpenAI") as mock_openai: mock_openai.return_value = MagicMock() agent = AIAgent( api_key="gh-token", - base_url="https://api.githubcopilot.com", + base_url=base_url, provider="copilot", model="gpt-5.4", quiet_mode=True, @@ -49,5 +49,43 @@ def fake_create(kwargs, *, reason, shared): assert headers["Copilot-Vision-Request"] == "true" +def test_enterprise_agent_client_has_default_copilot_headers(monkeypatch): + base_url = "https://copilot-api.ghe.example.com" + monkeypatch.setenv("COPILOT_API_BASE_URL", base_url) + + agent = _make_copilot_agent(base_url) + + assert agent._client_kwargs["default_headers"]["Copilot-Integration-Id"] == "vscode-chat" + + +def test_enterprise_request_client_adds_copilot_vision_header(monkeypatch): + base_url = "https://copilot-api.ghe.example.com" + monkeypatch.setenv("COPILOT_API_BASE_URL", base_url) + agent = _make_copilot_agent(base_url) + built_kwargs = [] + + def fake_create(kwargs, *, reason, shared): + built_kwargs.append(dict(kwargs)) + return MagicMock() + + api_kwargs = { + "model": "gpt-5.4", + "messages": [ + { + "role": "user", + "content": [ + {"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}}, + ], + } + ], + } + + agent.client = object() + with patch.object(agent, "_is_openai_client_closed", return_value=False), patch.object( + agent, "_create_openai_client", side_effect=fake_create + ): + agent._create_request_openai_client(reason="test", api_kwargs=api_kwargs) + + assert built_kwargs[-1]["default_headers"]["Copilot-Vision-Request"] == "true" diff --git a/tests/run_agent/test_run_agent_codex_responses.py b/tests/run_agent/test_run_agent_codex_responses.py index 4b11b1fdb7db9..c44651965a2ef 100644 --- a/tests/run_agent/test_run_agent_codex_responses.py +++ b/tests/run_agent/test_run_agent_codex_responses.py @@ -306,6 +306,26 @@ def test_build_api_kwargs_codex(monkeypatch): assert "extra_body" not in kwargs +def test_exchange_discovered_copilot_endpoint_uses_github_responses(monkeypatch): + monkeypatch.delenv("COPILOT_API_BASE_URL", raising=False) + agent = _build_copilot_agent(monkeypatch) + enterprise_base_url = "https://copilot-api.ghe.example.com" + agent.base_url = enterprise_base_url + agent._base_url_lower = enterprise_base_url + captured = {} + transport = agent._get_transport() + + def _capture_build_kwargs(**kwargs): + captured.update(kwargs) + return {} + + monkeypatch.setattr(transport, "build_kwargs", _capture_build_kwargs) + + agent._build_api_kwargs([{"role": "user", "content": "Ping"}]) + + assert captured["is_github_responses"] is True + + def test_build_api_kwargs_mantle_sets_extended_prompt_cache_retention(monkeypatch): _patch_agent_bootstrap(monkeypatch) agent = run_agent.AIAgent( @@ -1118,6 +1138,58 @@ def _fake_openai(**kwargs): assert isinstance(agent.client, _RebuiltClient) +def test_try_refresh_copilot_client_preserves_explicit_base_url(monkeypatch): + explicit_base_url = "https://copilot-api.ghe.example.com" + monkeypatch.setenv("COPILOT_API_BASE_URL", explicit_base_url) + agent = _build_copilot_agent(monkeypatch) + agent.base_url = explicit_base_url + agent._client_kwargs["base_url"] = explicit_base_url + + monkeypatch.setattr( + "hermes_cli.copilot_auth.resolve_copilot_token", + lambda: ("ghu_raw", "COPILOT_GITHUB_TOKEN"), + ) + monkeypatch.setattr( + "hermes_cli.copilot_auth.evict_cached_exchanged_token", + lambda _raw: None, + ) + monkeypatch.setattr( + "hermes_cli.copilot_auth.get_copilot_api_token", + lambda _raw: ("tid=fresh", "https://exchange-metadata.example.com"), + ) + monkeypatch.setattr(run_agent, "OpenAI", lambda **kwargs: object()) + + assert agent._try_refresh_copilot_client_credentials() is True + assert agent.base_url == explicit_base_url + assert agent._client_kwargs["base_url"] == explicit_base_url + + +def test_stale_copilot_recovery_preserves_explicit_base_url(monkeypatch): + explicit_base_url = "https://copilot-api.ghe.example.com" + monkeypatch.setenv("COPILOT_API_BASE_URL", explicit_base_url) + agent = _build_copilot_agent(monkeypatch) + agent.base_url = explicit_base_url + agent._client_kwargs["base_url"] = explicit_base_url + + monkeypatch.setattr( + "hermes_cli.copilot_auth.resolve_copilot_token", + lambda: ("ghu_raw", "COPILOT_GITHUB_TOKEN"), + ) + monkeypatch.setattr( + "hermes_cli.copilot_auth.evict_cached_exchanged_token", + lambda _raw: None, + ) + monkeypatch.setattr( + "hermes_cli.copilot_auth.get_copilot_api_token", + lambda _raw: ("tid=fresh", "https://exchange-metadata.example.com"), + ) + monkeypatch.setattr(run_agent, "OpenAI", lambda **kwargs: object()) + + assert agent._try_recover_stale_copilot_credential() is True + assert agent.base_url == explicit_base_url + assert agent._client_kwargs["base_url"] == explicit_base_url + + def test_try_refresh_copilot_client_credentials_rebuilds_even_if_token_unchanged(monkeypatch): agent = _build_copilot_agent(monkeypatch) rebuilt = {"count": 0} @@ -1991,5 +2063,3 @@ def test_duplicate_detection_uses_commentary_when_hidden_reasoning_changes(monke - - diff --git a/tests/test_copilot_initiator.py b/tests/test_copilot_initiator.py index f6cafb198bc73..3f0d5016be9b7 100644 --- a/tests/test_copilot_initiator.py +++ b/tests/test_copilot_initiator.py @@ -33,7 +33,7 @@ def close(self): pass -def _make_agent(monkeypatch, base_url, api_mode="chat_completions"): +def _make_agent(monkeypatch, base_url, api_mode="chat_completions", provider=None): """Create an AIAgent pointing at the given base_url.""" monkeypatch.setattr("run_agent.get_tool_definitions", lambda **kw: _tool_defs("web_search")) monkeypatch.setattr("run_agent.check_toolset_requirements", lambda: {}) @@ -41,7 +41,7 @@ def _make_agent(monkeypatch, base_url, api_mode="chat_completions"): return AIAgent( api_key="test-key", base_url=base_url, - provider="copilot" if "githubcopilot" in base_url else "openrouter", + provider=provider or ("copilot" if "githubcopilot" in base_url else "openrouter"), api_mode=api_mode, max_iterations=4, quiet_mode=True, @@ -80,6 +80,13 @@ def test_case_insensitive(self, monkeypatch): agent = _make_agent(monkeypatch, "https://API.GITHUBCOPILOT.COM") assert agent._is_copilot_url() is True + def test_configured_enterprise_endpoint(self, monkeypatch): + base_url = "https://copilot-api.ghe.example.com" + monkeypatch.setenv("COPILOT_API_BASE_URL", base_url) + agent = _make_agent(monkeypatch, base_url, provider="copilot") + assert agent._is_copilot_url() is True + assert agent._is_github_copilot_url() is True + class TestUserInitiatedTurnFlag: """_is_user_initiated_turn lifecycle.""" diff --git a/website/docs/integrations/providers.md b/website/docs/integrations/providers.md index 169c6ff624821..8050e850b60c2 100644 --- a/website/docs/integrations/providers.md +++ b/website/docs/integrations/providers.md @@ -177,6 +177,20 @@ 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. ::: +#### GitHub Enterprise Server + +For a GitHub Enterprise Server instance, configure its GitHub host and +Copilot inference endpoint: + +```bash +COPILOT_GH_HOST=ghe.example.com +COPILOT_API_BASE_URL=https://copilot-api.ghe.example.com +``` + +`COPILOT_GH_HOST` controls OAuth, `gh auth token --hostname`, and Copilot +token exchange. `COPILOT_API_BASE_URL` controls inference and model-catalog +routing. Public GitHub remains the default when these variables are unset. + :::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`). @@ -208,6 +222,8 @@ model: | Environment variable | Description | |---------------------|-------------| | `COPILOT_GITHUB_TOKEN` | GitHub token for Copilot API (first priority) | +| `COPILOT_GH_HOST` | GitHub host for Enterprise OAuth, CLI auth, and token exchange | +| `COPILOT_API_BASE_URL` | Copilot inference and model-catalog base URL | | `HERMES_COPILOT_ACP_COMMAND` | Override the Copilot CLI binary path (default: `copilot`) | | `HERMES_COPILOT_ACP_ARGS` | Override ACP args (default: `--acp --stdio`) | diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 39f6a057ebfc3..5642c7c230fb5 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -33,6 +33,7 @@ Hermes reads environment variables from the process environment and, for user-ma | `HERMES_COPILOT_ACP_ARGS` | Override Copilot ACP arguments (default: `--acp --stdio`) | | `COPILOT_ACP_BASE_URL` | Override Copilot ACP base URL | | `COPILOT_API_BASE_URL` | Override the Copilot API base URL (`copilot` provider) | +| `COPILOT_GH_HOST` | GitHub Enterprise host for Copilot OAuth, CLI token lookup, and token exchange | | `GLM_API_KEY` | z.ai / ZhipuAI GLM API key ([z.ai](https://z.ai)) | | `ZAI_API_KEY` | Alias for `GLM_API_KEY` | | `Z_AI_API_KEY` | Alias for `GLM_API_KEY` |