diff --git a/litellm/llms/github_copilot/authenticator.py b/litellm/llms/github_copilot/authenticator.py index 27a4651f154..2b96ee6d79a 100644 --- a/litellm/llms/github_copilot/authenticator.py +++ b/litellm/llms/github_copilot/authenticator.py @@ -4,6 +4,8 @@ import time from typing import Final import httpx +from pydantic import TypeAdapter, ValidationError +from typing_extensions import TypedDict from litellm._logging import verbose_logger from litellm.llms.custom_httpx.http_handler import _get_httpx_client @@ -15,6 +17,17 @@ from .common_utils import ( get_copilot_auth_headers, ) + +class _LegacyCopilotEndpoints(TypedDict, total=False): + api: str + + +class _LegacyCopilotTokenCache(TypedDict, total=False): + endpoints: _LegacyCopilotEndpoints + + +_LEGACY_COPILOT_TOKEN_CACHE_ADAPTER = TypeAdapter(_LegacyCopilotTokenCache) + # Constants (default values — overridable via environment variables at call time) DEFAULT_GITHUB_CLIENT_ID: Final = "Iv1.b507a08c87ecfe98" DEFAULT_GITHUB_DEVICE_CODE_URL: Final = "https://github.com/login/device/code" @@ -33,6 +46,14 @@ class Authenticator: self.token_dir, os.getenv("GITHUB_COPILOT_ACCESS_TOKEN_FILE", "access-token"), ) + self.legacy_api_key_file = os.path.join( + self.token_dir, + os.getenv("GITHUB_COPILOT_API_KEY_FILE", "api-key.json"), + ) + if os.getenv("GITHUB_COPILOT_API_KEY_URL"): + verbose_logger.warning( + "GITHUB_COPILOT_API_KEY_URL is no longer used; LiteLLM sends the OAuth access token directly" + ) self._ensure_token_dir() def get_access_token(self) -> str: @@ -82,7 +103,18 @@ class Authenticator: ) def get_api_base(self) -> str | None: - return os.getenv("GITHUB_COPILOT_API_BASE") + configured_api_base = os.getenv("GITHUB_COPILOT_API_BASE") + if configured_api_base: + return configured_api_base + try: + with open(self.legacy_api_key_file, "r") as legacy_api_key_file: + legacy_cache = _LEGACY_COPILOT_TOKEN_CACHE_ADAPTER.validate_json(legacy_api_key_file.read()) + except IOError: + return None + except ValidationError as e: + verbose_logger.warning(f"Error reading legacy GitHub Copilot API endpoint: {str(e)}") + return None + return legacy_cache.get("endpoints", {}).get("api") def _ensure_token_dir(self) -> None: """Ensure the token directory exists.""" diff --git a/tests/test_litellm/llms/github_copilot/test_github_copilot_authenticator.py b/tests/test_litellm/llms/github_copilot/test_github_copilot_authenticator.py index 7b42a774b43..422b5e7dfbe 100644 --- a/tests/test_litellm/llms/github_copilot/test_github_copilot_authenticator.py +++ b/tests/test_litellm/llms/github_copilot/test_github_copilot_authenticator.py @@ -41,6 +41,7 @@ class TestGitHubCopilotAuthenticator: auth = Authenticator() assert auth.token_dir.endswith("/github_copilot") assert auth.access_token_file.endswith("/access-token") + assert auth.legacy_api_key_file.endswith("/api-key.json") mock_makedirs.assert_called_once() def test_ensure_token_dir(self): @@ -52,6 +53,42 @@ class TestGitHubCopilotAuthenticator: auth = Authenticator() mock_makedirs.assert_called_once_with(auth.token_dir, exist_ok=True) + def test_get_api_base_prefers_environment(self, authenticator): + with ( + patch.dict( + os.environ, + {"GITHUB_COPILOT_API_BASE": "https://configured.githubcopilot.example"}, + clear=True, + ), + patch("builtins.open", mock_open()) as mock_file, + ): + assert authenticator.get_api_base() == "https://configured.githubcopilot.example" + mock_file.assert_not_called() + + def test_get_api_base_uses_legacy_endpoint(self, authenticator): + legacy_cache = '{"token":"ignored","endpoints":{"api":"https://api.enterprise.githubcopilot.com"}}' + with ( + patch.dict(os.environ, {}, clear=True), + patch("builtins.open", mock_open(read_data=legacy_cache)), + ): + assert authenticator.get_api_base() == "https://api.enterprise.githubcopilot.com" + + def test_deprecated_api_key_url_warns(self): + with ( + patch.dict( + os.environ, + {"GITHUB_COPILOT_API_KEY_URL": "https://deprecated.example.com/token"}, + clear=True, + ), + patch("os.path.exists", return_value=True), + patch("litellm.llms.github_copilot.authenticator.verbose_logger.warning") as mock_warning, + ): + Authenticator() + + mock_warning.assert_called_once_with( + "GITHUB_COPILOT_API_KEY_URL is no longer used; LiteLLM sends the OAuth access token directly" + ) + def test_get_github_headers(self, authenticator): headers = authenticator._get_github_headers() assert headers == {