mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-05 07:37:50 +00:00
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:
parent
6b8788eb27
commit
74783d38ea
4 changed files with 141 additions and 9 deletions
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue