mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-01 12:02:05 +00:00
fix(copilot): switch to VS Code client ID and derive enterprise base URL
Two changes that complete the Copilot auth story (#7731 parts 3 and 4): 1. Switch OAuth client ID from opencode (Ov23li8tweQw6odWQebz) to VS Code (Iv1.b507a08c87ecfe98). The old ID produces gho_* tokens that return 404 on /copilot_internal/v2/token, making token exchange non-functional. The new ID produces ghu_* tokens that support exchange. 2. Derive enterprise API base URL from the proxy-ep field in the exchanged token. Enterprise accounts get tokens containing e.g. "proxy-ep=proxy.enterprise.githubcopilot.com" which is converted to "https://api.enterprise.githubcopilot.com" and stored in the credential pool. Individual accounts (no proxy-ep) continue using the default URL. The COPILOT_API_BASE_URL env var remains as a user escape hatch. Tested on both Individual and Enterprise Copilot accounts: - Individual: device flow works, exchange succeeds, base_url=None (default) - Enterprise: device flow works, exchange succeeds, 39 models returned including claude-opus-4.6-1m (936K), enterprise base URL derived Parts 3 and 4 of #7731.
This commit is contained in:
parent
bf2dc18f84
commit
fbd15e285c
4 changed files with 153 additions and 29 deletions
|
|
@ -1884,11 +1884,16 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
|
|||
from hermes_cli.copilot_auth import resolve_copilot_token, get_copilot_api_token
|
||||
token, source = resolve_copilot_token()
|
||||
if token:
|
||||
api_token = get_copilot_api_token(token)
|
||||
api_token, enterprise_base_url = get_copilot_api_token(token)
|
||||
source_name = "gh_cli" if "gh" in source.lower() else f"env:{source}"
|
||||
if not _is_suppressed(provider, source_name):
|
||||
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 ""
|
||||
)
|
||||
changed |= _upsert_entry(
|
||||
entries,
|
||||
provider,
|
||||
|
|
@ -1897,7 +1902,7 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
|
|||
"source": source_name,
|
||||
"auth_type": AUTH_TYPE_API_KEY,
|
||||
"access_token": api_token,
|
||||
"base_url": pconfig.inference_base_url if pconfig else "",
|
||||
"base_url": effective_base_url,
|
||||
"label": source,
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -568,7 +568,8 @@ def _resolve_api_key_provider_secret(
|
|||
from hermes_cli.copilot_auth import resolve_copilot_token, get_copilot_api_token
|
||||
token, source = resolve_copilot_token()
|
||||
if token:
|
||||
return get_copilot_api_token(token), source
|
||||
api_token, _base_url = get_copilot_api_token(token)
|
||||
return api_token, source
|
||||
except ValueError as exc:
|
||||
logger.warning("Copilot token validation failed: %s", exc)
|
||||
except Exception:
|
||||
|
|
|
|||
|
|
@ -31,8 +31,14 @@ from hermes_cli._subprocess_compat import IS_WINDOWS, windows_hide_flags
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# OAuth device code flow constants (same client ID as opencode/Copilot CLI)
|
||||
COPILOT_OAUTH_CLIENT_ID = "Ov23li8tweQw6odWQebz"
|
||||
# OAuth device code flow constants — VS Code's GitHub App client ID.
|
||||
# The previous opencode OAuth App ID (Ov23li8tweQw6odWQebz) produces gho_*
|
||||
# tokens that cannot be exchanged for Copilot API JWTs (404 on
|
||||
# /copilot_internal/v2/token). VS Code's App ID produces ghu_* tokens
|
||||
# that support exchange, which is required to access internal-only models
|
||||
# (e.g. claude-opus-4.6-1m) and enterprise endpoints.
|
||||
# Tested on Individual and Enterprise accounts.
|
||||
COPILOT_OAUTH_CLIENT_ID = "Iv1.b507a08c87ecfe98"
|
||||
# Token type prefixes
|
||||
_CLASSIC_PAT_PREFIX = "ghp_"
|
||||
_SUPPORTED_PREFIXES = ("gho_", "github_pat_", "ghu_")
|
||||
|
|
@ -282,8 +288,8 @@ def copilot_device_code_login(
|
|||
# ─── Copilot Token Exchange ────────────────────────────────────────────────
|
||||
|
||||
# Module-level cache for exchanged Copilot API tokens.
|
||||
# Maps raw_token_fingerprint -> (api_token, expires_at_epoch).
|
||||
_jwt_cache: dict[str, tuple[str, float]] = {}
|
||||
# Maps raw_token_fingerprint -> (api_token, expires_at_epoch, base_url).
|
||||
_jwt_cache: dict[str, tuple[str, float, Optional[str]]] = {}
|
||||
_JWT_REFRESH_MARGIN_SECONDS = 120 # refresh 2 min before expiry
|
||||
|
||||
# Token exchange endpoint and headers (matching VS Code / Copilot CLI)
|
||||
|
|
@ -298,14 +304,16 @@ def _token_fingerprint(raw_token: str) -> str:
|
|||
return hashlib.sha256(raw_token.encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
def exchange_copilot_token(raw_token: str, *, timeout: float = 10.0) -> tuple[str, float]:
|
||||
def exchange_copilot_token(raw_token: str, *, timeout: float = 10.0) -> tuple[str, float, Optional[str]]:
|
||||
"""Exchange a raw GitHub token for a short-lived Copilot API token.
|
||||
|
||||
Calls ``GET https://api.github.com/copilot_internal/v2/token`` with
|
||||
the raw GitHub token and returns ``(api_token, expires_at)``.
|
||||
the raw GitHub token and returns ``(api_token, expires_at, base_url)``.
|
||||
|
||||
The returned token is a semicolon-separated string (not a standard JWT)
|
||||
used as ``Authorization: Bearer <token>`` 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.
|
||||
|
||||
Results are cached in-process and reused until close to expiry.
|
||||
Raises ``ValueError`` on failure.
|
||||
|
|
@ -317,9 +325,9 @@ def exchange_copilot_token(raw_token: str, *, timeout: float = 10.0) -> tuple[st
|
|||
# Check cache first
|
||||
cached = _jwt_cache.get(fp)
|
||||
if cached:
|
||||
api_token, expires_at = cached
|
||||
api_token, expires_at, base_url = cached
|
||||
if time.time() < expires_at - _JWT_REFRESH_MARGIN_SECONDS:
|
||||
return api_token, expires_at
|
||||
return api_token, expires_at, base_url
|
||||
|
||||
req = urllib.request.Request(
|
||||
_TOKEN_EXCHANGE_URL,
|
||||
|
|
@ -346,30 +354,71 @@ 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
|
||||
|
||||
_jwt_cache[fp] = (api_token, expires_at)
|
||||
# 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)
|
||||
|
||||
_jwt_cache[fp] = (api_token, expires_at, base_url)
|
||||
logger.debug(
|
||||
"Copilot token exchanged, expires_at=%s",
|
||||
"Copilot token exchanged, expires_at=%s, base_url=%s",
|
||||
expires_at,
|
||||
base_url,
|
||||
)
|
||||
return api_token, expires_at
|
||||
return api_token, expires_at, base_url
|
||||
|
||||
|
||||
def get_copilot_api_token(raw_token: str) -> str:
|
||||
def _derive_base_url_from_proxy_ep(token: str) -> Optional[str]:
|
||||
"""Derive the Copilot API base URL from a proxy-ep field in the token.
|
||||
|
||||
The exchanged Copilot token is a semicolon-separated string like
|
||||
``tid=xxx;exp=xxx;proxy-ep=proxy.enterprise.githubcopilot.com;...``.
|
||||
This extracts ``proxy-ep`` and converts it to an API base URL by
|
||||
replacing the leading ``proxy.`` with ``api.``.
|
||||
|
||||
Returns ``https://{api_hostname}`` or None if proxy-ep is absent.
|
||||
"""
|
||||
import re
|
||||
m = re.search(r'(?:^|;)\s*proxy-ep=([^;\s]+)', token)
|
||||
if not m:
|
||||
return None
|
||||
|
||||
proxy_ep = m.group(1)
|
||||
# Strip scheme if present
|
||||
for prefix in ("https://", "http://"):
|
||||
if proxy_ep.startswith(prefix):
|
||||
proxy_ep = proxy_ep[len(prefix):]
|
||||
break
|
||||
proxy_ep = proxy_ep.rstrip("/")
|
||||
|
||||
# Replace leading "proxy." with "api."
|
||||
if proxy_ep.startswith("proxy."):
|
||||
api_host = "api." + proxy_ep[len("proxy."):]
|
||||
else:
|
||||
api_host = proxy_ep
|
||||
|
||||
return f"https://{api_host}"
|
||||
|
||||
|
||||
def get_copilot_api_token(raw_token: str) -> tuple[str, Optional[str]]:
|
||||
"""Exchange a raw GitHub token for a Copilot API token, with fallback.
|
||||
|
||||
Convenience wrapper: returns the exchanged token on success, or the
|
||||
raw token unchanged if the exchange fails (e.g. network error, unsupported
|
||||
Convenience wrapper: returns ``(api_token, base_url)`` on success, or
|
||||
``(raw_token, None)`` if the exchange fails (e.g. network error, unsupported
|
||||
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.
|
||||
"""
|
||||
if not raw_token:
|
||||
return raw_token
|
||||
return raw_token, None
|
||||
try:
|
||||
api_token, _ = exchange_copilot_token(raw_token)
|
||||
return api_token
|
||||
api_token, _, base_url = exchange_copilot_token(raw_token)
|
||||
return api_token, base_url
|
||||
except Exception as exc:
|
||||
logger.debug("Copilot token exchange failed, using raw token: %s", exc)
|
||||
return raw_token
|
||||
return raw_token, None
|
||||
|
||||
|
||||
# ─── Copilot API Headers ───────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -37,10 +37,11 @@ class TestExchangeCopilotToken:
|
|||
from hermes_cli.copilot_auth import exchange_copilot_token
|
||||
|
||||
mock_urlopen.return_value = self._mock_urlopen(token="tid=abc;exp=999")
|
||||
api_token, expires_at = exchange_copilot_token("gho_test123")
|
||||
api_token, expires_at, base_url = exchange_copilot_token("gho_test123")
|
||||
|
||||
assert api_token == "tid=abc;exp=999"
|
||||
assert isinstance(expires_at, float)
|
||||
assert base_url is None # no proxy-ep in this token
|
||||
|
||||
# Verify request was made with correct headers
|
||||
call_args = mock_urlopen.call_args
|
||||
|
|
@ -66,12 +67,12 @@ class TestExchangeCopilotToken:
|
|||
|
||||
# Seed cache with expired entry
|
||||
fp = _token_fingerprint("gho_test123")
|
||||
_jwt_cache[fp] = ("old_token", time.time() - 10)
|
||||
_jwt_cache[fp] = ("old_token", time.time() - 10, None)
|
||||
|
||||
mock_urlopen.return_value = self._mock_urlopen(
|
||||
token="new_token", expires_at=time.time() + 1800
|
||||
)
|
||||
api_token, _ = exchange_copilot_token("gho_test123")
|
||||
api_token, _, _ = exchange_copilot_token("gho_test123")
|
||||
|
||||
assert api_token == "new_token"
|
||||
assert mock_urlopen.call_count == 1
|
||||
|
|
@ -105,19 +106,25 @@ class TestGetCopilotApiToken:
|
|||
def test_returns_exchanged_token(self, mock_exchange):
|
||||
from hermes_cli.copilot_auth import get_copilot_api_token
|
||||
|
||||
mock_exchange.return_value = ("exchanged_jwt", time.time() + 1800)
|
||||
assert get_copilot_api_token("gho_raw") == "exchanged_jwt"
|
||||
mock_exchange.return_value = ("exchanged_jwt", time.time() + 1800, None)
|
||||
api_token, base_url = get_copilot_api_token("gho_raw")
|
||||
assert api_token == "exchanged_jwt"
|
||||
assert base_url is None
|
||||
|
||||
@patch("hermes_cli.copilot_auth.exchange_copilot_token", side_effect=ValueError("fail"))
|
||||
def test_falls_back_to_raw_token(self, mock_exchange):
|
||||
from hermes_cli.copilot_auth import get_copilot_api_token
|
||||
|
||||
assert get_copilot_api_token("gho_raw") == "gho_raw"
|
||||
api_token, base_url = get_copilot_api_token("gho_raw")
|
||||
assert api_token == "gho_raw"
|
||||
assert base_url is None
|
||||
|
||||
def test_empty_token_passthrough(self):
|
||||
from hermes_cli.copilot_auth import get_copilot_api_token
|
||||
|
||||
assert get_copilot_api_token("") == ""
|
||||
api_token, base_url = get_copilot_api_token("")
|
||||
assert api_token == ""
|
||||
assert base_url is None
|
||||
|
||||
|
||||
class TestTokenFingerprint:
|
||||
|
|
@ -147,7 +154,7 @@ class TestCallerIntegration:
|
|||
"""Test that callers correctly use token exchange."""
|
||||
|
||||
@patch("hermes_cli.copilot_auth.resolve_copilot_token", return_value=("gho_raw", "GH_TOKEN"))
|
||||
@patch("hermes_cli.copilot_auth.get_copilot_api_token", return_value="exchanged_jwt")
|
||||
@patch("hermes_cli.copilot_auth.get_copilot_api_token", return_value=("exchanged_jwt", None))
|
||||
def test_auth_resolve_uses_exchange(self, mock_exchange, mock_resolve):
|
||||
from hermes_cli.auth import _resolve_api_key_provider_secret
|
||||
|
||||
|
|
@ -157,3 +164,65 @@ class TestCallerIntegration:
|
|||
assert token == "exchanged_jwt"
|
||||
assert source == "GH_TOKEN"
|
||||
mock_exchange.assert_called_once_with("gho_raw")
|
||||
|
||||
|
||||
class TestDeriveBaseUrlFromProxyEp:
|
||||
"""Tests for _derive_base_url_from_proxy_ep()."""
|
||||
|
||||
def test_extracts_enterprise_url(self):
|
||||
from hermes_cli.copilot_auth import _derive_base_url_from_proxy_ep
|
||||
|
||||
token = "tid=abc;exp=999;proxy-ep=proxy.enterprise.githubcopilot.com;sku=copilot_enterprise"
|
||||
assert _derive_base_url_from_proxy_ep(token) == "https://api.enterprise.githubcopilot.com"
|
||||
|
||||
def test_returns_none_without_proxy_ep(self):
|
||||
from hermes_cli.copilot_auth import _derive_base_url_from_proxy_ep
|
||||
|
||||
token = "tid=abc;exp=999;sku=copilot_individual"
|
||||
assert _derive_base_url_from_proxy_ep(token) is None
|
||||
|
||||
def test_handles_https_prefix(self):
|
||||
from hermes_cli.copilot_auth import _derive_base_url_from_proxy_ep
|
||||
|
||||
token = "proxy-ep=https://proxy.enterprise.githubcopilot.com/"
|
||||
assert _derive_base_url_from_proxy_ep(token) == "https://api.enterprise.githubcopilot.com"
|
||||
|
||||
def test_no_proxy_prefix(self):
|
||||
from hermes_cli.copilot_auth import _derive_base_url_from_proxy_ep
|
||||
|
||||
token = "proxy-ep=custom.copilot.example.com"
|
||||
assert _derive_base_url_from_proxy_ep(token) == "https://custom.copilot.example.com"
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_exchange_returns_enterprise_base_url(self, mock_urlopen, _clear_jwt_cache):
|
||||
"""exchange_copilot_token returns base_url from proxy-ep."""
|
||||
from hermes_cli.copilot_auth import exchange_copilot_token
|
||||
|
||||
token_with_ep = "tid=abc;exp=999;proxy-ep=proxy.enterprise.githubcopilot.com"
|
||||
expires_at = time.time() + 1800
|
||||
resp_data = json.dumps({"token": token_with_ep, "expires_at": expires_at}).encode()
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = resp_data
|
||||
mock_resp.__enter__ = MagicMock(return_value=mock_resp)
|
||||
mock_resp.__exit__ = MagicMock(return_value=False)
|
||||
mock_urlopen.return_value = mock_resp
|
||||
|
||||
api_token, _, base_url = exchange_copilot_token("gho_test")
|
||||
assert base_url == "https://api.enterprise.githubcopilot.com"
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_exchange_returns_none_base_url_for_individual(self, mock_urlopen, _clear_jwt_cache):
|
||||
"""exchange_copilot_token returns None base_url for individual accounts."""
|
||||
from hermes_cli.copilot_auth import exchange_copilot_token
|
||||
|
||||
token_no_ep = "tid=abc;exp=999;sku=copilot_individual"
|
||||
expires_at = time.time() + 1800
|
||||
resp_data = json.dumps({"token": token_no_ep, "expires_at": expires_at}).encode()
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = resp_data
|
||||
mock_resp.__enter__ = MagicMock(return_value=mock_resp)
|
||||
mock_resp.__exit__ = MagicMock(return_value=False)
|
||||
mock_urlopen.return_value = mock_resp
|
||||
|
||||
api_token, _, base_url = exchange_copilot_token("gho_test")
|
||||
assert base_url is None
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue