diff --git a/litellm/llms/github_copilot/authenticator.py b/litellm/llms/github_copilot/authenticator.py index 80fd4f755e7..07442d1e616 100644 --- a/litellm/llms/github_copilot/authenticator.py +++ b/litellm/llms/github_copilot/authenticator.py @@ -15,6 +15,7 @@ from .common_utils import ( GetAPIKeyError, GetDeviceCodeError, RefreshAPIKeyError, + get_copilot_default_headers, ) # Constants (default values — overridable via environment variables at call time) @@ -188,30 +189,7 @@ class Authenticator: os.makedirs(self.token_dir, exist_ok=True) def _get_github_headers(self, access_token: str | None = None) -> dict[str, str]: - """ - Generate standard GitHub headers for API requests. - - Args: - access_token: Optional access token to include in the headers. - - Returns: - Dict[str, str]: Headers for GitHub API requests. - """ - headers: Final = { - "accept": "application/json", - "editor-version": "vscode/1.85.1", - "editor-plugin-version": "copilot/1.155.0", - "user-agent": "GithubCopilot/1.155.0", - "accept-encoding": "gzip,deflate,br", - } - - if access_token: - headers["authorization"] = f"token {access_token}" - - if "content-type" not in headers: - headers["content-type"] = "application/json" - - return headers + return get_copilot_default_headers(access_token=access_token) def _get_device_code(self) -> dict[str, str]: """ diff --git a/litellm/llms/github_copilot/common_utils.py b/litellm/llms/github_copilot/common_utils.py index 110a6584ec2..2ea87671466 100644 --- a/litellm/llms/github_copilot/common_utils.py +++ b/litellm/llms/github_copilot/common_utils.py @@ -2,6 +2,7 @@ Constants for Copilot integration """ +import os from typing import Final from uuid import uuid4 @@ -9,12 +10,31 @@ import httpx from litellm.llms.base_llm.chat.transformation import BaseLLMException -# Constants COPILOT_VERSION: Final = "0.26.7" EDITOR_PLUGIN_VERSION: Final = f"copilot-chat/{COPILOT_VERSION}" USER_AGENT: Final = f"GitHubCopilotChat/{COPILOT_VERSION}" API_VERSION: Final = "2025-04-01" DEFAULT_GITHUB_COPILOT_API_BASE: Final = "https://api.githubcopilot.com" +DEFAULT_COPILOT_INTEGRATION_ID: Final = "vscode-chat" +DEFAULT_COPILOT_EDITOR_VERSION: Final = "vscode/1.115.0" +DEFAULT_COPILOT_EDITOR_PLUGIN_VERSION: Final = EDITOR_PLUGIN_VERSION +DEFAULT_COPILOT_USER_AGENT: Final = USER_AGENT + +_COPILOT_HEADER_CONFIG = ( + ("accept", "GITHUB_COPILOT_ACCEPT", "application/json"), + ("content-type", "GITHUB_COPILOT_CONTENT_TYPE", "application/json"), + ("copilot-integration-id", "GITHUB_COPILOT_INTEGRATION_ID", DEFAULT_COPILOT_INTEGRATION_ID), + ("editor-version", "GITHUB_COPILOT_EDITOR_VERSION", DEFAULT_COPILOT_EDITOR_VERSION), + ("editor-plugin-version", "GITHUB_COPILOT_EDITOR_PLUGIN_VERSION", DEFAULT_COPILOT_EDITOR_PLUGIN_VERSION), + ("user-agent", "GITHUB_COPILOT_USER_AGENT", DEFAULT_COPILOT_USER_AGENT), + ("openai-intent", "GITHUB_COPILOT_OPENAI_INTENT", None), + ("x-github-api-version", "GITHUB_COPILOT_API_VERSION", None), + ( + "x-vscode-user-agent-library-version", + "GITHUB_COPILOT_USER_AGENT_LIBRARY_VERSION", + None, + ), +) class GithubCopilotError(BaseLLMException): @@ -57,21 +77,23 @@ class GetAPIKeyError(GithubCopilotError): pass -def get_copilot_default_headers(api_key: str) -> dict: - """ - Get default headers for GitHub Copilot Responses API. +def _get_copilot_header_value(environment_variable: str, default: str | None) -> str | None: + value = os.getenv(environment_variable) + if value is None: + return default + return value or None - Based on copilot-api's header configuration. - """ - return { - "Authorization": f"Bearer {api_key}", - "content-type": "application/json", - "copilot-integration-id": "vscode-chat", - "editor-version": "vscode/1.95.0", # Fixed version for stability - "editor-plugin-version": EDITOR_PLUGIN_VERSION, - "user-agent": USER_AGENT, - "openai-intent": "conversation-panel", - "x-github-api-version": API_VERSION, - "x-request-id": str(uuid4()), - "x-vscode-user-agent-library-version": "electron-fetch", + +def get_copilot_default_headers( + api_key: str | None = None, + *, + access_token: str | None = None, +) -> dict[str, str]: + configured_headers = { + header: value + for header, environment_variable, default in _COPILOT_HEADER_CONFIG + if (value := _get_copilot_header_value(environment_variable, default)) is not None } + authorization = f"token {access_token}" if access_token else f"Bearer {api_key}" if api_key else None + authorization_header = {"Authorization": authorization} if authorization else {} + return {**configured_headers, **authorization_header} 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 90cf5a17398..2a40e487c0d 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 @@ -37,8 +37,8 @@ def test_github_copilot_embedding_config_validate_environment(): assert validated_headers["Authorization"] == f"Bearer {mock_api_key}" assert validated_headers["copilot-integration-id"] == "vscode-chat" - assert validated_headers["editor-version"] == "vscode/1.95.0" - assert "x-request-id" in validated_headers + assert validated_headers["editor-version"] == "vscode/1.115.0" + assert "x-request-id" not in validated_headers # Test with authentication failure config.authenticator.get_api_key.side_effect = GetAPIKeyError( 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 174efceb499..d20c9ad5386 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 @@ -92,29 +92,80 @@ class TestGithubCopilotResponsesAPITransformation: ), "Should handle trailing slash" @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 the authenticator + def test_validate_environment_default_headers(self, mock_authenticator_class, monkeypatch): + for environment_variable in ( + "GITHUB_COPILOT_OPENAI_INTENT", + "GITHUB_COPILOT_API_VERSION", + "GITHUB_COPILOT_USER_AGENT_LIBRARY_VERSION", + ): + monkeypatch.delenv(environment_variable, raising=False) + mock_auth_instance = MagicMock() mock_auth_instance.get_api_key.return_value = "test-api-key-123" mock_authenticator_class.return_value = mock_auth_instance config = GithubCopilotResponsesAPIConfig() - headers = config.validate_environment( headers={}, model="gpt-5.1-codex", litellm_params={} ) - # Check required headers assert headers["Authorization"] == "Bearer test-api-key-123" + assert headers["accept"] == "application/json" assert headers["content-type"] == "application/json" assert headers["copilot-integration-id"] == "vscode-chat" - assert headers["editor-version"] == "vscode/1.95.0" - assert headers["editor-plugin-version"] == "copilot-chat/0.26.7" - assert headers["user-agent"] == "GitHubCopilotChat/0.26.7" - assert headers["openai-intent"] == "conversation-panel" - assert headers["x-github-api-version"] == "2025-04-01" - assert "x-request-id" in headers + assert headers["editor-version"] == "vscode/1.115.0" + assert headers["editor-plugin-version"] == "copilot-chat/0.44.0" + assert headers["user-agent"] == "GitHubCopilotChat/0.44.0" + assert "openai-intent" not in headers + assert "x-github-api-version" not in headers + assert "x-request-id" not in headers + assert "x-vscode-user-agent-library-version" not in headers + + @patch("litellm.llms.github_copilot.responses.transformation.Authenticator") + def test_validate_environment_headers_from_environment(self, mock_authenticator_class): + mock_auth_instance = MagicMock() + mock_auth_instance.get_api_key.return_value = "test-api-key-123" + mock_authenticator_class.return_value = mock_auth_instance + environment = { + "GITHUB_COPILOT_ACCEPT": "application/vnd.github+json", + "GITHUB_COPILOT_CONTENT_TYPE": "application/custom+json", + "GITHUB_COPILOT_INTEGRATION_ID": "custom-integration", + "GITHUB_COPILOT_EDITOR_VERSION": "custom-editor/1.0", + "GITHUB_COPILOT_EDITOR_PLUGIN_VERSION": "custom-plugin/2.0", + "GITHUB_COPILOT_USER_AGENT": "CustomAgent/2.0", + "GITHUB_COPILOT_OPENAI_INTENT": "custom-intent", + "GITHUB_COPILOT_API_VERSION": "2099-01-01", + "GITHUB_COPILOT_USER_AGENT_LIBRARY_VERSION": "custom-library", + } + + with patch.dict(os.environ, environment): + headers = GithubCopilotResponsesAPIConfig().validate_environment( + headers={}, model="gpt-5.1-codex", litellm_params={} + ) + + assert headers["Authorization"] == "Bearer test-api-key-123" + assert headers["accept"] == "application/vnd.github+json" + assert headers["content-type"] == "application/custom+json" + assert headers["copilot-integration-id"] == "custom-integration" + assert headers["editor-version"] == "custom-editor/1.0" + assert headers["editor-plugin-version"] == "custom-plugin/2.0" + assert headers["user-agent"] == "CustomAgent/2.0" + assert headers["openai-intent"] == "custom-intent" + assert headers["x-github-api-version"] == "2099-01-01" + assert headers["x-vscode-user-agent-library-version"] == "custom-library" + + @patch("litellm.llms.github_copilot.responses.transformation.Authenticator") + def test_empty_environment_header_omits_default(self, mock_authenticator_class): + mock_auth_instance = MagicMock() + mock_auth_instance.get_api_key.return_value = "test-api-key-123" + mock_authenticator_class.return_value = mock_auth_instance + + with patch.dict(os.environ, {"GITHUB_COPILOT_USER_AGENT": ""}): + headers = GithubCopilotResponsesAPIConfig().validate_environment( + headers={}, model="gpt-5.1-codex", litellm_params={} + ) + + assert "user-agent" not in headers @patch("litellm.llms.github_copilot.responses.transformation.Authenticator") def test_validate_environment_user_headers_override(self, mock_authenticator_class): 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..0b86f7bef9c 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 @@ -58,15 +58,73 @@ class TestGitHubCopilotAuthenticator: mock_makedirs.assert_called_once_with(auth.token_dir, exist_ok=True) def test_get_github_headers(self, authenticator): - """Test that GitHub headers are correctly generated.""" headers = authenticator._get_github_headers() - assert "accept" in headers - assert "editor-version" in headers - assert "user-agent" in headers - assert "content-type" in headers + assert headers == { + "accept": "application/json", + "content-type": "application/json", + "copilot-integration-id": "vscode-chat", + "editor-version": "vscode/1.115.0", + "editor-plugin-version": "copilot-chat/0.44.0", + "user-agent": "GitHubCopilotChat/0.44.0", + } headers_with_token = authenticator._get_github_headers("test-token") - assert headers_with_token["authorization"] == "token test-token" + assert headers_with_token["Authorization"] == "token test-token" + + def test_auth_requests_use_custom_copilot_headers(self, authenticator, mock_http_client): + mock_client, mock_response = mock_http_client + mock_response.json.side_effect = ( + { + "device_code": "dc", + "user_code": "UC", + "verification_uri": "https://example.com", + }, + {"access_token": "access-token"}, + {"token": "api-token", "expires_at": 9999999999}, + ) + environment = { + "GITHUB_COPILOT_ACCEPT": "application/vnd.github+json", + "GITHUB_COPILOT_CONTENT_TYPE": "application/custom+json", + "GITHUB_COPILOT_INTEGRATION_ID": "custom-integration", + "GITHUB_COPILOT_EDITOR_VERSION": "custom-editor/1.0", + "GITHUB_COPILOT_EDITOR_PLUGIN_VERSION": "custom-plugin/2.0", + "GITHUB_COPILOT_USER_AGENT": "CustomAgent/2.0", + "GITHUB_COPILOT_OPENAI_INTENT": "custom-intent", + "GITHUB_COPILOT_API_VERSION": "2099-01-01", + "GITHUB_COPILOT_USER_AGENT_LIBRARY_VERSION": "custom-library", + } + + with ( + patch.dict(os.environ, environment), + patch( + "litellm.llms.github_copilot.authenticator._get_httpx_client", + return_value=mock_client, + ), + patch.object(authenticator, "get_access_token", return_value="github-token"), + ): + authenticator._get_device_code() + authenticator._poll_for_access_token("dc") + authenticator._refresh_api_key() + + request_headers = ( + mock_client.post.call_args_list[0].kwargs["headers"], + mock_client.post.call_args_list[1].kwargs["headers"], + mock_client.get.call_args.kwargs["headers"], + ) + for headers in request_headers: + assert headers["accept"] == "application/vnd.github+json" + assert headers["content-type"] == "application/custom+json" + assert headers["copilot-integration-id"] == "custom-integration" + assert headers["editor-version"] == "custom-editor/1.0" + assert headers["editor-plugin-version"] == "custom-plugin/2.0" + assert headers["user-agent"] == "CustomAgent/2.0" + assert headers["openai-intent"] == "custom-intent" + assert headers["x-github-api-version"] == "2099-01-01" + assert headers["x-vscode-user-agent-library-version"] == "custom-library" + + assert "Authorization" not in request_headers[0] + assert "Authorization" not in request_headers[1] + assert request_headers[2]["Authorization"] == "token github-token" def test_get_access_token_from_file(self, authenticator): """Test retrieving an access token from a file."""