diff --git a/litellm/llms/github_copilot/authenticator.py b/litellm/llms/github_copilot/authenticator.py index 8ecd0b63298..3d58fd6788d 100644 --- a/litellm/llms/github_copilot/authenticator.py +++ b/litellm/llms/github_copilot/authenticator.py @@ -60,13 +60,16 @@ class Authenticator: def get_access_token(self) -> str: """ - Login to Copilot with retry 3 times. + Return the GitHub access token. + + In credential mode, returns the injected token directly. + In file-based mode (SDK), reads from the access-token file on disk. Returns: str: The GitHub access token. Raises: - GetAccessTokenError: If unable to obtain an access token after retries. + GetAccessTokenError: If no access token is available. """ if self._injected_access_token is not None: return self._injected_access_token @@ -82,7 +85,8 @@ class Authenticator: raise GetAccessTokenError( message=( "No GitHub Copilot access token configured. " - "Use a named credential via the LiteLLM proxy or UI before making requests. " + "Run `litellm --login github_copilot` (SDK) or create a named credential " + "via the LiteLLM proxy UI. " "See: https://docs.litellm.ai/docs/providers/github_copilot" ), status_code=401, diff --git a/litellm/llms/github_copilot/embedding/transformation.py b/litellm/llms/github_copilot/embedding/transformation.py index 68ce93550cb..a1d37718e0d 100644 --- a/litellm/llms/github_copilot/embedding/transformation.py +++ b/litellm/llms/github_copilot/embedding/transformation.py @@ -64,10 +64,11 @@ class GithubCopilotEmbeddingConfig(BaseEmbeddingConfig): message="GitHub Copilot API key is required. Please authenticate via OAuth Device Flow.", ) - # api_key at this point is already the resolved copilot inference - # token (exchanged by the provider info step or main.py). - # Just use it directly — no re-authentication needed. - default_headers = get_copilot_default_headers(api_key) + # api_key is the GitHub access token (ghu_xxx). Exchange it for + # a short-lived Copilot inference token (cached at module level). + authenticator = Authenticator(access_token=api_key) + inference_token = authenticator.get_api_key() + default_headers = get_copilot_default_headers(inference_token) # Merge with existing headers (user's extra_headers take priority) merged_headers = {**default_headers, **headers} @@ -99,7 +100,8 @@ class GithubCopilotEmbeddingConfig(BaseEmbeddingConfig): """ # Use provided api_base or fall back to credential-resolved base or default if api_key: - api_base = Authenticator(access_token=api_key).get_api_base() or api_base or GITHUB_COPILOT_API_BASE + derived_api_base = Authenticator(access_token=api_key).get_api_base() + api_base = api_base or derived_api_base or GITHUB_COPILOT_API_BASE else: api_base = api_base or GITHUB_COPILOT_API_BASE diff --git a/litellm/llms/github_copilot/responses/transformation.py b/litellm/llms/github_copilot/responses/transformation.py index fb31920f5bb..c66c5cf43c2 100644 --- a/litellm/llms/github_copilot/responses/transformation.py +++ b/litellm/llms/github_copilot/responses/transformation.py @@ -106,7 +106,9 @@ class GithubCopilotResponsesAPIConfig(OpenAIResponsesAPIConfig): if isinstance(litellm_params, dict): _api_key = litellm_params.get("api_key") else: - _api_key = getattr(litellm_params, "api_key", None) if litellm_params else None + _api_key = ( + getattr(litellm_params, "api_key", None) if litellm_params else None + ) if not _api_key: raise AuthenticationError( model=model, @@ -114,10 +116,11 @@ class GithubCopilotResponsesAPIConfig(OpenAIResponsesAPIConfig): message="GitHub Copilot API key is required. Please authenticate via OAuth Device Flow.", ) - # api_key at this point is already the resolved copilot inference - # token (exchanged by _get_openai_compatible_provider_info or - # main.py). Just use it directly — no re-authentication needed. - default_headers = get_copilot_default_headers(_api_key) + # _api_key is the GitHub access token (ghu_xxx). Exchange it for + # a short-lived Copilot inference token (cached at module level). + authenticator = Authenticator(access_token=_api_key) + inference_token = authenticator.get_api_key() + default_headers = get_copilot_default_headers(inference_token) # Merge with existing headers (user's extra_headers take priority) merged_headers = {**default_headers, **headers} @@ -168,9 +171,15 @@ class GithubCopilotResponsesAPIConfig(OpenAIResponsesAPIConfig): added in the future by detecting account type. """ # Use provided api_base or fall back to credential-resolved base or default - _api_key = litellm_params.get("api_key") if isinstance(litellm_params, dict) else None + _api_key = ( + litellm_params.get("api_key") if isinstance(litellm_params, dict) else None + ) if _api_key: - api_base = api_base or Authenticator(access_token=_api_key).get_api_base() or GITHUB_COPILOT_API_BASE + api_base = ( + api_base + or Authenticator(access_token=_api_key).get_api_base() + or GITHUB_COPILOT_API_BASE + ) else: api_base = api_base or GITHUB_COPILOT_API_BASE diff --git a/tests/test_litellm/llms/github_copilot/embedding/test_github_copilot_embedding_transformation.py b/tests/test_litellm/llms/github_copilot/embedding/test_github_copilot_embedding_transformation.py index 3b1ebc1a682..15fd6844a9e 100644 --- a/tests/test_litellm/llms/github_copilot/embedding/test_github_copilot_embedding_transformation.py +++ b/tests/test_litellm/llms/github_copilot/embedding/test_github_copilot_embedding_transformation.py @@ -10,8 +10,13 @@ from litellm.exceptions import AuthenticationError from litellm.llms.github_copilot.embedding.transformation import GithubCopilotEmbeddingConfig from litellm.llms.github_copilot.common_utils import GetAPIKeyError -def test_github_copilot_embedding_config_validate_environment(): +@patch("litellm.llms.github_copilot.embedding.transformation.Authenticator") +def test_github_copilot_embedding_config_validate_environment(mock_authenticator_class): """Test the GitHub Copilot embedding configuration environment validation.""" + mock_auth_instance = MagicMock() + mock_auth_instance.get_api_key.return_value = "copilot-inference-token" + mock_authenticator_class.return_value = mock_auth_instance + config = GithubCopilotEmbeddingConfig() model = "github_copilot/text-embedding-3-small" @@ -24,8 +29,9 @@ def test_github_copilot_embedding_config_validate_environment(): api_key="gh-access-token", ) - # api_key is used directly — no re-exchange in validate_environment - assert validated_headers["Authorization"] == "Bearer gh-access-token" + # Access token is exchanged for inference token + mock_authenticator_class.assert_called_with(access_token="gh-access-token") + assert validated_headers["Authorization"] == "Bearer copilot-inference-token" assert validated_headers["copilot-integration-id"] == "vscode-chat" assert validated_headers["editor-version"] == "vscode/1.95.0" assert "x-request-id" in validated_headers @@ -42,9 +48,6 @@ def test_github_copilot_embedding_config_validate_environment(): ) assert "required" in str(excinfo.value).lower() - # Auth failures now happen upstream (in _get_openai_compatible_provider_info - # or main.py), not in validate_environment. No re-exchange test needed. - @patch("litellm.llms.github_copilot.embedding.transformation.Authenticator") def test_github_copilot_embedding_config_get_complete_url(mock_authenticator_class): """Test the GitHub Copilot embedding configuration URL generation.""" diff --git a/tests/test_litellm/llms/github_copilot/responses/test_github_copilot_responses_transformation.py b/tests/test_litellm/llms/github_copilot/responses/test_github_copilot_responses_transformation.py index 49fb2f2e186..ce27d5f2114 100644 --- a/tests/test_litellm/llms/github_copilot/responses/test_github_copilot_responses_transformation.py +++ b/tests/test_litellm/llms/github_copilot/responses/test_github_copilot_responses_transformation.py @@ -82,16 +82,22 @@ class TestGithubCopilotResponsesAPITransformation: "Should handle trailing slash" ) - def test_validate_environment_default_headers(self): + @patch("litellm.llms.github_copilot.responses.transformation.Authenticator") + def test_validate_environment_default_headers(self, mock_authenticator_class): """Test that validate_environment generates correct default headers""" + mock_auth_instance = MagicMock() + mock_auth_instance.get_api_key.return_value = "copilot-inference-token" + mock_authenticator_class.return_value = mock_auth_instance + config = GithubCopilotResponsesAPIConfig() headers = config.validate_environment( headers={}, model="gpt-5.1-codex", litellm_params={"api_key": "gh-access-token"} ) - # api_key is used directly — no re-exchange in validate_environment - assert headers["Authorization"] == "Bearer gh-access-token" + # Access token is exchanged for inference token + mock_authenticator_class.assert_called_with(access_token="gh-access-token") + assert headers["Authorization"] == "Bearer copilot-inference-token" assert headers["content-type"] == "application/json" assert headers["copilot-integration-id"] == "vscode-chat" assert headers["editor-version"] == "vscode/1.95.0" @@ -101,8 +107,13 @@ class TestGithubCopilotResponsesAPITransformation: assert headers["x-github-api-version"] == "2025-04-01" assert "x-request-id" in headers - def test_validate_environment_user_headers_override(self): + @patch("litellm.llms.github_copilot.responses.transformation.Authenticator") + def test_validate_environment_user_headers_override(self, mock_authenticator_class): """Test that user-provided headers override default headers""" + mock_auth_instance = MagicMock() + mock_auth_instance.get_api_key.return_value = "copilot-inference-token" + mock_authenticator_class.return_value = mock_auth_instance + config = GithubCopilotResponsesAPIConfig() custom_headers = { @@ -118,8 +129,8 @@ class TestGithubCopilotResponsesAPITransformation: assert headers["editor-version"] == "custom/2.0.0" # Custom header should be preserved assert headers["custom-header"] == "custom-value" - # api_key used directly - assert headers["Authorization"] == "Bearer gh-access-token" + # Access token exchanged for inference token + assert headers["Authorization"] == "Bearer copilot-inference-token" def test_get_initiator_with_assistant_role(self): """Test _get_initiator returns 'agent' for assistant role"""