From fed6b6e208ff894745e58b121866c97a991ad65c Mon Sep 17 00:00:00 2001 From: yyouretoast Date: Wed, 26 Aug 2026 14:32:30 +0400 Subject: [PATCH 01/11] fix(github_copilot): fix non-root Docker permission error and support token input - Remove eager _ensure_token_dir() from Authenticator.__init__() to prevent PermissionError during route resolution in non-root containers - Add PermissionError fallback to secure temp directory in _ensure_token_dir() - Accept access_token parameter in get_access_token/get_api_key/_refresh_api_key so tokens provided via UI API Key field or config are used directly - Pass api_key through in chat and embedding transformations Fixes #38329 --- litellm/llms/github_copilot/authenticator.py | 67 +++++++++--- .../github_copilot/chat/transformation.py | 4 +- .../embedding/transformation.py | 2 +- .../test_github_copilot_authenticator.py | 103 +++++++++--------- 4 files changed, 104 insertions(+), 72 deletions(-) diff --git a/litellm/llms/github_copilot/authenticator.py b/litellm/llms/github_copilot/authenticator.py index 80fd4f755e7..adca402601c 100644 --- a/litellm/llms/github_copilot/authenticator.py +++ b/litellm/llms/github_copilot/authenticator.py @@ -1,5 +1,6 @@ import json import os +import tempfile import time from datetime import datetime from typing import Any, Final @@ -37,18 +38,23 @@ class Authenticator: 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() - def get_access_token(self) -> str: + def get_access_token(self, access_token: str | None = None) -> str: """ Login to Copilot with retry 3 times. + Args: + access_token: Optional access token passed via config/request params. + Returns: str: The GitHub access token. Raises: GetAccessTokenError: If unable to obtain an access token after retries. """ + if access_token: + return access_token + try: with open(self.access_token_file, "r") as f: access_token = f.read().strip() @@ -62,6 +68,7 @@ class Authenticator: try: access_token = self._login() try: + self._ensure_token_dir() with open(self.access_token_file, "w") as f: f.write(access_token) except OSError: @@ -76,10 +83,13 @@ class Authenticator: status_code=401, ) - def get_api_key(self) -> str: + def get_api_key(self, access_token: str | None = None) -> str: """ Get the API key, refreshing if necessary. + Args: + access_token: Optional GitHub access token passed via config/request params. + Returns: str: The GitHub Copilot API key. @@ -105,9 +115,13 @@ class Authenticator: pass # Already logged in the try block try: - api_key_info = self._refresh_api_key() - with open(self.api_key_file, "w") as f: - json.dump(api_key_info, f) + api_key_info = self._refresh_api_key(access_token) + try: + self._ensure_token_dir() + with open(self.api_key_file, "w") as f: + json.dump(api_key_info, f) + except OSError as e: + verbose_logger.warning("Error saving API key to file (continuing with in-memory token): %s", e) token: Final = api_key_info.get("token") if token: return token @@ -116,12 +130,6 @@ class Authenticator: message="API key response missing token", status_code=401, ) - except OSError as e: - verbose_logger.error("Error saving API key to file: %s", e) - raise GetAPIKeyError( - message=f"Failed to save API key: {e}", - status_code=500, - ) except RefreshAPIKeyError as e: raise GetAPIKeyError( message=f"Failed to refresh API key: {e}", @@ -145,18 +153,21 @@ class Authenticator: verbose_logger.warning("Error reading API endpoint from file: %s", e) return None - def _refresh_api_key(self) -> dict[str, Any]: + def _refresh_api_key(self, access_token: str | None = None) -> dict[str, Any]: """ Refresh the API key using the access token. + Args: + access_token: Optional access token. If not provided, will call get_access_token(). + Returns: Dict[str, Any]: The API key information including token and expiration. Raises: RefreshAPIKeyError: If unable to refresh the API key. """ - access_token: Final = self.get_access_token() - headers: Final = self._get_github_headers(access_token) + resolved_token: Final = access_token or self.get_access_token() + headers: Final = self._get_github_headers(resolved_token) api_key_url: Final = os.getenv("GITHUB_COPILOT_API_KEY_URL", DEFAULT_GITHUB_API_KEY_URL) max_retries: Final = 3 @@ -183,9 +194,29 @@ class Authenticator: ) def _ensure_token_dir(self) -> None: - """Ensure the token directory exists.""" - if not os.path.exists(self.token_dir): - os.makedirs(self.token_dir, exist_ok=True) + """Ensure the token directory exists, falling back to temp directory on permission errors.""" + try: + if not os.path.exists(self.token_dir): + os.makedirs(self.token_dir, mode=0o700, exist_ok=True) + except (PermissionError, OSError) as e: + verbose_logger.warning( + "Cannot create token directory at %s (%s). Falling back to temp directory.", + self.token_dir, + e, + ) + self.token_dir = os.path.join(tempfile.gettempdir(), "litellm", "github_copilot") + try: + os.makedirs(self.token_dir, mode=0o700, exist_ok=True) + except OSError: + pass + self.access_token_file = os.path.join( + self.token_dir, + 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"), + ) def _get_github_headers(self, access_token: str | None = None) -> dict[str, str]: """ diff --git a/litellm/llms/github_copilot/chat/transformation.py b/litellm/llms/github_copilot/chat/transformation.py index 27a0028ce4a..dd3a4975e5d 100644 --- a/litellm/llms/github_copilot/chat/transformation.py +++ b/litellm/llms/github_copilot/chat/transformation.py @@ -42,7 +42,7 @@ class GithubCopilotConfig(OpenAIConfig): or DEFAULT_GITHUB_COPILOT_API_BASE ) try: - dynamic_api_key: Final = self.authenticator.get_api_key() + dynamic_api_key: Final = self.authenticator.get_api_key(access_token=api_key) except GetAPIKeyError as e: raise AuthenticationError( model=model, @@ -94,7 +94,7 @@ class GithubCopilotConfig(OpenAIConfig): # Add Copilot-specific headers (editor-version, user-agent, etc.) try: - copilot_api_key: Final = self.authenticator.get_api_key() + copilot_api_key: Final = self.authenticator.get_api_key(access_token=api_key) copilot_headers: Final = get_copilot_default_headers(copilot_api_key) validated_headers = {**copilot_headers, **validated_headers} except GetAPIKeyError: diff --git a/litellm/llms/github_copilot/embedding/transformation.py b/litellm/llms/github_copilot/embedding/transformation.py index 7ea7a89b4ca..15186a9d384 100644 --- a/litellm/llms/github_copilot/embedding/transformation.py +++ b/litellm/llms/github_copilot/embedding/transformation.py @@ -60,7 +60,7 @@ class GithubCopilotEmbeddingConfig(BaseEmbeddingConfig): """ try: # Get GitHub Copilot API key via OAuth - api_key = self.authenticator.get_api_key() + api_key = self.authenticator.get_api_key(access_token=api_key) if not api_key: raise AuthenticationError( 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 6c846a90c71..4055ec13bbf 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 @@ -1,6 +1,5 @@ import json import os -import time from datetime import datetime, timedelta from unittest.mock import MagicMock, mock_open, patch @@ -8,9 +7,7 @@ import pytest from litellm.llms.github_copilot.authenticator import Authenticator from litellm.llms.github_copilot.common_utils import ( - APIKeyExpiredError, GetAccessTokenError, - GetAPIKeyError, GetDeviceCodeError, RefreshAPIKeyError, ) @@ -19,13 +16,8 @@ from litellm.llms.github_copilot.common_utils import ( class TestGitHubCopilotAuthenticator: @pytest.fixture def authenticator(self): - with ( - patch("os.path.exists", return_value=False), - patch("os.makedirs") as mock_makedirs, - ): - auth = Authenticator() - mock_makedirs.assert_called_once() - return auth + auth = Authenticator() + return auth @pytest.fixture def mock_http_client(self): @@ -38,15 +30,13 @@ class TestGitHubCopilotAuthenticator: def test_init(self): """Test the initialization of the authenticator.""" - with ( - patch("os.path.exists", return_value=False), - patch("os.makedirs") as mock_makedirs, - ): + with patch("os.makedirs") as mock_makedirs: auth = Authenticator() - assert auth.token_dir.endswith("/github_copilot") - assert auth.access_token_file.endswith("/access-token") - assert auth.api_key_file.endswith("/api-key.json") - mock_makedirs.assert_called_once() + assert os.path.basename(auth.token_dir) == "github_copilot" + assert os.path.basename(auth.access_token_file) == "access-token" + assert os.path.basename(auth.api_key_file) == "api-key.json" + # Lazy init: directory is NOT created eagerly on __init__ + mock_makedirs.assert_not_called() def test_ensure_token_dir(self): """Test that the token directory is created if it doesn't exist.""" @@ -55,7 +45,20 @@ class TestGitHubCopilotAuthenticator: patch("os.makedirs") as mock_makedirs, ): auth = Authenticator() - mock_makedirs.assert_called_once_with(auth.token_dir, exist_ok=True) + auth._ensure_token_dir() + mock_makedirs.assert_called_once_with(auth.token_dir, mode=0o700, exist_ok=True) + + def test_ensure_token_dir_permission_error_fallback(self): + """Test that _ensure_token_dir falls back to temp directory on PermissionError.""" + auth = Authenticator() + original_dir = auth.token_dir + with ( + patch("os.path.exists", return_value=False), + patch("os.makedirs", side_effect=[PermissionError("Permission denied"), None]), + ): + auth._ensure_token_dir() + assert auth.token_dir != original_dir + assert "litellm" in auth.token_dir def test_get_github_headers(self, authenticator): """Test that GitHub headers are correctly generated.""" @@ -82,8 +85,7 @@ class TestGitHubCopilotAuthenticator: with ( patch.object(authenticator, "_login", return_value=mock_token), - patch("builtins.open", mock_open()), - patch("builtins.open", side_effect=IOError) as mock_read, + patch("builtins.open", side_effect=IOError), ): token = authenticator.get_access_token() assert token == mock_token @@ -106,9 +108,7 @@ class TestGitHubCopilotAuthenticator: def test_get_api_key_from_file(self, authenticator): """Test retrieving an API key from a file.""" future_time = (datetime.now() + timedelta(hours=1)).timestamp() - mock_api_key_data = json.dumps( - {"token": "mock-api-key", "expires_at": future_time} - ) + mock_api_key_data = json.dumps({"token": "mock-api-key", "expires_at": future_time}) with patch("builtins.open", mock_open(read_data=mock_api_key_data)): api_key = authenticator.get_api_key() @@ -117,9 +117,7 @@ class TestGitHubCopilotAuthenticator: def test_get_api_key_expired(self, authenticator): """Test refreshing an expired API key.""" past_time = (datetime.now() - timedelta(hours=1)).timestamp() - mock_expired_data = json.dumps( - {"token": "expired-api-key", "expires_at": past_time} - ) + mock_expired_data = json.dumps({"token": "expired-api-key", "expires_at": past_time}) mock_new_data = { "token": "new-api-key", "expires_at": (datetime.now() + timedelta(hours=1)).timestamp(), @@ -128,7 +126,7 @@ class TestGitHubCopilotAuthenticator: with ( patch("builtins.open", mock_open(read_data=mock_expired_data)), patch.object(authenticator, "_refresh_api_key", return_value=mock_new_data), - patch("json.dump") as mock_json_dump, + patch("json.dump"), ): api_key = authenticator.get_api_key() assert api_key == "new-api-key" @@ -217,20 +215,14 @@ class TestGitHubCopilotAuthenticator: mock_token = "mock-access-token" with ( - patch.object( - authenticator, "_get_device_code", return_value=mock_device_code_data - ), - patch.object( - authenticator, "_poll_for_access_token", return_value=mock_token - ), + patch.object(authenticator, "_get_device_code", return_value=mock_device_code_data), + patch.object(authenticator, "_poll_for_access_token", return_value=mock_token), patch("builtins.print") as mock_print, ): result = authenticator._login() assert result == mock_token authenticator._get_device_code.assert_called_once() - authenticator._poll_for_access_token.assert_called_once_with( - "mock-device-code" - ) + authenticator._poll_for_access_token.assert_called_once_with("mock-device-code") mock_print.assert_called_once() def test_get_api_base_from_file(self, authenticator): @@ -255,8 +247,10 @@ class TestGitHubCopilotAuthenticator: "user_code": "UC", "verification_uri": "https://example.com", } - with patch.dict(os.environ, {"GITHUB_COPILOT_DEVICE_CODE_URL": custom_url}), \ - patch("litellm.llms.github_copilot.authenticator._get_httpx_client", return_value=mock_client): + with ( + patch.dict(os.environ, {"GITHUB_COPILOT_DEVICE_CODE_URL": custom_url}), + patch("litellm.llms.github_copilot.authenticator._get_httpx_client", return_value=mock_client), + ): authenticator._get_device_code() assert mock_client.post.call_args[0][0] == custom_url @@ -269,8 +263,10 @@ class TestGitHubCopilotAuthenticator: "user_code": "UC", "verification_uri": "https://example.com", } - with patch.dict(os.environ, {"GITHUB_COPILOT_CLIENT_ID": custom_id}), \ - patch("litellm.llms.github_copilot.authenticator._get_httpx_client", return_value=mock_client): + with ( + patch.dict(os.environ, {"GITHUB_COPILOT_CLIENT_ID": custom_id}), + patch("litellm.llms.github_copilot.authenticator._get_httpx_client", return_value=mock_client), + ): authenticator._get_device_code() assert mock_client.post.call_args[1]["json"]["client_id"] == custom_id @@ -279,9 +275,11 @@ class TestGitHubCopilotAuthenticator: mock_client, mock_response = mock_http_client custom_url = "https://custom.example.com/token" mock_response.json.return_value = {"access_token": "tok"} - with patch.dict(os.environ, {"GITHUB_COPILOT_ACCESS_TOKEN_URL": custom_url}), \ - patch("litellm.llms.github_copilot.authenticator._get_httpx_client", return_value=mock_client), \ - patch("time.sleep"): + with ( + patch.dict(os.environ, {"GITHUB_COPILOT_ACCESS_TOKEN_URL": custom_url}), + patch("litellm.llms.github_copilot.authenticator._get_httpx_client", return_value=mock_client), + patch("time.sleep"), + ): authenticator._poll_for_access_token("dc") assert mock_client.post.call_args[0][0] == custom_url @@ -290,9 +288,11 @@ class TestGitHubCopilotAuthenticator: mock_client, mock_response = mock_http_client custom_id = "custom_client_id" mock_response.json.return_value = {"access_token": "tok"} - with patch.dict(os.environ, {"GITHUB_COPILOT_CLIENT_ID": custom_id}), \ - patch("litellm.llms.github_copilot.authenticator._get_httpx_client", return_value=mock_client), \ - patch("time.sleep"): + with ( + patch.dict(os.environ, {"GITHUB_COPILOT_CLIENT_ID": custom_id}), + patch("litellm.llms.github_copilot.authenticator._get_httpx_client", return_value=mock_client), + patch("time.sleep"), + ): authenticator._poll_for_access_token("dc") assert mock_client.post.call_args[1]["json"]["client_id"] == custom_id @@ -301,9 +301,10 @@ class TestGitHubCopilotAuthenticator: mock_client, mock_response = mock_http_client custom_url = "https://custom.example.com/api-key" mock_response.json.return_value = {"token": "api-tok", "expires_at": 9999999999} - with patch.dict(os.environ, {"GITHUB_COPILOT_API_KEY_URL": custom_url}), \ - patch("litellm.llms.github_copilot.authenticator._get_httpx_client", return_value=mock_client), \ - patch.object(authenticator, "get_access_token", return_value="access-tok"): + with ( + patch.dict(os.environ, {"GITHUB_COPILOT_API_KEY_URL": custom_url}), + patch("litellm.llms.github_copilot.authenticator._get_httpx_client", return_value=mock_client), + patch.object(authenticator, "get_access_token", return_value="access-tok"), + ): authenticator._refresh_api_key() assert mock_client.get.call_args[0][0] == custom_url - From debd52c3989facdf25e08539c94206941109625a Mon Sep 17 00:00:00 2001 From: yyouretoast Date: Wed, 26 Aug 2026 14:51:13 +0400 Subject: [PATCH 02/11] fix(github_copilot): isolate per-token session cache and namespace temp directory --- litellm/llms/github_copilot/authenticator.py | 34 +++++++++++++++++-- .../test_github_copilot_authenticator.py | 13 ++++++- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/litellm/llms/github_copilot/authenticator.py b/litellm/llms/github_copilot/authenticator.py index adca402601c..dafaf913327 100644 --- a/litellm/llms/github_copilot/authenticator.py +++ b/litellm/llms/github_copilot/authenticator.py @@ -38,6 +38,7 @@ class Authenticator: 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._session_cache: dict[str, dict[str, Any]] = {} def get_access_token(self, access_token: str | None = None) -> str: """ @@ -96,6 +97,32 @@ class Authenticator: Raises: GetAPIKeyError: If unable to obtain an API key. """ + # When an explicit access_token is provided, isolate in memory to prevent sharing a cached key across callers + if access_token: + cached_info = self._session_cache.get(access_token) + if cached_info and cached_info.get("expires_at", 0) > time.time(): + token = cached_info.get("token") + if token: + return token + + try: + api_key_info = self._refresh_api_key(access_token) + self._session_cache[access_token] = api_key_info + token: Final = api_key_info.get("token") + if token: + return token + else: + raise GetAPIKeyError( + message="API key response missing token", + status_code=401, + ) + except RefreshAPIKeyError as e: + raise GetAPIKeyError( + message=f"Failed to refresh API key: {e}", + status_code=401, + ) + + # Fallback to single-user local file storage when no explicit token is passed try: with open(self.api_key_file, "r") as f: api_key_info = json.load(f) @@ -115,7 +142,7 @@ class Authenticator: pass # Already logged in the try block try: - api_key_info = self._refresh_api_key(access_token) + api_key_info = self._refresh_api_key() try: self._ensure_token_dir() with open(self.api_key_file, "w") as f: @@ -194,7 +221,7 @@ class Authenticator: ) def _ensure_token_dir(self) -> None: - """Ensure the token directory exists, falling back to temp directory on permission errors.""" + """Ensure the token directory exists, falling back to secure user-isolated temp directory on permission errors.""" try: if not os.path.exists(self.token_dir): os.makedirs(self.token_dir, mode=0o700, exist_ok=True) @@ -204,7 +231,8 @@ class Authenticator: self.token_dir, e, ) - self.token_dir = os.path.join(tempfile.gettempdir(), "litellm", "github_copilot") + uid = os.getuid() if hasattr(os, "getuid") else "user" + self.token_dir = os.path.join(tempfile.gettempdir(), f"litellm_{uid}", "github_copilot") try: os.makedirs(self.token_dir, mode=0o700, exist_ok=True) except OSError: 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 4055ec13bbf..db68eb416c6 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 @@ -35,7 +35,6 @@ class TestGitHubCopilotAuthenticator: assert os.path.basename(auth.token_dir) == "github_copilot" assert os.path.basename(auth.access_token_file) == "access-token" assert os.path.basename(auth.api_key_file) == "api-key.json" - # Lazy init: directory is NOT created eagerly on __init__ mock_makedirs.assert_not_called() def test_ensure_token_dir(self): @@ -60,6 +59,18 @@ class TestGitHubCopilotAuthenticator: assert auth.token_dir != original_dir assert "litellm" in auth.token_dir + def test_get_api_key_with_explicit_token_isolation(self, authenticator): + """Test that explicit tokens use isolated in-memory caching and do not read stale disk files.""" + mock_data = {"token": "token-b-session-key", "expires_at": (datetime.now() + timedelta(hours=1)).timestamp()} + with ( + patch.object(authenticator, "_refresh_api_key", return_value=mock_data) as mock_refresh, + patch("builtins.open") as mock_file_open, + ): + api_key = authenticator.get_api_key(access_token="user-b-custom-token") + assert api_key == "token-b-session-key" + mock_refresh.assert_called_once_with("user-b-custom-token") + mock_file_open.assert_not_called() + def test_get_github_headers(self, authenticator): """Test that GitHub headers are correctly generated.""" headers = authenticator._get_github_headers() From fb09502a0e633d1317353c5275b7504b392380f1 Mon Sep 17 00:00:00 2001 From: yyouretoast Date: Wed, 26 Aug 2026 14:59:52 +0400 Subject: [PATCH 03/11] style(github_copilot): satisfy type discipline annotations and Final declarations --- litellm/llms/github_copilot/authenticator.py | 26 ++++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/litellm/llms/github_copilot/authenticator.py b/litellm/llms/github_copilot/authenticator.py index dafaf913327..bb2e1fca056 100644 --- a/litellm/llms/github_copilot/authenticator.py +++ b/litellm/llms/github_copilot/authenticator.py @@ -38,7 +38,7 @@ class Authenticator: 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._session_cache: dict[str, dict[str, Any]] = {} + self._session_cache: dict[str, dict[str, Any]] = {} # mutable-ok: session cache def get_access_token(self, access_token: str | None = None) -> str: """ @@ -58,23 +58,23 @@ class Authenticator: try: with open(self.access_token_file, "r") as f: - access_token = f.read().strip() - if access_token: - return access_token + saved_token: Final = f.read().strip() + if saved_token: + return saved_token except OSError: verbose_logger.warning("No existing access token found or error reading file") for attempt in range(3): verbose_logger.debug("Access token acquisition attempt %s/3", attempt + 1) try: - access_token = self._login() + new_token: Final = self._login() try: self._ensure_token_dir() with open(self.access_token_file, "w") as f: - f.write(access_token) + f.write(new_token) except OSError: verbose_logger.error("Error saving access token to file") - return access_token + return new_token except (GetDeviceCodeError, GetAccessTokenError, RefreshAPIKeyError) as e: verbose_logger.warning("Failed attempt %s: %s", attempt + 1, e) continue @@ -99,14 +99,14 @@ class Authenticator: """ # When an explicit access_token is provided, isolate in memory to prevent sharing a cached key across callers if access_token: - cached_info = self._session_cache.get(access_token) + cached_info: Final = self._session_cache.get(access_token) if cached_info and cached_info.get("expires_at", 0) > time.time(): - token = cached_info.get("token") + token: Final = cached_info.get("token") if token: return token try: - api_key_info = self._refresh_api_key(access_token) + api_key_info: Final = self._refresh_api_key(access_token) self._session_cache[access_token] = api_key_info token: Final = api_key_info.get("token") if token: @@ -125,7 +125,7 @@ class Authenticator: # Fallback to single-user local file storage when no explicit token is passed try: with open(self.api_key_file, "r") as f: - api_key_info = json.load(f) + api_key_info: Final = json.load(f) if api_key_info.get("expires_at", 0) > datetime.now().timestamp(): return api_key_info.get("token") else: @@ -142,7 +142,7 @@ class Authenticator: pass # Already logged in the try block try: - api_key_info = self._refresh_api_key() + api_key_info: Final = self._refresh_api_key() try: self._ensure_token_dir() with open(self.api_key_file, "w") as f: @@ -231,7 +231,7 @@ class Authenticator: self.token_dir, e, ) - uid = os.getuid() if hasattr(os, "getuid") else "user" + uid: Final = os.getuid() if hasattr(os, "getuid") else "user" self.token_dir = os.path.join(tempfile.gettempdir(), f"litellm_{uid}", "github_copilot") try: os.makedirs(self.token_dir, mode=0o700, exist_ok=True) From 60613a8610160575c654070eb4581855dc25ff64 Mon Sep 17 00:00:00 2001 From: yyouretoast Date: Wed, 26 Aug 2026 15:10:52 +0400 Subject: [PATCH 04/11] style(github_copilot): fix parameter rebinding in embedding transformation --- litellm/llms/github_copilot/embedding/transformation.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/litellm/llms/github_copilot/embedding/transformation.py b/litellm/llms/github_copilot/embedding/transformation.py index 15186a9d384..6da1a46d74a 100644 --- a/litellm/llms/github_copilot/embedding/transformation.py +++ b/litellm/llms/github_copilot/embedding/transformation.py @@ -60,9 +60,9 @@ class GithubCopilotEmbeddingConfig(BaseEmbeddingConfig): """ try: # Get GitHub Copilot API key via OAuth - api_key = self.authenticator.get_api_key(access_token=api_key) + copilot_api_key: Final = self.authenticator.get_api_key(access_token=api_key) - if not api_key: + if not copilot_api_key: raise AuthenticationError( model=model, llm_provider="github_copilot", @@ -70,7 +70,7 @@ class GithubCopilotEmbeddingConfig(BaseEmbeddingConfig): ) # Get default headers - default_headers: Final = get_copilot_default_headers(api_key) + default_headers: Final = get_copilot_default_headers(copilot_api_key) # Merge with existing headers (user's extra_headers take priority) merged_headers: Final = {**default_headers, **headers} From 1328b7124fe07e1b0ebbaf7fe471af1a5004091d Mon Sep 17 00:00:00 2001 From: yyouretoast Date: Wed, 26 Aug 2026 15:13:57 +0400 Subject: [PATCH 05/11] test(github_copilot): add unit tests for explicit token cache hit and error paths --- .../test_github_copilot_authenticator.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) 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 db68eb416c6..b5e43b677e5 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 @@ -8,6 +8,7 @@ import pytest from litellm.llms.github_copilot.authenticator import Authenticator from litellm.llms.github_copilot.common_utils import ( GetAccessTokenError, + GetAPIKeyError, GetDeviceCodeError, RefreshAPIKeyError, ) @@ -71,6 +72,25 @@ class TestGitHubCopilotAuthenticator: mock_refresh.assert_called_once_with("user-b-custom-token") mock_file_open.assert_not_called() + # Second call with the same token should hit in-memory cache without calling _refresh_api_key again + cached_key = authenticator.get_api_key(access_token="user-b-custom-token") + assert cached_key == "token-b-session-key" + assert mock_refresh.call_count == 1 + + def test_get_api_key_with_explicit_token_missing_token_in_response(self, authenticator): + """Test that get_api_key raises GetAPIKeyError when API response lacks token.""" + with patch.object(authenticator, "_refresh_api_key", return_value={}): + with pytest.raises(GetAPIKeyError): + authenticator.get_api_key(access_token="token-without-key") + + def test_get_api_key_with_explicit_token_refresh_error(self, authenticator): + """Test that get_api_key handles RefreshAPIKeyError when refreshing explicit token.""" + with patch.object( + authenticator, "_refresh_api_key", side_effect=RefreshAPIKeyError(message="Refresh failed", status_code=401) + ): + with pytest.raises(GetAPIKeyError): + authenticator.get_api_key(access_token="failing-token") + def test_get_github_headers(self, authenticator): """Test that GitHub headers are correctly generated.""" headers = authenticator._get_github_headers() From 268517578380d590c2af6d980c8c797b5a0b9359 Mon Sep 17 00:00:00 2001 From: yyouretoast Date: Wed, 26 Aug 2026 15:28:45 +0400 Subject: [PATCH 06/11] fix(github_copilot): use mkdtemp for atomic temp directory and clean test quality assertions --- litellm/llms/github_copilot/authenticator.py | 4 +--- .../test_github_copilot_authenticator.py | 16 +++++++--------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/litellm/llms/github_copilot/authenticator.py b/litellm/llms/github_copilot/authenticator.py index bb2e1fca056..94a81c7dfd0 100644 --- a/litellm/llms/github_copilot/authenticator.py +++ b/litellm/llms/github_copilot/authenticator.py @@ -231,10 +231,8 @@ class Authenticator: self.token_dir, e, ) - uid: Final = os.getuid() if hasattr(os, "getuid") else "user" - self.token_dir = os.path.join(tempfile.gettempdir(), f"litellm_{uid}", "github_copilot") try: - os.makedirs(self.token_dir, mode=0o700, exist_ok=True) + self.token_dir = tempfile.mkdtemp(prefix="litellm_copilot_") except OSError: pass self.access_token_file = os.path.join( 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 b5e43b677e5..881fde49c66 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 @@ -38,15 +38,13 @@ class TestGitHubCopilotAuthenticator: assert os.path.basename(auth.api_key_file) == "api-key.json" mock_makedirs.assert_not_called() - def test_ensure_token_dir(self): + def test_ensure_token_dir(self, tmp_path): """Test that the token directory is created if it doesn't exist.""" - with ( - patch("os.path.exists", return_value=False), - patch("os.makedirs") as mock_makedirs, - ): - auth = Authenticator() - auth._ensure_token_dir() - mock_makedirs.assert_called_once_with(auth.token_dir, mode=0o700, exist_ok=True) + test_dir = str(tmp_path / "new_copilot_dir") + auth = Authenticator() + auth.token_dir = test_dir + auth._ensure_token_dir() + assert os.path.exists(test_dir) def test_ensure_token_dir_permission_error_fallback(self): """Test that _ensure_token_dir falls back to temp directory on PermissionError.""" @@ -54,7 +52,7 @@ class TestGitHubCopilotAuthenticator: original_dir = auth.token_dir with ( patch("os.path.exists", return_value=False), - patch("os.makedirs", side_effect=[PermissionError("Permission denied"), None]), + patch("os.makedirs", side_effect=PermissionError("Permission denied")), ): auth._ensure_token_dir() assert auth.token_dir != original_dir From 07ff250a71d7149c8cbc0fb8a61f5d28c8487871 Mon Sep 17 00:00:00 2001 From: yyouretoast Date: Wed, 26 Aug 2026 15:37:04 +0400 Subject: [PATCH 07/11] chore: ratchet down TQ002 limit in test quality budget --- test-quality-budget.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-quality-budget.json b/test-quality-budget.json index 4a7bc7edff2..0350d813743 100644 --- a/test-quality-budget.json +++ b/test-quality-budget.json @@ -3,7 +3,7 @@ "limit": 744 }, "TQ002": { - "limit": 742 + "limit": 741 }, "TQ003": { "limit": 62 From 1e1377c0a3bc42c22778a6b0633dd2b0f9d4ad9e Mon Sep 17 00:00:00 2001 From: yyouretoast Date: Wed, 26 Aug 2026 15:51:50 +0400 Subject: [PATCH 08/11] style(github_copilot): add type narrowing to satisfy basedpyright --- litellm/llms/github_copilot/authenticator.py | 30 ++++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/litellm/llms/github_copilot/authenticator.py b/litellm/llms/github_copilot/authenticator.py index 94a81c7dfd0..148367ac010 100644 --- a/litellm/llms/github_copilot/authenticator.py +++ b/litellm/llms/github_copilot/authenticator.py @@ -102,14 +102,14 @@ class Authenticator: cached_info: Final = self._session_cache.get(access_token) if cached_info and cached_info.get("expires_at", 0) > time.time(): token: Final = cached_info.get("token") - if token: + if isinstance(token, str): return token try: api_key_info: Final = self._refresh_api_key(access_token) self._session_cache[access_token] = api_key_info token: Final = api_key_info.get("token") - if token: + if isinstance(token, str): return token else: raise GetAPIKeyError( @@ -126,8 +126,10 @@ class Authenticator: try: with open(self.api_key_file, "r") as f: api_key_info: Final = json.load(f) - if api_key_info.get("expires_at", 0) > datetime.now().timestamp(): - return api_key_info.get("token") + if isinstance(api_key_info, dict) and api_key_info.get("expires_at", 0) > datetime.now().timestamp(): + token: Final = api_key_info.get("token") + if isinstance(token, str): + return token else: verbose_logger.warning("API key expired, refreshing") raise APIKeyExpiredError( @@ -150,7 +152,7 @@ class Authenticator: except OSError as e: verbose_logger.warning("Error saving API key to file (continuing with in-memory token): %s", e) token: Final = api_key_info.get("token") - if token: + if isinstance(token, str): return token else: raise GetAPIKeyError( @@ -173,9 +175,13 @@ class Authenticator: try: with open(self.api_key_file, "r") as f: api_key_info: Final = json.load(f) - endpoints: Final = api_key_info.get("endpoints", {}) - api_endpoint: Final = endpoints.get("api") - return api_endpoint + if isinstance(api_key_info, dict): + endpoints: Final = api_key_info.get("endpoints", {}) + if isinstance(endpoints, dict): + api_endpoint: Final = endpoints.get("api") + if isinstance(api_endpoint, str): + return api_endpoint + return None except (OSError, json.JSONDecodeError, KeyError) as e: verbose_logger.warning("Error reading API endpoint from file: %s", e) return None @@ -200,13 +206,13 @@ class Authenticator: max_retries: Final = 3 for attempt in range(max_retries): try: - sync_client = _get_httpx_client() - response = sync_client.get(api_key_url, headers=headers) + sync_client: Final = _get_httpx_client() + response: Final = sync_client.get(api_key_url, headers=headers) response.raise_for_status() - response_json = response.json() + response_json: Final = response.json() - if "token" in response_json: + if isinstance(response_json, dict) and "token" in response_json: return response_json else: verbose_logger.warning("API key response missing token: %s", response_json) From 1114e1866b1e50583150948320331885a6623659 Mon Sep 17 00:00:00 2001 From: yyouretoast Date: Wed, 26 Aug 2026 16:10:41 +0400 Subject: [PATCH 09/11] style(github_copilot): remove Final from loop variables to satisfy basedpyright --- litellm/llms/github_copilot/authenticator.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/litellm/llms/github_copilot/authenticator.py b/litellm/llms/github_copilot/authenticator.py index 148367ac010..fe67c20921f 100644 --- a/litellm/llms/github_copilot/authenticator.py +++ b/litellm/llms/github_copilot/authenticator.py @@ -67,7 +67,7 @@ class Authenticator: for attempt in range(3): verbose_logger.debug("Access token acquisition attempt %s/3", attempt + 1) try: - new_token: Final = self._login() + new_token = self._login() # rebind-ok: loop variable try: self._ensure_token_dir() with open(self.access_token_file, "w") as f: @@ -206,11 +206,11 @@ class Authenticator: max_retries: Final = 3 for attempt in range(max_retries): try: - sync_client: Final = _get_httpx_client() - response: Final = sync_client.get(api_key_url, headers=headers) + sync_client = _get_httpx_client() # rebind-ok: loop variable + response = sync_client.get(api_key_url, headers=headers) # rebind-ok: loop variable response.raise_for_status() - response_json: Final = response.json() + response_json = response.json() # rebind-ok: loop variable if isinstance(response_json, dict) and "token" in response_json: return response_json From 5f32ebae1649f2ddb3a4823ad604ad1fac0b848c Mon Sep 17 00:00:00 2001 From: yyouretoast Date: Wed, 26 Aug 2026 16:20:00 +0400 Subject: [PATCH 10/11] style(github_copilot): use distinct variable names for Final declarations across branches --- litellm/llms/github_copilot/authenticator.py | 36 ++++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/litellm/llms/github_copilot/authenticator.py b/litellm/llms/github_copilot/authenticator.py index fe67c20921f..32e90a57f4a 100644 --- a/litellm/llms/github_copilot/authenticator.py +++ b/litellm/llms/github_copilot/authenticator.py @@ -101,16 +101,16 @@ class Authenticator: if access_token: cached_info: Final = self._session_cache.get(access_token) if cached_info and cached_info.get("expires_at", 0) > time.time(): - token: Final = cached_info.get("token") - if isinstance(token, str): - return token + cached_token: Final = cached_info.get("token") + if isinstance(cached_token, str): + return cached_token try: - api_key_info: Final = self._refresh_api_key(access_token) - self._session_cache[access_token] = api_key_info - token: Final = api_key_info.get("token") - if isinstance(token, str): - return token + session_key_info: Final = self._refresh_api_key(access_token) + self._session_cache[access_token] = session_key_info + refreshed_token: Final = session_key_info.get("token") + if isinstance(refreshed_token, str): + return refreshed_token else: raise GetAPIKeyError( message="API key response missing token", @@ -125,11 +125,11 @@ class Authenticator: # Fallback to single-user local file storage when no explicit token is passed try: with open(self.api_key_file, "r") as f: - api_key_info: Final = json.load(f) - if isinstance(api_key_info, dict) and api_key_info.get("expires_at", 0) > datetime.now().timestamp(): - token: Final = api_key_info.get("token") - if isinstance(token, str): - return token + file_key_info: Final = json.load(f) + if isinstance(file_key_info, dict) and file_key_info.get("expires_at", 0) > datetime.now().timestamp(): + file_token: Final = file_key_info.get("token") + if isinstance(file_token, str): + return file_token else: verbose_logger.warning("API key expired, refreshing") raise APIKeyExpiredError( @@ -144,16 +144,16 @@ class Authenticator: pass # Already logged in the try block try: - api_key_info: Final = self._refresh_api_key() + refreshed_key_info: Final = self._refresh_api_key() try: self._ensure_token_dir() with open(self.api_key_file, "w") as f: - json.dump(api_key_info, f) + json.dump(refreshed_key_info, f) except OSError as e: verbose_logger.warning("Error saving API key to file (continuing with in-memory token): %s", e) - token: Final = api_key_info.get("token") - if isinstance(token, str): - return token + new_key_token: Final = refreshed_key_info.get("token") + if isinstance(new_key_token, str): + return new_key_token else: raise GetAPIKeyError( message="API key response missing token", From 7d00e16c26d29dba1b3078acedefd0ea1309039a Mon Sep 17 00:00:00 2001 From: yyouretoast Date: Wed, 26 Aug 2026 16:36:18 +0400 Subject: [PATCH 11/11] test(github_copilot): add unit tests for fallback error handling and in-memory persistence --- .../test_github_copilot_authenticator.py | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) 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 881fde49c66..9145ca76d7b 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 @@ -337,3 +337,43 @@ class TestGitHubCopilotAuthenticator: ): authenticator._refresh_api_key() assert mock_client.get.call_args[0][0] == custom_url + + def test_get_api_key_fallback_refresh_missing_token(self, authenticator): + """Test fallback flow when refreshed API key is missing token.""" + with ( + patch("builtins.open", side_effect=OSError), + patch.object(authenticator, "_refresh_api_key", return_value={}), + ): + with pytest.raises(GetAPIKeyError): + authenticator.get_api_key() + + def test_get_api_key_fallback_refresh_error(self, authenticator): + """Test fallback flow when _refresh_api_key raises RefreshAPIKeyError.""" + with ( + patch("builtins.open", side_effect=OSError), + patch.object( + authenticator, "_refresh_api_key", side_effect=RefreshAPIKeyError(message="Error", status_code=401) + ), + ): + with pytest.raises(GetAPIKeyError): + authenticator.get_api_key() + + def test_get_api_key_fallback_save_os_error(self, authenticator): + """Test fallback flow continues when saving API key raises OSError.""" + mock_new_data = { + "token": "in-memory-token", + "expires_at": (datetime.now() + timedelta(hours=1)).timestamp(), + } + with ( + patch("builtins.open", side_effect=[OSError, OSError]), + patch.object(authenticator, "_refresh_api_key", return_value=mock_new_data), + patch.object(authenticator, "_ensure_token_dir", side_effect=OSError), + ): + api_key = authenticator.get_api_key() + assert api_key == "in-memory-token" + + def test_get_api_base_non_dict_or_missing(self, authenticator): + """Test get_api_base returns None for non-dict or missing endpoints.""" + with patch("builtins.open", mock_open(read_data=json.dumps({"endpoints": "invalid"}))): + assert authenticator.get_api_base() is None +