fix(github_copilot): let per-user connections work with a custom GitHub client ID (#50116)

GitHub only issues Copilot tokens through copilot_internal/v2/token to its own
Copilot apps, so a custom GITHUB_COPILOT_CLIENT_ID signed users in and then
failed with no Copilot access. With a custom client ID, look up the account at
copilot_internal/user for the seat and Copilot API address and send the user's
GitHub token straight to Copilot. The default client ID keeps the token swap.
This commit is contained in:
ryan-crabbe-berri 2026-11-04 15:09:17 -08:00 • committed by GitHub
parent 6b8788eb27
commit 74783d38ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 141 additions and 9 deletions

View file

@ -21,8 +21,10 @@ class GithubHostEndpoints:
device_code_url: str
access_token_url: str
api_key_url: str
copilot_user_url: str
user_url: str
client_id: str
exchanges_copilot_token: bool
copilot_api_hosts: frozenset[str]
copilot_api_host_suffix: str | None
default_copilot_api_base: str
@ -49,14 +51,17 @@ def github_host_endpoints(host: str) -> GithubHostEndpoints:
if normalized is None:
raise ValueError("host must be github.com or a single-subdomain GHE.com hostname")
client_id: Final = os.getenv("GITHUB_COPILOT_CLIENT_ID", DEFAULT_GITHUB_CLIENT_ID)
exchanges_copilot_token: Final = client_id == DEFAULT_GITHUB_CLIENT_ID
if normalized == GITHUB_COPILOT_DEFAULT_HOST:
return GithubHostEndpoints(
host=normalized,
device_code_url=os.getenv("GITHUB_COPILOT_DEVICE_CODE_URL", DEFAULT_GITHUB_DEVICE_CODE_URL),
access_token_url=os.getenv("GITHUB_COPILOT_ACCESS_TOKEN_URL", DEFAULT_GITHUB_ACCESS_TOKEN_URL),
api_key_url=os.getenv("GITHUB_COPILOT_API_KEY_URL", DEFAULT_GITHUB_API_KEY_URL),
copilot_user_url="https://api.github.com/copilot_internal/user",
user_url="https://api.github.com/user",
client_id=client_id,
exchanges_copilot_token=exchanges_copilot_token,
copilot_api_hosts=frozenset({"githubcopilot.com"}),
copilot_api_host_suffix=".githubcopilot.com",
default_copilot_api_base="https://api.githubcopilot.com",
@ -70,8 +75,10 @@ def github_host_endpoints(host: str) -> GithubHostEndpoints:
device_code_url=f"https://{normalized}/login/device/code",
access_token_url=f"https://{normalized}/login/oauth/access_token",
api_key_url=f"https://{api_host}/copilot_internal/v2/token",
copilot_user_url=f"https://{api_host}/copilot_internal/user",
user_url=f"https://{api_host}/user",
client_id=client_id,
exchanges_copilot_token=exchanges_copilot_token,
copilot_api_hosts=frozenset({copilot_api_host}),
copilot_api_host_suffix=None,
default_copilot_api_base=f"https://{copilot_api_host}",

View file

@ -46,6 +46,7 @@ _LLM_PROVIDER: Final = "github_copilot"
_DEVICE_FLOW_SCOPE: Final = "read:user"
_SESSION_CACHE_MAX_SIZE: Final = 1000
_SYNC_LOCK_COUNT: Final = 64
_ACCOUNT_LOOKUP_TTL_SECONDS: Final = 1800
class _SessionCache(Protocol):
@ -125,12 +126,20 @@ class _CopilotEndpointsPayload(TypedDict):
api: ReadOnly[NotRequired[object]]
@with_config(ConfigDict(extra="allow", strict=True))
class _CopilotAccountPayload(TypedDict):
access_type_sku: ReadOnly[NotRequired[object]]
chat_enabled: ReadOnly[NotRequired[object]]
endpoints: ReadOnly[NotRequired[object]]
@with_config(ConfigDict(extra="allow", strict=True))
class _GithubUserPayload(TypedDict):
login: ReadOnly[str]
_COPILOT_TOKEN: Final = TypeAdapter(_CopilotTokenPayload)
_COPILOT_ACCOUNT: Final = TypeAdapter(_CopilotAccountPayload)
_DEVICE_CODE: Final = TypeAdapter(_DeviceCodePayload)
_ACCESS_TOKEN_POLL: Final = TypeAdapter(_AccessTokenPollPayload)
_GITHUB_USER: Final = TypeAdapter(_GithubUserPayload)
@ -273,6 +282,8 @@ def _session_from_response(
llm_provider=_LLM_PROVIDER,
model="",
)
if not endpoints.exchanges_copilot_token:
return _session_from_account(response, github_token, credential_name, cache_key, endpoints)
try:
payload: Final = _COPILOT_TOKEN.validate_python(response.json())
except Exception as e:
@ -282,13 +293,7 @@ def _session_from_response(
model="",
) from e
payload_endpoints: Final[object] = payload.get("endpoints")
endpoints_api: Final[object] = (
_COPILOT_ENDPOINTS.validate_python(payload_endpoints).get("api")
if isinstance(payload_endpoints, Mapping)
else None
)
api_base: Final = validated_copilot_api_base(endpoints_api, endpoints)
api_base: Final = validated_copilot_api_base(_endpoints_api(payload.get("endpoints")), endpoints)
session: Final = GithubCopilotUserSession(token=payload["token"], api_base=api_base)
expires_at: Final = _expires_at_seconds(payload.get("expires_at"))
if expires_at > 0:
@ -296,6 +301,41 @@ def _session_from_response(
return session
def _session_from_account(
response: httpx.Response,
github_token: str,
credential_name: str,
cache_key: str,
endpoints: GithubHostEndpoints,
) -> GithubCopilotUserSession:
try:
payload: Final = _COPILOT_ACCOUNT.validate_python(response.json())
except Exception as e:
raise ServiceUnavailableError(
message="GitHub Copilot account lookup returned an invalid response",
llm_provider=_LLM_PROVIDER,
model="",
) from e
if payload.get("chat_enabled") is False or payload.get("access_type_sku") == "no_access":
_SESSION_CACHE.delete_cache(cache_key)
raise _reject_connection(credential_name)
api_base: Final = validated_copilot_api_base(_endpoints_api(payload.get("endpoints")), endpoints)
session: Final = GithubCopilotUserSession(token=github_token, api_base=api_base)
_cache_session(cache_key, github_token, session, time.time() + _ACCOUNT_LOOKUP_TTL_SECONDS)
return session
def _endpoints_api(payload_endpoints: object) -> object:
if not isinstance(payload_endpoints, Mapping):
return None
return _COPILOT_ENDPOINTS.validate_python(payload_endpoints).get("api")
def _session_lookup_url(endpoints: GithubHostEndpoints) -> str:
return endpoints.api_key_url if endpoints.exchanges_copilot_token else endpoints.copilot_user_url
def _to_int(raw: object) -> int:
if isinstance(raw, bool):
return 0
@ -337,7 +377,7 @@ def _do_exchange(
_SyncGetClient, litellm.module_level_client
)
response: Final = client.get(
endpoints.api_key_url,
_session_lookup_url(endpoints),
headers=github_api_headers(github_token),
)
except Exception as e:
@ -367,7 +407,7 @@ async def _do_aexchange(
client: Final = _new_async_github_client()
try:
response: Final = await client.get(
endpoints.api_key_url,
_session_lookup_url(endpoints),
headers=github_api_headers(github_token),
)
except Exception as e:

View file

@ -54,6 +54,7 @@ def test_ghe_endpoints_ignore_github_com_url_overrides(monkeypatch: pytest.Monke
assert endpoints.device_code_url == "https://octo.ghe.com/login/device/code"
assert endpoints.access_token_url == "https://octo.ghe.com/login/oauth/access_token"
assert endpoints.api_key_url == "https://api.octo.ghe.com/copilot_internal/v2/token"
assert endpoints.copilot_user_url == "https://api.octo.ghe.com/copilot_internal/user"
assert endpoints.user_url == "https://api.octo.ghe.com/user"
assert endpoints.default_copilot_api_base == "https://copilot-api.octo.ghe.com"
assert endpoints.client_id == "custom-client"

View file

@ -307,6 +307,71 @@ async def test_aexchange_401_raises_and_evicts():
)
ACCOUNT_URL = "https://api.github.com/copilot_internal/user"
_CUSTOM_CLIENT_ID: Final = "Ov23liCustomOrgApp"
_ACCOUNT_API_BASE: Final = "https://api.business.githubcopilot.com"
def _account_payload(access_type_sku="copilot_enterprise_seat", chat_enabled=True, api_base=_ACCOUNT_API_BASE):
return {"access_type_sku": access_type_sku, "chat_enabled": chat_enabled, "endpoints": {"api": api_base}}
def _account_responder(payload=None, status=200, model_payload=None):
def respond(request: httpx.Request) -> httpx.Response:
if str(request.url) == ACCOUNT_URL:
return httpx.Response(status, json=payload or _account_payload(), request=request)
if model_payload is not None and request.url.host.endswith("githubcopilot.com"):
return httpx.Response(200, json=model_payload, request=request)
pytest.fail(f"unexpected request to {request.url}")
return respond
@pytest.fixture
def custom_client_id(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("GITHUB_COPILOT_CLIENT_ID", _CUSTOM_CLIENT_ID)
@pytest.mark.asyncio
async def test_custom_client_id_uses_the_github_token_without_the_copilot_token_swap(custom_client_id):
endpoints: Final = github_host_endpoints("github.com")
with github_http(_account_responder()) as requests:
first = await aexchange_github_token("user-a", "gho_org_app", "copilot-cred", endpoints)
second = await aexchange_github_token("user-a", "gho_org_app", "copilot-cred", endpoints)
assert [str(r.url) for r in requests] == [ACCOUNT_URL]
assert requests[0].headers["Authorization"] == "token gho_org_app"
assert first.token == second.token == "gho_org_app"
assert first.api_base == _ACCOUNT_API_BASE
@pytest.mark.parametrize(
"payload",
[
_account_payload(access_type_sku="no_access", chat_enabled=False),
_account_payload(chat_enabled=False),
_account_payload(access_type_sku="no_access"),
],
)
def test_custom_client_id_without_a_copilot_seat_raises_reconnect(custom_client_id, payload):
with github_http(_account_responder(payload)):
with pytest.raises(CallerCredentialAuthenticationError):
exchange_github_token("user-a", "gho_org_app", "copilot-cred", github_host_endpoints("github.com"))
@pytest.mark.parametrize("status", [401, 403, 404])
def test_custom_client_id_rejected_account_lookup_raises_reconnect(custom_client_id, status):
with github_http(_account_responder(status=status)):
with pytest.raises(CallerCredentialAuthenticationError):
exchange_github_token("user-a", "gho_org_app", "copilot-cred", github_host_endpoints("github.com"))
def test_custom_client_id_account_api_base_must_stay_on_copilot_hosts(custom_client_id):
with github_http(_account_responder(_account_payload(api_base="https://evil.example"))):
session = exchange_github_token("user-a", "gho_org_app", "copilot-cred", github_host_endpoints("github.com"))
assert session.api_base == "https://api.githubcopilot.com"
def _kwargs_with_connection(
token: str = "gho_user_token",
github_host: str | None = "github.com",
@ -740,6 +805,25 @@ def test_e2e_embedding_per_user_caller_authorization_cannot_override_session(per
_assert_session_token_on_the_wire(requests)
def test_e2e_custom_client_id_sends_the_github_token_to_the_accounts_copilot_host(
per_user_credential, custom_client_id
):
with (
github_http(_account_responder(model_payload=_embedding_response_payload())) as requests,
_no_authenticator(),
):
litellm.embedding(
model="github_copilot/text-embedding-3-small",
input=["hi"],
litellm_credential_name="copilot-cred",
secret_fields=_secret_fields("user-a", "gho_org_app"),
)
assert EXCHANGE_URL not in [str(r.url) for r in requests]
upstream = [r for r in requests if r.url.host.endswith("githubcopilot.com")]
assert upstream[0].url.host == httpx.URL(_ACCOUNT_API_BASE).host
assert upstream[0].headers["authorization"] == "Bearer gho_org_app"
def test_e2e_responses_per_user_caller_authorization_cannot_override_session(per_user_credential):
with (
github_http(_copilot_surface_responder(_responses_payload())) as requests,