From 15e44527ab947a25e09e4fdb8ccbc8cf686eb26b Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 30 Jun 2026 03:04:51 -0700 Subject: [PATCH] fix(copilot): prefer endpoints.api for base URL, guard empty chat base URL Folds @trevorgordon981's #50590 into difujia's #15139: - exchange_copilot_token now prefers the authoritative endpoints.api from the token-exchange response, falling back to the proxy-ep-derived host - resolve_api_key_provider_credentials gains a copilot branch that resolves the account-specific base URL and a non-empty last-resort guard, so chat inference never wedges on an empty base URL (#50252) Co-authored-by: Trevor Gordon --- hermes_cli/auth.py | 26 ++++++++++++++++++++++++++ hermes_cli/copilot_auth.py | 29 +++++++++++++++++++++-------- 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 8ed80a777a8..d53f2cbbfc3 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -6357,6 +6357,26 @@ def resolve_api_key_provider_credentials(provider_id: str) -> Dict[str, Any]: base_url = _resolve_kimi_base_url(api_key, pconfig.inference_base_url, env_url) elif provider_id == "zai": base_url = _resolve_zai_base_url(api_key, pconfig.inference_base_url, env_url) + elif provider_id == "copilot": + # Resolve the Copilot API base URL from the token-exchange response + # (endpoints.api, with a proxy-ep fallback), which is authoritative + # for Enterprise / proxied accounts. Falls back to the registry + # default and is guarded non-empty below so chat inference never + # resolves an empty base URL (#50252). + base_url = env_url.rstrip("/") if env_url else pconfig.inference_base_url + try: + from hermes_cli.copilot_auth import ( + resolve_copilot_token, + get_copilot_api_token, + ) + raw_token, _ = resolve_copilot_token() + if raw_token: + _, resolved = get_copilot_api_token(raw_token) + resolved = (resolved or "").strip() + if resolved: + base_url = resolved + except Exception as exc: + logger.debug("Copilot base URL resolution fell back to default: %s", exc) elif env_url: base_url = env_url.rstrip("/") else: @@ -6365,6 +6385,12 @@ def resolve_api_key_provider_credentials(provider_id: str) -> Dict[str, Any]: if provider_id == "lmstudio": base_url = _normalize_lmstudio_runtime_base_url(base_url) + # Last-resort guard: an API-key provider must never hand back an empty + # base URL (a set-but-empty COPILOT_API_BASE_URL or similar env override + # otherwise wedges chat inference — #50252). + if not (isinstance(base_url, str) and base_url.strip()): + base_url = pconfig.inference_base_url + return { "provider": provider_id, "api_key": api_key, diff --git a/hermes_cli/copilot_auth.py b/hermes_cli/copilot_auth.py index 69e472c8f85..1216254fea5 100644 --- a/hermes_cli/copilot_auth.py +++ b/hermes_cli/copilot_auth.py @@ -312,8 +312,10 @@ def exchange_copilot_token(raw_token: str, *, timeout: float = 10.0) -> tuple[st The returned token is a semicolon-separated string (not a standard JWT) used as ``Authorization: Bearer `` for Copilot API requests. - If the token contains a ``proxy-ep`` field (enterprise accounts), the - derived API base URL is returned; otherwise ``base_url`` is None. + ``base_url`` is the account-specific API host: the authoritative + ``endpoints.api`` advertised by the exchange (enterprise/proxied + accounts), falling back to a host derived from the token's ``proxy-ep`` + field. Individual accounts have neither, so ``base_url`` is None. Results are cached in-process and reused until close to expiry. Raises ``ValueError`` on failure. @@ -354,10 +356,20 @@ def exchange_copilot_token(raw_token: str, *, timeout: float = 10.0) -> tuple[st # Convert expires_at to float if needed expires_at = float(expires_at) if expires_at else time.time() + 1800 - # Derive enterprise base URL from proxy-ep in the token. - # The token is semicolon-separated: "tid=xxx;exp=xxx;proxy-ep=proxy.enterprise.githubcopilot.com;..." - # Replace leading "proxy." with "api." to get the API base URL. - base_url = _derive_base_url_from_proxy_ep(api_token) + # Resolve the account-specific API base URL. GitHub advertises the + # authoritative endpoint under ``endpoints.api`` in the exchange response + # (it differs for Copilot Enterprise / proxied accounts). When the + # response omits it, fall back to deriving the host from the ``proxy-ep`` + # field embedded in the exchanged token. Individual accounts have neither, + # so ``base_url`` stays None and callers use the registry default. + base_url: Optional[str] = None + endpoints = data.get("endpoints") + if isinstance(endpoints, dict): + api_endpoint = str(endpoints.get("api") or "").strip().rstrip("/") + if api_endpoint: + base_url = api_endpoint + if not base_url: + base_url = _derive_base_url_from_proxy_ep(api_token) _jwt_cache[fp] = (api_token, expires_at, base_url) logger.debug( @@ -408,8 +420,9 @@ def get_copilot_api_token(raw_token: str) -> tuple[str, Optional[str]]: account type). This preserves existing behaviour for accounts that don't need exchange while enabling access to internal-only models for those that do. - ``base_url`` is the enterprise API endpoint derived from the token's - ``proxy-ep`` field, or None for individual accounts. + ``base_url`` is the account-specific API endpoint advertised by the + exchange (``endpoints.api``, with a ``proxy-ep`` fallback), or None for + individual accounts. """ if not raw_token: return raw_token, None