Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 28 additions & 4 deletions litellm/llms/github_copilot/authenticator.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import json
import os
import threading
import time
from collections.abc import Callable
from datetime import datetime
from typing import Any, Final

Expand All @@ -25,6 +27,16 @@
DEFAULT_GITHUB_DEVICE_CODE_URL: Final = "https://github.com/login/device/code"
DEFAULT_GITHUB_ACCESS_TOKEN_URL: Final = "https://github.com/login/oauth/access_token"
DEFAULT_GITHUB_API_KEY_URL: Final = "https://api.github.com/copilot_internal/v2/token"
_SHARED_LOGIN_ALLOWED: Final = threading.Event()
_SHARED_LOGIN_ALLOWED.set()
_SHARED_LOGIN_DISABLED_MESSAGE: Final = (
"GitHub Copilot shared device login is disabled on the LiteLLM proxy. Create an LLM credential with Auth Type "
"'Per-user GitHub OAuth' and reference it from the deployment with litellm_credential_name"
)


def disable_github_copilot_shared_login() -> None:
_SHARED_LOGIN_ALLOWED.clear()


@with_config(ConfigDict(extra="allow", strict=True))
Expand Down Expand Up @@ -62,8 +74,9 @@ def github_api_headers(


class Authenticator:
def __init__(self) -> None:
def __init__(self, shared_login_allowed: Callable[[], bool] = _SHARED_LOGIN_ALLOWED.is_set) -> None:
"""Initialize the GitHub Copilot authenticator with configurable token paths."""
self._shared_login_allowed = shared_login_allowed
# Token storage paths
self.token_dir = os.getenv(
"GITHUB_COPILOT_TOKEN_DIR",
Expand All @@ -74,7 +87,15 @@ def __init__(self) -> None:
os.getenv("GITHUB_COPILOT_ACCESS_TOKEN_FILE", "access-token"),
)
self.api_key_file = os.path.join(self.token_dir, os.getenv("GITHUB_COPILOT_API_KEY_FILE", "api-key.json"))
self._ensure_token_dir()
if self._shared_login_allowed():
self._ensure_token_dir()

def _raise_if_shared_login_disabled(self) -> None:
if not self.is_shared_login_allowed():
raise GetAPIKeyError(message=_SHARED_LOGIN_DISABLED_MESSAGE, status_code=401)

def is_shared_login_allowed(self) -> bool:
return self._shared_login_allowed()

def get_access_token(self) -> str:
"""
Expand All @@ -86,6 +107,7 @@ def get_access_token(self) -> str:
Raises:
GetAccessTokenError: If unable to obtain an access token after retries.
"""
self._raise_if_shared_login_disabled()
try:
with open(self.access_token_file, "r") as f:
access_token = f.read().strip()
Expand All @@ -100,8 +122,7 @@ def get_access_token(self) -> str:
"GitHub Copilot device-code login needs a human and cannot run inside a running event loop "
"or a worker thread (for example the LiteLLM proxy). Log in once outside the proxy with "
'`python -c "from litellm.llms.github_copilot.authenticator import Authenticator; '
'Authenticator().get_access_token()"` and mount the resulting access-token file into '
"the proxy, or set GITHUB_COPILOT_TOKEN_DIR to a directory that already holds it."
'Authenticator().get_access_token()"`.'
),
status_code=401,
)
Expand Down Expand Up @@ -135,6 +156,7 @@ def get_api_key(self) -> str:
Raises:
GetAPIKeyError: If unable to obtain an API key.
"""
self._raise_if_shared_login_disabled()
try:
with open(self.api_key_file, "r") as f:
api_key_info = json.load(f)
Expand Down Expand Up @@ -184,6 +206,8 @@ def get_api_base(self) -> str | None:
Returns:
Optional[str]: The GitHub Copilot API endpoint, or None if not found.
"""
if not self._shared_login_allowed():
return None
try:
with open(self.api_key_file, "r") as f:
api_key_info: Final = json.load(f)
Expand Down
14 changes: 10 additions & 4 deletions litellm/llms/github_copilot/chat/transformation.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ def _get_openai_compatible_provider_info(
custom_llm_provider: str,
) -> tuple[str | None, str | None, str]:
dynamic_api_base: Final = self.api_base_without_login(api_base)
if not self.authenticator.is_shared_login_allowed():
return dynamic_api_base, api_key, custom_llm_provider
try:
dynamic_api_key: Final = self.authenticator.get_api_key()
except GetAPIKeyError as e:
Expand Down Expand Up @@ -95,13 +97,17 @@ def _transform_messages(

return transformed_messages

def _copilot_headers(self, session_token: str | None) -> Mapping[str, str]:
def _copilot_headers(self, session_token: str | None, model: str) -> Mapping[str, str]:
if session_token is not None:
return get_copilot_default_headers(session_token)
try:
return get_copilot_default_headers(self.authenticator.get_api_key())
except GetAPIKeyError:
return {}
except GetAPIKeyError as exc:
raise AuthenticationError(
model=model,
llm_provider="github_copilot",
message=str(exc),
)

def validate_environment(
self,
Expand All @@ -121,7 +127,7 @@ def validate_environment(
cast("dict[str, object]", litellm_params) # cast-ok: litellm_params arrives as an untyped request dict
)
session_token: Final = user_session.token if user_session is not None else None
validated_headers: Final = {**self._copilot_headers(session_token), **parent_headers}
validated_headers: Final = {**self._copilot_headers(session_token, model), **parent_headers}
if session_token is not None:
# the caller's stored session token wins unconditionally, even over a
# caller-supplied api_key or the parent's placeholder Authorization
Expand Down
35 changes: 28 additions & 7 deletions litellm/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@
NADIR_DEFAULT_API_BASE,
OPENAI_AUDIO_TRANSCRIPTION_PROVIDERS,
)
from litellm.exceptions import LiteLLMUnknownProvider
from litellm.exceptions import AuthenticationError, LiteLLMUnknownProvider
from litellm.integrations.custom_logger import CustomLogger
from litellm.litellm_core_utils.asyncify import asyncify, run_async_function
from litellm.litellm_core_utils.audio_utils.utils import (
Expand Down Expand Up @@ -2602,6 +2602,23 @@ def _complete_hosted_vllm(ctx: CompletionDispatchContext) -> _CompletionDispatch
return response


def _get_github_copilot_shared_api_key(
get_api_key: Callable[[], str],
model: str,
custom_llm_provider: str,
) -> str:
from litellm.llms.github_copilot.common_utils import GetAPIKeyError

try:
return get_api_key()
except GetAPIKeyError as exc:
Comment on lines +2605 to +2614

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Provider logic enters shared code

_get_github_copilot_shared_api_key adds GitHub Copilot鈥檚 error mapping to main.py. The repository requires provider-specific logic inside litellm/llms/<provider>/. Move the helper there before merging

Rule Used: What: Keep provider-specific logic inside litellm/llms/<provider>/. Shared code (main.py, utils.py, router.py, proxy/) should not branch on a provider name. Why: provider branches in shared code spread across the codebase and break silently when a p... (source)

raise AuthenticationError(
model=model,
llm_provider=custom_llm_provider,
message=str(exc),
)


def _complete_custom_openai(
ctx: CompletionDispatchContext,
) -> _CompletionDispatchResult:
Expand Down Expand Up @@ -2654,18 +2671,22 @@ def _complete_custom_openai(
# Add GitHub Copilot headers (same as /responses endpoint does)
if custom_llm_provider == "github_copilot":
from litellm.llms.github_copilot.authenticator import Authenticator
from litellm.llms.github_copilot.common_utils import (
get_copilot_default_headers,
pin_session_authorization,
)
from litellm.llms.github_copilot.common_utils import get_copilot_default_headers, pin_session_authorization
from litellm.llms.github_copilot.per_user_auth import (
require_github_copilot_user_session,
)

user_session: Final = require_github_copilot_user_session(litellm_params)
copilot_headers: Final = get_copilot_default_headers(
user_session.token if user_session is not None else Authenticator().get_api_key()
copilot_token: Final = (
user_session.token
if user_session is not None
else _get_github_copilot_shared_api_key(
get_api_key=Authenticator().get_api_key,
model=model,
custom_llm_provider=custom_llm_provider,
)
)
copilot_headers: Final = get_copilot_default_headers(copilot_token)
if extra_headers:
copilot_headers.update(
cast("dict[str, str]", extra_headers) # cast-ok: extra_headers is a str-valued request dict
Expand Down
3 changes: 3 additions & 0 deletions litellm/proxy/proxy_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1337,6 +1337,9 @@ async def proxy_startup_event(app: FastAPI) -> AsyncGenerator[ProxyLifespanState
shared_aiohttp_session
import json

from litellm.llms.github_copilot.authenticator import disable_github_copilot_shared_login

disable_github_copilot_shared_login()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Breaking change lacks title marker

Existing shared-token deployments now return 401, but the title lacks the breaking-change marker required by the repository. Change it to fix(github_copilot)!: before merging so release notes flag the upgrade impact

Rule Used: What: When a PR changes behavior that existing users rely on, the PR title must mark it breaking with ! after the type or scope (e.g. fix(mcp)!: ..., feat!: ...), and the PR description must have a short "Breaking change" note saying who is aff... (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Required live proof is incomplete

The supplied proof shows rejected requests but no connected-user success, Responses or embeddings calls, or dashboard setup. The repository requires live proof across affected endpoints and both success and failure paths. Add those cases before merging

Rule Used: Flag if the PR has no proof of e2e testing (if e2e testing is applicable here. If it's like an AGENTS.md change or something then probably no need to have e2e test proof) that either doesn't exist, claims that the box that it was trying to run in doe... (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

init_verbose_loggers()

prometheus_multiproc_dir: Final = os.environ.get("PROMETHEUS_MULTIPROC_DIR")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@


from litellm.exceptions import AuthenticationError
from litellm.llms.github_copilot.authenticator import Authenticator
from litellm.llms.github_copilot.embedding.transformation import (
GithubCopilotEmbeddingConfig,
)
Expand Down Expand Up @@ -264,8 +265,7 @@ def test_validate_environment_uses_per_user_session_and_skips_authenticator():
from litellm.llms.github_copilot.per_user_auth import GithubCopilotUserSession

config = GithubCopilotEmbeddingConfig()
config.authenticator = MagicMock()
config.authenticator.get_api_key.side_effect = AssertionError("shared authenticator must not run")
config.authenticator = Authenticator(shared_login_allowed=lambda: False)

session = GithubCopilotUserSession(token="user-copilot-token", api_base="https://api.githubcopilot.com")
headers = config.validate_environment(
Expand All @@ -276,7 +276,23 @@ def test_validate_environment_uses_per_user_session_and_skips_authenticator():
litellm_params={"github_copilot_user_session": session},
)
assert headers["Authorization"] == "Bearer user-copilot-token"
config.authenticator.get_api_key.assert_not_called()


def test_shared_authentication_error_is_raised_when_login_is_disabled():
config = GithubCopilotEmbeddingConfig()
config.authenticator = Authenticator(shared_login_allowed=lambda: False)

with pytest.raises(AuthenticationError) as exc_info:
config.validate_environment(
headers={},
model="github_copilot/text-embedding-3-small",
messages=[],
optional_params={},
litellm_params={},
)

assert "GitHub Copilot shared device login is disabled on the LiteLLM proxy" in str(exc_info.value)
assert exc_info.value.status_code == 401


def test_per_user_session_token_wins_over_caller_authorization():
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@


from litellm.exceptions import AuthenticationError
from litellm.llms.github_copilot.authenticator import Authenticator
from litellm.llms.github_copilot.common_utils import GetAPIKeyError
from litellm.llms.github_copilot.messages.transformation import (
GithubCopilotAnthropicMessagesConfig,
Expand Down Expand Up @@ -329,9 +330,7 @@ def test_validate_environment_uses_per_user_session_and_skips_authenticator():
from litellm.llms.github_copilot.per_user_auth import GithubCopilotUserSession

config = GithubCopilotAnthropicMessagesConfig()
config.authenticator = MagicMock()
config.authenticator.get_api_key.side_effect = AssertionError("shared authenticator must not run")
config.authenticator.get_api_base.side_effect = AssertionError("shared authenticator must not run")
config.authenticator = Authenticator(shared_login_allowed=lambda: False)

session = GithubCopilotUserSession(token="user-copilot-token", api_base="https://tenant.githubcopilot.com/")
headers, api_base = config.validate_anthropic_messages_environment(
Expand All @@ -343,7 +342,23 @@ def test_validate_environment_uses_per_user_session_and_skips_authenticator():
)
assert headers["Authorization"] == "Bearer user-copilot-token"
assert api_base == "https://tenant.githubcopilot.com"
config.authenticator.get_api_key.assert_not_called()


def test_shared_authentication_error_is_raised_when_login_is_disabled():
config = GithubCopilotAnthropicMessagesConfig()
config.authenticator = Authenticator(shared_login_allowed=lambda: False)

with pytest.raises(AuthenticationError) as exc_info:
config.validate_anthropic_messages_environment(
headers={},
model="github_copilot/claude-sonnet-5.5",
messages=[],
optional_params={},
litellm_params={},
)

assert "GitHub Copilot shared device login is disabled on the LiteLLM proxy" in str(exc_info.value)
assert exc_info.value.status_code == 401


def test_per_user_session_token_wins_over_caller_authorization():
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
import httpx
import pytest
import litellm
from litellm.exceptions import AuthenticationError
from litellm.llms.github_copilot.authenticator import Authenticator
from litellm.litellm_core_utils.get_model_cost_map import get_model_cost_map
from litellm.types.utils import LlmProviders
from litellm.utils import ProviderConfigManager
Expand Down Expand Up @@ -759,8 +761,7 @@ def test_validate_environment_uses_per_user_session_and_skips_authenticator():
from litellm.llms.github_copilot.per_user_auth import GithubCopilotUserSession

config = GithubCopilotResponsesAPIConfig()
config.authenticator = MagicMock()
config.authenticator.get_api_key.side_effect = AssertionError("shared authenticator must not run")
config.authenticator = Authenticator(shared_login_allowed=lambda: False)

session = GithubCopilotUserSession(token="user-copilot-token", api_base="https://api.githubcopilot.com")
headers = config.validate_environment(
Expand All @@ -769,7 +770,21 @@ def test_validate_environment_uses_per_user_session_and_skips_authenticator():
litellm_params={"github_copilot_user_session": session},
)
assert headers["Authorization"] == "Bearer user-copilot-token"
config.authenticator.get_api_key.assert_not_called()


def test_shared_authentication_error_is_raised_when_login_is_disabled():
config = GithubCopilotResponsesAPIConfig()
config.authenticator = Authenticator(shared_login_allowed=lambda: False)

with pytest.raises(AuthenticationError) as exc_info:
config.validate_environment(
headers={},
model="github_copilot/gpt-5.4",
litellm_params={},
)

assert "GitHub Copilot shared device login is disabled on the LiteLLM proxy" in str(exc_info.value)
assert exc_info.value.status_code == 401


def test_per_user_session_token_wins_over_caller_authorization():
Expand Down
Loading
Loading