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:
NiuNiu Xia 2026-04-24 12:51:11 +00:00 committed by Teknium
parent bf2dc18f84
commit fbd15e285c
4 changed files with 153 additions and 29 deletions

View file

@ -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