From e264b893bf701b249ee454d06974dedb6a6bdb5f Mon Sep 17 00:00:00 2001 From: codgician <15964984+codgician@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:19:42 +0800 Subject: [PATCH 01/11] fix(github-copilot): make client headers configurable --- litellm/llms/github_copilot/authenticator.py | 26 +------ litellm/llms/github_copilot/common_utils.py | 56 +++++++++----- ...github_copilot_embedding_transformation.py | 4 +- ...github_copilot_responses_transformation.py | 73 ++++++++++++++++--- .../test_github_copilot_authenticator.py | 70 ++++++++++++++++-- 5 files changed, 169 insertions(+), 60 deletions(-) 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.""" From 29bae6e21ad278e13bbdb4fb8a28703e9449a4ed Mon Sep 17 00:00:00 2001 From: codgician <15964984+codgician@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:45:34 +0800 Subject: [PATCH 02/11] fix(github-copilot): support direct OAuth tokens --- litellm/llms/github_copilot/authenticator.py | 8 ++ .../test_github_copilot_authenticator.py | 129 +++++++++--------- 2 files changed, 72 insertions(+), 65 deletions(-) diff --git a/litellm/llms/github_copilot/authenticator.py b/litellm/llms/github_copilot/authenticator.py index 07442d1e616..17ef4d6e7c8 100644 --- a/litellm/llms/github_copilot/authenticator.py +++ b/litellm/llms/github_copilot/authenticator.py @@ -25,6 +25,10 @@ DEFAULT_GITHUB_ACCESS_TOKEN_URL: Final = "https://github.com/login/oauth/access_ DEFAULT_GITHUB_API_KEY_URL: Final = "https://api.github.com/copilot_internal/v2/token" +def _use_oauth_token() -> bool: + return os.getenv("GITHUB_COPILOT_USE_OAUTH_TOKEN", "").strip().lower() in {"1", "true", "yes", "on"} + + class Authenticator: def __init__(self) -> None: """Initialize the GitHub Copilot authenticator with configurable token paths.""" @@ -87,6 +91,8 @@ class Authenticator: Raises: GetAPIKeyError: If unable to obtain an API key. """ + if _use_oauth_token(): + return self.get_access_token() try: with open(self.api_key_file, "r") as f: api_key_info = json.load(f) @@ -136,6 +142,8 @@ class Authenticator: Returns: Optional[str]: The GitHub Copilot API endpoint, or None if not found. """ + if _use_oauth_token(): + return os.getenv("GITHUB_COPILOT_API_BASE") try: with open(self.api_key_file, "r") as f: api_key_info: Final = json.load(f) 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 0b86f7bef9c..c378402324d 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 @@ -71,60 +71,60 @@ class TestGitHubCopilotAuthenticator: headers_with_token = authenticator._get_github_headers("test-token") assert headers_with_token["Authorization"] == "token test-token" - def test_auth_requests_use_custom_copilot_headers(self, authenticator, mock_http_client): + def test_auth_requests_support_opencode_identity(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", + "verification_uri": "https://github.com/login/device", }, - {"access_token": "access-token"}, - {"token": "api-token", "expires_at": 9999999999}, + {"access_token": "opencode-oauth-token"}, ) 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", + "GITHUB_COPILOT_CLIENT_ID": "Ov23li8tweQw6odWQebz", + "GITHUB_COPILOT_USER_AGENT": "opencode/1.18.7", + "GITHUB_COPILOT_INTEGRATION_ID": "", + "GITHUB_COPILOT_EDITOR_VERSION": "", + "GITHUB_COPILOT_EDITOR_PLUGIN_VERSION": "", + "GITHUB_COPILOT_USE_OAUTH_TOKEN": "true", + "GITHUB_COPILOT_API_BASE": "https://api.githubcopilot.com", } with ( - patch.dict(os.environ, environment), + patch.dict(os.environ, environment, clear=True), patch( "litellm.llms.github_copilot.authenticator._get_httpx_client", return_value=mock_client, ), - patch.object(authenticator, "get_access_token", return_value="github-token"), + patch.object(authenticator, "get_access_token", return_value="opencode-oauth-token"), ): authenticator._get_device_code() - authenticator._poll_for_access_token("dc") - authenticator._refresh_api_key() + assert authenticator._poll_for_access_token("dc") == "opencode-oauth-token" + assert authenticator.get_api_key() == "opencode-oauth-token" + assert authenticator.get_api_base() == "https://api.githubcopilot.com" - 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" + expected_headers = { + "accept": "application/json", + "content-type": "application/json", + "user-agent": "opencode/1.18.7", + } + assert mock_client.post.call_args_list[0].kwargs == { + "headers": expected_headers, + "json": { + "client_id": "Ov23li8tweQw6odWQebz", + "scope": "read:user", + }, + } + assert mock_client.post.call_args_list[1].kwargs == { + "headers": expected_headers, + "json": { + "client_id": "Ov23li8tweQw6odWQebz", + "device_code": "dc", + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + }, + } + mock_client.get.assert_not_called() def test_get_access_token_from_file(self, authenticator): """Test retrieving an access token from a file.""" @@ -164,9 +164,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() @@ -175,9 +173,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(), @@ -275,20 +271,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): @@ -313,8 +303,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 @@ -327,8 +319,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 @@ -337,9 +331,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 @@ -348,9 +344,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 @@ -359,9 +357,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 58d0e1010b7f23d6a9531d22bf12eb36e5feb54d Mon Sep 17 00:00:00 2001 From: codgician <15964984+codgician@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:20:33 +0800 Subject: [PATCH 03/11] refactor(github-copilot): use unified OAuth token flow --- litellm/llms/github_copilot/authenticator.py | 125 ++---------------- .../github_copilot/chat/transformation.py | 8 +- litellm/llms/github_copilot/common_utils.py | 36 ++--- .../embedding/transformation.py | 9 +- .../responses/transformation.py | 9 +- ...github_copilot_embedding_transformation.py | 10 +- ...github_copilot_responses_transformation.py | 118 +++++------------ .../test_github_copilot_authenticator.py | 120 +++-------------- .../test_github_copilot_transformation.py | 54 +++----- 9 files changed, 106 insertions(+), 383 deletions(-) diff --git a/litellm/llms/github_copilot/authenticator.py b/litellm/llms/github_copilot/authenticator.py index 17ef4d6e7c8..27a4651f154 100644 --- a/litellm/llms/github_copilot/authenticator.py +++ b/litellm/llms/github_copilot/authenticator.py @@ -1,8 +1,7 @@ import json import os import time -from datetime import datetime -from typing import Any, Final +from typing import Final import httpx @@ -10,23 +9,16 @@ from litellm._logging import verbose_logger from litellm.llms.custom_httpx.http_handler import _get_httpx_client from .common_utils import ( - APIKeyExpiredError, GetAccessTokenError, GetAPIKeyError, GetDeviceCodeError, - RefreshAPIKeyError, - get_copilot_default_headers, + get_copilot_auth_headers, ) # 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" 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" - - -def _use_oauth_token() -> bool: - return os.getenv("GITHUB_COPILOT_USE_OAUTH_TOKEN", "").strip().lower() in {"1", "true", "yes", "on"} class Authenticator: @@ -41,7 +33,6 @@ class Authenticator: 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")) self._ensure_token_dir() def get_access_token(self) -> str: @@ -72,7 +63,7 @@ class Authenticator: except OSError: verbose_logger.error("Error saving access token to file") return access_token - except (GetDeviceCodeError, GetAccessTokenError, RefreshAPIKeyError) as e: + except (GetDeviceCodeError, GetAccessTokenError) as e: verbose_logger.warning("Failed attempt %s: %s", attempt + 1, e) continue @@ -82,122 +73,24 @@ class Authenticator: ) def get_api_key(self) -> str: - """ - Get the API key, refreshing if necessary. - - Returns: - str: The GitHub Copilot API key. - - Raises: - GetAPIKeyError: If unable to obtain an API key. - """ - if _use_oauth_token(): + try: return self.get_access_token() - try: - with open(self.api_key_file, "r") as f: - api_key_info = json.load(f) - if api_key_info.get("expires_at", 0) > datetime.now().timestamp(): - return api_key_info.get("token") - else: - verbose_logger.warning("API key expired, refreshing") - raise APIKeyExpiredError( - message="API key expired", - status_code=401, - ) - except OSError: - verbose_logger.warning("No API key file found or error opening file") - except (json.JSONDecodeError, KeyError) as e: - verbose_logger.warning("Error reading API key from file: %s", e) - except APIKeyExpiredError: - 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) - token: Final = api_key_info.get("token") - if token: - return token - else: - raise GetAPIKeyError( - message="API key response missing token", - status_code=401, - ) - except OSError as e: - verbose_logger.error("Error saving API key to file: %s", e) + except GetAccessTokenError as 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}", + message=f"Failed to get OAuth access token: {str(e)}", status_code=401, ) def get_api_base(self) -> str | None: - """ - Get the API endpoint from the api-key.json file. - - Returns: - Optional[str]: The GitHub Copilot API endpoint, or None if not found. - """ - if _use_oauth_token(): - return os.getenv("GITHUB_COPILOT_API_BASE") - 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 - except (OSError, json.JSONDecodeError, KeyError) as e: - verbose_logger.warning("Error reading API endpoint from file: %s", e) - return None - - def _refresh_api_key(self) -> dict[str, Any]: - """ - Refresh the API key using the 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) - api_key_url: Final = os.getenv("GITHUB_COPILOT_API_KEY_URL", DEFAULT_GITHUB_API_KEY_URL) - - 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) - response.raise_for_status() - - response_json = response.json() - - if "token" in response_json: - return response_json - else: - verbose_logger.warning("API key response missing token: %s", response_json) - except httpx.HTTPStatusError as e: - verbose_logger.error("HTTP error refreshing API key (attempt %s/%s): %s", attempt + 1, max_retries, e) - except Exception as e: - verbose_logger.error("Unexpected error refreshing API key: %s", e) - - raise RefreshAPIKeyError( - message="Failed to refresh API key after maximum retries", - status_code=401, - ) + return os.getenv("GITHUB_COPILOT_API_BASE") 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) - def _get_github_headers(self, access_token: str | None = None) -> dict[str, str]: - return get_copilot_default_headers(access_token=access_token) + def _get_github_headers(self) -> dict[str, str]: + return get_copilot_auth_headers() def _get_device_code(self) -> dict[str, str]: """ diff --git a/litellm/llms/github_copilot/chat/transformation.py b/litellm/llms/github_copilot/chat/transformation.py index 27a0028ce4a..2130dd10a9f 100644 --- a/litellm/llms/github_copilot/chat/transformation.py +++ b/litellm/llms/github_copilot/chat/transformation.py @@ -1,5 +1,4 @@ import json -import os from typing import Any, Final import httpx @@ -35,12 +34,7 @@ class GithubCopilotConfig(OpenAIConfig): api_key: str | None, custom_llm_provider: str, ) -> tuple[str | None, str | None, str]: - dynamic_api_base: Final = ( - api_base - or self.authenticator.get_api_base() - or os.getenv("GITHUB_COPILOT_API_BASE") - or DEFAULT_GITHUB_COPILOT_API_BASE - ) + dynamic_api_base: Final = self.authenticator.get_api_base() or DEFAULT_GITHUB_COPILOT_API_BASE try: dynamic_api_key: Final = self.authenticator.get_api_key() except GetAPIKeyError as e: diff --git a/litellm/llms/github_copilot/common_utils.py b/litellm/llms/github_copilot/common_utils.py index 2ea87671466..ad5d05184b2 100644 --- a/litellm/llms/github_copilot/common_utils.py +++ b/litellm/llms/github_copilot/common_utils.py @@ -20,13 +20,15 @@ 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 = ( +_COPILOT_AUTH_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), +) +_COPILOT_REQUEST_HEADER_CONFIG = _COPILOT_AUTH_HEADER_CONFIG + ( ("openai-intent", "GITHUB_COPILOT_OPENAI_INTENT", None), ("x-github-api-version", "GITHUB_COPILOT_API_VERSION", None), ( @@ -65,14 +67,6 @@ class GetAccessTokenError(GithubCopilotError): pass -class APIKeyExpiredError(GithubCopilotError): - pass - - -class RefreshAPIKeyError(GithubCopilotError): - pass - - class GetAPIKeyError(GithubCopilotError): pass @@ -84,16 +78,22 @@ def _get_copilot_header_value(environment_variable: str, default: str | None) -> return value or None -def get_copilot_default_headers( - api_key: str | None = None, - *, - access_token: str | None = None, +def _get_configured_copilot_headers( + config: tuple[tuple[str, str, str | None], ...], ) -> dict[str, str]: - configured_headers = { + return { header: value - for header, environment_variable, default in _COPILOT_HEADER_CONFIG + for header, environment_variable, default in 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} + + +def get_copilot_auth_headers() -> dict[str, str]: + return _get_configured_copilot_headers(_COPILOT_AUTH_HEADER_CONFIG) + + +def get_copilot_default_headers(api_key: str) -> dict[str, str]: + return { + **_get_configured_copilot_headers(_COPILOT_REQUEST_HEADER_CONFIG), + "Authorization": f"Bearer {api_key}", + } diff --git a/litellm/llms/github_copilot/embedding/transformation.py b/litellm/llms/github_copilot/embedding/transformation.py index 7ea7a89b4ca..e5e35059ce2 100644 --- a/litellm/llms/github_copilot/embedding/transformation.py +++ b/litellm/llms/github_copilot/embedding/transformation.py @@ -7,7 +7,6 @@ Implementation based on analysis of the copilot-api project by caozhiyuan: https://github.com/caozhiyuan/copilot-api """ -import os from typing import TYPE_CHECKING, Any, Final import httpx @@ -98,13 +97,7 @@ class GithubCopilotEmbeddingConfig(BaseEmbeddingConfig): """ Get the complete URL for GitHub Copilot Embedding API endpoint. """ - # Use provided api_base or fall back to authenticator's base or default - effective_api_base = ( - api_base - or self.authenticator.get_api_base() - or os.getenv("GITHUB_COPILOT_API_BASE") - or DEFAULT_GITHUB_COPILOT_API_BASE - ) + effective_api_base = self.authenticator.get_api_base() or DEFAULT_GITHUB_COPILOT_API_BASE # Remove trailing slashes effective_api_base = effective_api_base.rstrip("/") diff --git a/litellm/llms/github_copilot/responses/transformation.py b/litellm/llms/github_copilot/responses/transformation.py index 5a4bb798851..526b240354f 100644 --- a/litellm/llms/github_copilot/responses/transformation.py +++ b/litellm/llms/github_copilot/responses/transformation.py @@ -8,7 +8,6 @@ Implementation based on analysis of the copilot-api project by caozhiyuan: https://github.com/caozhiyuan/copilot-api """ -import os from typing import TYPE_CHECKING, Any, Final import litellm @@ -248,13 +247,7 @@ class GithubCopilotResponsesAPIConfig(OpenAIResponsesAPIConfig): """ Get the complete URL for GitHub Copilot Responses API endpoint. """ - # Use provided api_base or fall back to authenticator's base or default - effective_api_base = ( - api_base - or self.authenticator.get_api_base() - or os.getenv("GITHUB_COPILOT_API_BASE") - or DEFAULT_GITHUB_COPILOT_API_BASE - ) + effective_api_base = self.authenticator.get_api_base() or DEFAULT_GITHUB_COPILOT_API_BASE # Remove trailing slashes effective_api_base = effective_api_base.rstrip("/") 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 2a40e487c0d..87921a66297 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 @@ -76,9 +76,7 @@ def test_github_copilot_embedding_config_get_complete_url(): assert url == "https://api.githubcopilot.com/embeddings" # Test with custom API base from authenticator - config.authenticator.get_api_base.return_value = ( - "https://api.enterprise.githubcopilot.com" - ) + config.authenticator.get_api_base.return_value = "https://api.enterprise.githubcopilot.com" url = config.get_complete_url( api_base=None, api_key=None, @@ -88,16 +86,14 @@ def test_github_copilot_embedding_config_get_complete_url(): ) assert url == "https://api.enterprise.githubcopilot.com/embeddings" - # Test with custom API base from params - config.authenticator.get_api_base.return_value = None url = config.get_complete_url( - api_base="https://custom.api.com", + api_base="https://untrusted.example.com", api_key=None, model="github_copilot/text-embedding-3-small", optional_params={}, litellm_params={}, ) - assert url == "https://custom.api.com/embeddings" + assert url == "https://api.enterprise.githubcopilot.com/embeddings" def test_github_copilot_embedding_config_transform_request(): 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 d20c9ad5386..de0fc41a6a9 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 @@ -29,9 +29,7 @@ def use_local_model_cost_map(monkeypatch: pytest.MonkeyPatch): """Pin litellm.model_cost to the bundled local backup so tests don't depend on remote catalog fetches (and don't change behavior across remote refreshes).""" monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") - monkeypatch.setattr( - litellm, "model_cost", get_model_cost_map(url=litellm.model_cost_map_url) - ) + monkeypatch.setattr(litellm, "model_cost", get_model_cost_map(url=litellm.model_cost_map_url)) litellm.add_known_models(model_cost_map=litellm.model_cost) @@ -47,49 +45,33 @@ class TestGithubCopilotResponsesAPITransformation: provider=LlmProviders.GITHUB_COPILOT, ) - assert ( - config is not None - ), "Config should not be None for GitHub Copilot provider" - assert isinstance( - config, GithubCopilotResponsesAPIConfig - ), f"Expected GithubCopilotResponsesAPIConfig, got {type(config)}" - assert ( - config.custom_llm_provider == LlmProviders.GITHUB_COPILOT - ), "custom_llm_provider should be GITHUB_COPILOT" + assert config is not None, "Config should not be None for GitHub Copilot provider" + assert isinstance(config, GithubCopilotResponsesAPIConfig), ( + f"Expected GithubCopilotResponsesAPIConfig, got {type(config)}" + ) + assert config.custom_llm_provider == LlmProviders.GITHUB_COPILOT, "custom_llm_provider should be GITHUB_COPILOT" @patch("litellm.llms.github_copilot.responses.transformation.Authenticator") def test_github_copilot_responses_endpoint_url(self, mock_authenticator_class): """Test that get_complete_url returns correct GitHub Copilot endpoint""" # Mock authenticator to return default base mock_auth_instance = MagicMock() - mock_auth_instance.get_api_base.return_value = ( - "https://api.individual.githubcopilot.com" - ) + mock_auth_instance.get_api_base.return_value = "https://api.individual.githubcopilot.com" mock_authenticator_class.return_value = mock_auth_instance config = GithubCopilotResponsesAPIConfig() # Test with default GitHub Copilot API base (from authenticator) url = config.get_complete_url(api_base=None, litellm_params={}) - assert ( - url == "https://api.individual.githubcopilot.com/responses" - ), f"Expected GitHub Copilot responses endpoint, got {url}" - - # Test with custom api_base (overrides authenticator) - custom_url = config.get_complete_url( - api_base="https://custom.githubcopilot.com", litellm_params={} + assert url == "https://api.individual.githubcopilot.com/responses", ( + f"Expected GitHub Copilot responses endpoint, got {url}" ) - assert ( - custom_url == "https://custom.githubcopilot.com/responses" - ), f"Expected custom endpoint, got {custom_url}" - # Test with trailing slash - url_with_slash = config.get_complete_url( - api_base="https://api.githubcopilot.com/", litellm_params={} - ) - assert ( - url_with_slash == "https://api.githubcopilot.com/responses" - ), "Should handle trailing slash" + custom_url = config.get_complete_url(api_base="https://untrusted.example.com", litellm_params={}) + assert custom_url == "https://api.individual.githubcopilot.com/responses" + + url_with_slash = config.get_complete_url(api_base="https://untrusted.example.com/", litellm_params={}) + assert url_with_slash == "https://api.individual.githubcopilot.com/responses" @patch("litellm.llms.github_copilot.responses.transformation.Authenticator") def test_validate_environment_default_headers(self, mock_authenticator_class, monkeypatch): @@ -105,9 +87,7 @@ class TestGithubCopilotResponsesAPITransformation: mock_authenticator_class.return_value = mock_auth_instance config = GithubCopilotResponsesAPIConfig() - headers = config.validate_environment( - headers={}, model="gpt-5.1-codex", litellm_params={} - ) + headers = config.validate_environment(headers={}, model="gpt-5.1-codex", litellm_params={}) assert headers["Authorization"] == "Bearer test-api-key-123" assert headers["accept"] == "application/json" @@ -181,9 +161,7 @@ class TestGithubCopilotResponsesAPITransformation: "custom-header": "custom-value", } - headers = config.validate_environment( - headers=custom_headers, model="gpt-5.1-codex", litellm_params={} - ) + headers = config.validate_environment(headers=custom_headers, model="gpt-5.1-codex", litellm_params={}) # User header should override default assert headers["editor-version"] == "custom/2.0.0" @@ -236,9 +214,7 @@ class TestGithubCopilotResponsesAPITransformation: """Test _has_vision_input detects input_image type""" config = GithubCopilotResponsesAPIConfig() - input_with_vision = [ - {"role": "user", "content": [{"type": "input_image", "data": "base64..."}]} - ] + input_with_vision = [{"role": "user", "content": [{"type": "input_image", "data": "base64..."}]}] has_vision = config._has_vision_input(input_with_vision) assert has_vision is True, "Should detect input_image type" @@ -300,13 +276,11 @@ class TestGithubCopilotResponsesAPITransformation: } ] - headers = config.validate_environment( - headers={}, model="gpt-5.1-codex", litellm_params=mock_litellm_params - ) + headers = config.validate_environment(headers={}, model="gpt-5.1-codex", litellm_params=mock_litellm_params) - assert ( - headers.get("copilot-vision-request") == "true" - ), "Should add copilot-vision-request header for vision input" + assert headers.get("copilot-vision-request") == "true", ( + "Should add copilot-vision-request header for vision input" + ) @patch("litellm.llms.github_copilot.responses.transformation.Authenticator") def test_validate_environment_with_x_initiator(self, mock_authenticator_class): @@ -324,21 +298,15 @@ class TestGithubCopilotResponsesAPITransformation: {"role": "assistant", "content": "Hi"}, ] - headers = config.validate_environment( - headers={}, model="gpt-5.1-codex", litellm_params=mock_litellm_params - ) + headers = config.validate_environment(headers={}, model="gpt-5.1-codex", litellm_params=mock_litellm_params) - assert ( - headers.get("X-Initiator") == "agent" - ), "Should set X-Initiator to 'agent' for assistant role" + assert headers.get("X-Initiator") == "agent", "Should set X-Initiator to 'agent' for assistant role" def test_map_openai_params_no_transformation(self): """Test that map_openai_params passes through parameters unchanged""" config = GithubCopilotResponsesAPIConfig() - params = ResponsesAPIOptionalRequestParams( - temperature=0.7, max_output_tokens=1000, stream=False - ) + params = ResponsesAPIOptionalRequestParams(temperature=0.7, max_output_tokens=1000, stream=False) result = config.map_openai_params( response_api_optional_params=params, @@ -392,9 +360,9 @@ class TestGithubCopilotResponsesAPITransformation: result = config._handle_reasoning_item(reasoning_item) # encrypted_content should be preserved - assert ( - result.get("encrypted_content") == "encrypted-blob-abc123" - ), "encrypted_content must be preserved for GitHub Copilot multi-turn conversations" + assert result.get("encrypted_content") == "encrypted-blob-abc123", ( + "encrypted_content must be preserved for GitHub Copilot multi-turn conversations" + ) # status=None should be filtered out assert "status" not in result, "status=None should be filtered out" # content=None should be filtered out @@ -447,9 +415,7 @@ class TestGithubCopilotResponsesAPIRouting: in the (already-merged) model info; otherwise returns None so the dispatcher routes through the chat-completions translation bridge.""" - @patch( - "litellm.llms.github_copilot.responses.transformation._cached_get_model_info_helper" - ) + @patch("litellm.llms.github_copilot.responses.transformation._cached_get_model_info_helper") def test_returns_config_when_mode_is_responses(self, mock_get_info): """``mode=responses`` returns native config.""" mock_get_info.return_value = {"mode": "responses"} @@ -459,9 +425,7 @@ class TestGithubCopilotResponsesAPIRouting: ) assert isinstance(config, GithubCopilotResponsesAPIConfig) - @patch( - "litellm.llms.github_copilot.responses.transformation._cached_get_model_info_helper" - ) + @patch("litellm.llms.github_copilot.responses.transformation._cached_get_model_info_helper") def test_returns_none_when_mode_is_chat(self, mock_get_info): """``mode=chat`` returns None so dispatcher uses bridge.""" mock_get_info.return_value = {"mode": "chat"} @@ -471,9 +435,7 @@ class TestGithubCopilotResponsesAPIRouting: ) assert config is None - @patch( - "litellm.llms.github_copilot.responses.transformation._cached_get_model_info_helper" - ) + @patch("litellm.llms.github_copilot.responses.transformation._cached_get_model_info_helper") def test_returns_none_when_mode_is_unset_and_no_endpoints(self, mock_get_info): """Entry without ``mode`` and without ``supported_endpoints`` returns None (conservative default).""" @@ -554,9 +516,7 @@ class TestGithubCopilotResponsesAPIRouting: ) assert isinstance(config, GithubCopilotResponsesAPIConfig) - @patch( - "litellm.llms.github_copilot.responses.transformation._cached_get_model_info_helper" - ) + @patch("litellm.llms.github_copilot.responses.transformation._cached_get_model_info_helper") def test_returns_none_when_get_model_info_raises(self, mock_get_info): """Catalog lookup failure (model not registered) returns None (conservative default; bridge handles unknown models safely).""" @@ -567,9 +527,7 @@ class TestGithubCopilotResponsesAPIRouting: ) assert config is None - @patch( - "litellm.llms.github_copilot.responses.transformation._cached_get_model_info_helper" - ) + @patch("litellm.llms.github_copilot.responses.transformation._cached_get_model_info_helper") def test_user_override_via_register_model(self, mock_get_info): """User-supplied per-deployment ``model_info`` flows through ``litellm.register_model`` (called by the router) into the merged @@ -583,9 +541,7 @@ class TestGithubCopilotResponsesAPIRouting: ) assert isinstance(config, GithubCopilotResponsesAPIConfig) - @patch( - "litellm.llms.github_copilot.responses.transformation._cached_get_model_info_helper" - ) + @patch("litellm.llms.github_copilot.responses.transformation._cached_get_model_info_helper") def test_realistic_chat_only_entry_returns_none(self, mock_get_info): """Realistic ``model_prices_and_context_window.json`` shape for a chat-only Copilot model (e.g. github_copilot/gemini-3.1-pro-preview) @@ -609,9 +565,7 @@ class TestGithubCopilotResponsesAPIRouting: ) assert config is None - @patch( - "litellm.llms.github_copilot.responses.transformation._cached_get_model_info_helper" - ) + @patch("litellm.llms.github_copilot.responses.transformation._cached_get_model_info_helper") def test_realistic_responses_only_entry_returns_config(self, mock_get_info): """Realistic catalog entry for a Responses-only Copilot model (e.g. github_copilot/gpt-5.5) returns the native config.""" @@ -647,9 +601,7 @@ class TestGithubCopilotReasoningStreamItemIdNormalization: output_index group to the id from its output_item.added.""" def _config(self): - with patch( - "litellm.llms.github_copilot.responses.transformation.Authenticator" - ): + with patch("litellm.llms.github_copilot.responses.transformation.Authenticator"): return GithubCopilotResponsesAPIConfig() def _transform(self, config, chunk): 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 c378402324d..7b42a774b43 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,18 +1,14 @@ -import json import os -import time -from datetime import datetime, timedelta from unittest.mock import MagicMock, mock_open, patch import pytest from litellm.llms.github_copilot.authenticator import Authenticator from litellm.llms.github_copilot.common_utils import ( - APIKeyExpiredError, GetAccessTokenError, GetAPIKeyError, GetDeviceCodeError, - RefreshAPIKeyError, + get_copilot_default_headers, ) @@ -45,7 +41,6 @@ class TestGitHubCopilotAuthenticator: 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() def test_ensure_token_dir(self): @@ -68,9 +63,6 @@ class TestGitHubCopilotAuthenticator: "user-agent": "GitHubCopilotChat/0.44.0", } - headers_with_token = authenticator._get_github_headers("test-token") - assert headers_with_token["Authorization"] == "token test-token" - def test_auth_requests_support_opencode_identity(self, authenticator, mock_http_client): mock_client, mock_response = mock_http_client mock_response.json.side_effect = ( @@ -87,7 +79,8 @@ class TestGitHubCopilotAuthenticator: "GITHUB_COPILOT_INTEGRATION_ID": "", "GITHUB_COPILOT_EDITOR_VERSION": "", "GITHUB_COPILOT_EDITOR_PLUGIN_VERSION": "", - "GITHUB_COPILOT_USE_OAUTH_TOKEN": "true", + "GITHUB_COPILOT_API_VERSION": "2026-06-01", + "GITHUB_COPILOT_OPENAI_INTENT": "conversation-edits", "GITHUB_COPILOT_API_BASE": "https://api.githubcopilot.com", } @@ -103,27 +96,34 @@ class TestGitHubCopilotAuthenticator: assert authenticator._poll_for_access_token("dc") == "opencode-oauth-token" assert authenticator.get_api_key() == "opencode-oauth-token" assert authenticator.get_api_base() == "https://api.githubcopilot.com" + request_headers = get_copilot_default_headers("opencode-oauth-token") - expected_headers = { + expected_auth_headers = { "accept": "application/json", "content-type": "application/json", "user-agent": "opencode/1.18.7", } assert mock_client.post.call_args_list[0].kwargs == { - "headers": expected_headers, + "headers": expected_auth_headers, "json": { "client_id": "Ov23li8tweQw6odWQebz", "scope": "read:user", }, } assert mock_client.post.call_args_list[1].kwargs == { - "headers": expected_headers, + "headers": expected_auth_headers, "json": { "client_id": "Ov23li8tweQw6odWQebz", "device_code": "dc", "grant_type": "urn:ietf:params:oauth:grant-type:device_code", }, } + assert request_headers == { + **expected_auth_headers, + "openai-intent": "conversation-edits", + "x-github-api-version": "2026-06-01", + "Authorization": "Bearer opencode-oauth-token", + } mock_client.get.assert_not_called() def test_get_access_token_from_file(self, authenticator): @@ -161,68 +161,14 @@ class TestGitHubCopilotAuthenticator: authenticator.get_access_token() assert authenticator._login.call_count == 3 - 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}) - - with patch("builtins.open", mock_open(read_data=mock_api_key_data)): - api_key = authenticator.get_api_key() - assert api_key == "mock-api-key" - - 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_new_data = { - "token": "new-api-key", - "expires_at": (datetime.now() + timedelta(hours=1)).timestamp(), - } - - 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, + def test_get_api_key_maps_access_token_failure(self, authenticator): + with patch.object( + authenticator, + "get_access_token", + side_effect=GetAccessTokenError(message="OAuth failed", status_code=401), ): - api_key = authenticator.get_api_key() - assert api_key == "new-api-key" - authenticator._refresh_api_key.assert_called_once() - - def test_refresh_api_key(self, authenticator, mock_http_client): - """Test refreshing an API key.""" - mock_client, mock_response = mock_http_client - mock_token = "mock-access-token" - mock_api_key_data = {"token": "new-api-key", "expires_at": 12345} - - with ( - patch.object(authenticator, "get_access_token", return_value=mock_token), - patch( - "litellm.llms.github_copilot.authenticator._get_httpx_client", - return_value=mock_client, - ), - patch.object(mock_response, "json", return_value=mock_api_key_data), - ): - result = authenticator._refresh_api_key() - assert result == mock_api_key_data - mock_client.get.assert_called_once() - authenticator.get_access_token.assert_called_once() - - def test_refresh_api_key_failure(self, authenticator, mock_http_client): - """Test failure to refresh an API key.""" - mock_client, mock_response = mock_http_client - mock_token = "mock-access-token" - - with ( - patch.object(authenticator, "get_access_token", return_value=mock_token), - patch( - "litellm.llms.github_copilot.authenticator._get_httpx_client", - return_value=mock_client, - ), - patch.object(mock_response, "json", return_value={}), - ): - with pytest.raises(RefreshAPIKeyError): - authenticator._refresh_api_key() - assert mock_client.get.call_count == 3 + with pytest.raises(GetAPIKeyError, match="Failed to get OAuth access token"): + authenticator.get_api_key() def test_get_device_code(self, authenticator, mock_http_client): """Test getting a device code.""" @@ -281,19 +227,6 @@ class TestGitHubCopilotAuthenticator: 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): - """Test retrieving the API base endpoint from a file.""" - mock_api_key_data = json.dumps( - { - "token": "mock-api-key", - "expires_at": (datetime.now() + timedelta(hours=1)).timestamp(), - "endpoints": {"api": "https://api.enterprise.githubcopilot.com"}, - } - ) - with patch("builtins.open", mock_open(read_data=mock_api_key_data)): - api_base = authenticator.get_api_base() - assert api_base == "https://api.enterprise.githubcopilot.com" - def test_get_device_code_with_custom_url(self, authenticator, mock_http_client): """GITHUB_COPILOT_DEVICE_CODE_URL env var must be used by _get_device_code at call time.""" mock_client, mock_response = mock_http_client @@ -351,16 +284,3 @@ class TestGitHubCopilotAuthenticator: ): authenticator._poll_for_access_token("dc") assert mock_client.post.call_args[1]["json"]["client_id"] == custom_id - - def test_refresh_api_key_with_custom_url(self, authenticator, mock_http_client): - """GITHUB_COPILOT_API_KEY_URL env var must be used by _refresh_api_key at call time.""" - 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"), - ): - authenticator._refresh_api_key() - assert mock_client.get.call_args[0][0] == custom_url diff --git a/tests/test_litellm/llms/github_copilot/test_github_copilot_transformation.py b/tests/test_litellm/llms/github_copilot/test_github_copilot_transformation.py index f69ba7df938..856a99d0859 100644 --- a/tests/test_litellm/llms/github_copilot/test_github_copilot_transformation.py +++ b/tests/test_litellm/llms/github_copilot/test_github_copilot_transformation.py @@ -23,11 +23,9 @@ from litellm.exceptions import AuthenticationError from litellm.llms.github_copilot.authenticator import Authenticator from litellm.llms.github_copilot.chat.transformation import GithubCopilotConfig from litellm.llms.github_copilot.common_utils import ( - APIKeyExpiredError, GetAccessTokenError, GetAPIKeyError, GetDeviceCodeError, - RefreshAPIKeyError, ) @@ -41,9 +39,7 @@ def test_github_copilot_config_get_openai_compatible_provider_info(): config.authenticator = MagicMock() config.authenticator.get_api_key.return_value = mock_api_key # Test with dynamic endpoint - config.authenticator.get_api_base.return_value = ( - "https://api.enterprise.githubcopilot.com" - ) + config.authenticator.get_api_base.return_value = "https://api.enterprise.githubcopilot.com" # Test with default values model = "github_copilot/gpt-4" @@ -61,6 +57,13 @@ def test_github_copilot_config_get_openai_compatible_provider_info(): assert api_base == "https://api.enterprise.githubcopilot.com" assert dynamic_api_key == mock_api_key assert custom_llm_provider == "github_copilot" + api_base, _, _ = config._get_openai_compatible_provider_info( + model=model, + api_base="https://untrusted.example.com", + api_key=None, + custom_llm_provider="github_copilot", + ) + assert api_base == "https://api.enterprise.githubcopilot.com" # Test fallback to default if no dynamic endpoint config.authenticator.get_api_base.return_value = None @@ -162,25 +165,19 @@ def test_transform_messages_disable_copilot_system_to_assistant(monkeypatch): {"role": "system", "content": "System message."}, {"role": "user", "content": "User message."}, ] - out = config._transform_messages( - [m.copy() for m in messages], model="github_copilot/gpt-4" - ) + out = config._transform_messages([m.copy() for m in messages], model="github_copilot/gpt-4") assert out[0]["role"] == "assistant" assert out[1]["role"] == "user" # Case 2: Flag is True (conversion does not happen) litellm.disable_copilot_system_to_assistant = True - out = config._transform_messages( - [m.copy() for m in messages], model="github_copilot/gpt-4" - ) + out = config._transform_messages([m.copy() for m in messages], model="github_copilot/gpt-4") assert out[0]["role"] == "system" assert out[1]["role"] == "user" # Case 3: Flag is False again (conversion happens) litellm.disable_copilot_system_to_assistant = False - out = config._transform_messages( - [m.copy() for m in messages], model="github_copilot/gpt-4" - ) + out = config._transform_messages([m.copy() for m in messages], model="github_copilot/gpt-4") assert out[0]["role"] == "assistant" assert out[1]["role"] == "user" finally: @@ -385,9 +382,7 @@ def test_get_supported_openai_params_claude_model(): assert "reasoning_effort" in supported_params # Test Claude 3-7 model supports thinking and reasoning_effort parameters - supported_params_claude37 = config.get_supported_openai_params( - "claude-3-7-sonnet-20250219" - ) + supported_params_claude37 = config.get_supported_openai_params("claude-3-7-sonnet-20250219") assert "thinking" in supported_params_claude37 assert "reasoning_effort" in supported_params_claude37 @@ -414,16 +409,12 @@ def test_get_supported_openai_params_case_insensitive(): config = GithubCopilotConfig() # Test uppercase Claude 4 model with full model name - supported_params_upper = config.get_supported_openai_params( - "CLAUDE-SONNET-4-20250514" - ) + supported_params_upper = config.get_supported_openai_params("CLAUDE-SONNET-4-20250514") assert "thinking" in supported_params_upper assert "reasoning_effort" in supported_params_upper # Test mixed case Claude 3-7 model (has extended thinking) with full model name - supported_params_mixed = config.get_supported_openai_params( - "Claude-3-7-Sonnet-20250219" - ) + supported_params_mixed = config.get_supported_openai_params("Claude-3-7-Sonnet-20250219") assert "thinking" in supported_params_mixed assert "reasoning_effort" in supported_params_mixed @@ -757,13 +748,8 @@ class TestGithubCopilotTransformResponse: assert result.choices[0].message.tool_calls is not None assert len(result.choices[0].message.tool_calls) == 1 assert result.choices[0].message.tool_calls[0]["id"] == "toolu_01ABC" - assert ( - result.choices[0].message.tool_calls[0]["function"]["name"] == "get_weather" - ) - assert ( - '"Boston, MA"' - in result.choices[0].message.tool_calls[0]["function"]["arguments"] - ) + assert result.choices[0].message.tool_calls[0]["function"]["name"] == "get_weather" + assert '"Boston, MA"' in result.choices[0].message.tool_calls[0]["function"]["arguments"] def test_transform_response_anthropic_native_multiple_text_blocks(self): """All text blocks must be concatenated, not only the first.""" @@ -931,12 +917,8 @@ class TestGithubCopilotTransformParsedResponseDict: @patch("litellm.llms.openai.openai.OpenAIChatCompletion._get_openai_client") -@patch( - "litellm.llms.openai.openai.OpenAIChatCompletion.make_sync_openai_chat_completion_request" -) -def test_openai_handler_repairs_github_copilot_empty_choices( - mock_request, mock_get_client -): +@patch("litellm.llms.openai.openai.OpenAIChatCompletion.make_sync_openai_chat_completion_request") +def test_openai_handler_repairs_github_copilot_empty_choices(mock_request, mock_get_client): """ The OpenAI SDK handler calls convert_to_model_response_object directly on the SDK's parsed output, bypassing transform_response. convert raises APIError on From 23f01440dbe715fd38d5b14b5ccfc0029627f1ec Mon Sep 17 00:00:00 2001 From: codgician <15964984+codgician@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:51:48 +0800 Subject: [PATCH 04/11] fix(github-copilot): preserve legacy endpoint routing --- litellm/llms/github_copilot/authenticator.py | 34 ++++++++++++++++- .../test_github_copilot_authenticator.py | 37 +++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) 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 == { From cca3c8a4a7b4464cf5b0108f3687b0c208deedd9 Mon Sep 17 00:00:00 2001 From: codgician <15964984+codgician@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:13:34 +0800 Subject: [PATCH 05/11] test(github-copilot): cover invalid legacy cache --- .../test_github_copilot_authenticator.py | 11 +++++++++++ 1 file changed, 11 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 422b5e7dfbe..56c6dae4189 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 @@ -73,6 +73,17 @@ class TestGitHubCopilotAuthenticator: ): assert authenticator.get_api_base() == "https://api.enterprise.githubcopilot.com" + def test_get_api_base_ignores_invalid_legacy_cache(self, authenticator): + with ( + patch.dict(os.environ, {}, clear=True), + patch("builtins.open", mock_open(read_data="not-json")), + patch("litellm.llms.github_copilot.authenticator.verbose_logger.warning") as mock_warning, + ): + assert authenticator.get_api_base() is None + + assert mock_warning.call_count == 1 + assert "Error reading legacy GitHub Copilot API endpoint" in mock_warning.call_args.args[0] + def test_deprecated_api_key_url_warns(self): with ( patch.dict( From 1df687b02bd832368e6a760e7cbf07da525f9ef4 Mon Sep 17 00:00:00 2001 From: codgician <15964984+codgician@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:54:12 +0800 Subject: [PATCH 06/11] test(github-copilot): cover OAuth token persistence --- .../test_github_copilot_authenticator.py | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) 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 56c6dae4189..a165a9f7d46 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 @@ -183,17 +183,32 @@ class TestGitHubCopilotAuthenticator: assert token == mock_token def test_get_access_token_login(self, authenticator): - """Test logging in to get an access token.""" + mock_token = "mock-access-token" + write_open = mock_open() + + with ( + patch.object(authenticator, "_login", return_value=mock_token) as mock_login, + patch("builtins.open", side_effect=(IOError, write_open.return_value)), + ): + token = authenticator.get_access_token() + + assert token == mock_token + mock_login.assert_called_once() + write_open().write.assert_called_once_with(mock_token) + + def test_get_access_token_survives_persistence_failure(self, authenticator): mock_token = "mock-access-token" with ( - patch.object(authenticator, "_login", return_value=mock_token), - patch("builtins.open", mock_open()), - patch("builtins.open", side_effect=IOError) as mock_read, + patch.object(authenticator, "_login", return_value=mock_token) as mock_login, + patch("builtins.open", side_effect=IOError), + patch("litellm.llms.github_copilot.authenticator.verbose_logger.error") as mock_error, ): token = authenticator.get_access_token() - assert token == mock_token - authenticator._login.assert_called_once() + + assert token == mock_token + mock_login.assert_called_once() + mock_error.assert_called_once_with("Error saving access token to file") def test_get_access_token_failure(self, authenticator): """Test that an exception is raised after multiple login failures.""" From 8e35653b556d98f36870e2fc15927dcb6cf1e26c Mon Sep 17 00:00:00 2001 From: codgician <15964984+codgician@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:10:14 +0800 Subject: [PATCH 07/11] fix(github-copilot): validate OAuth API base --- litellm/llms/github_copilot/authenticator.py | 48 +++++++--------- .../test_github_copilot_authenticator.py | 56 +++++++------------ 2 files changed, 39 insertions(+), 65 deletions(-) diff --git a/litellm/llms/github_copilot/authenticator.py b/litellm/llms/github_copilot/authenticator.py index 2b96ee6d79a..3ad35ebd620 100644 --- a/litellm/llms/github_copilot/authenticator.py +++ b/litellm/llms/github_copilot/authenticator.py @@ -2,10 +2,9 @@ import json import os import time from typing import Final +from urllib.parse import urlsplit 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 @@ -18,22 +17,24 @@ from .common_utils import ( ) -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" DEFAULT_GITHUB_ACCESS_TOKEN_URL: Final = "https://github.com/login/oauth/access_token" +def _is_secure_api_base(api_base: str) -> bool: + parsed_api_base = urlsplit(api_base) + return ( + parsed_api_base.scheme.lower() == "https" + and parsed_api_base.hostname is not None + and parsed_api_base.username is None + and parsed_api_base.password is None + and not parsed_api_base.query + and not parsed_api_base.fragment + ) + + class Authenticator: def __init__(self) -> None: """Initialize the GitHub Copilot authenticator with configurable token paths.""" @@ -46,14 +47,6 @@ 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: @@ -104,17 +97,14 @@ class Authenticator: def get_api_base(self) -> str | None: 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: + if configured_api_base is None: return None - except ValidationError as e: - verbose_logger.warning(f"Error reading legacy GitHub Copilot API endpoint: {str(e)}") + if not _is_secure_api_base(configured_api_base): + verbose_logger.warning( + "Ignoring GITHUB_COPILOT_API_BASE because it must be an HTTPS URL without credentials, query, or fragment" + ) return None - return legacy_cache.get("endpoints", {}).get("api") + return configured_api_base 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 a165a9f7d46..79fe94dc5c1 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,7 +41,6 @@ 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): @@ -54,52 +53,37 @@ class TestGitHubCopilotAuthenticator: 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, + with patch.dict( + os.environ, + {"GITHUB_COPILOT_API_BASE": "https://configured.githubcopilot.example"}, + clear=True, ): 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"}}' + @pytest.mark.parametrize( + "api_base", + ( + "http://api.githubcopilot.com", + "https://user:password@api.githubcopilot.com", + "https://api.githubcopilot.com?tenant=example", + "https://api.githubcopilot.com#fragment", + ), + ) + def test_get_api_base_rejects_insecure_configuration(self, authenticator, api_base): 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_get_api_base_ignores_invalid_legacy_cache(self, authenticator): - with ( - patch.dict(os.environ, {}, clear=True), - patch("builtins.open", mock_open(read_data="not-json")), + patch.dict(os.environ, {"GITHUB_COPILOT_API_BASE": api_base}, clear=True), patch("litellm.llms.github_copilot.authenticator.verbose_logger.warning") as mock_warning, ): assert authenticator.get_api_base() is None - assert mock_warning.call_count == 1 - assert "Error reading legacy GitHub Copilot API endpoint" in mock_warning.call_args.args[0] - - 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" + "Ignoring GITHUB_COPILOT_API_BASE because it must be an HTTPS URL without credentials, query, or fragment" ) + def test_get_api_base_uses_default_when_unconfigured(self, authenticator): + with patch.dict(os.environ, {}, clear=True): + assert authenticator.get_api_base() is None + def test_get_github_headers(self, authenticator): headers = authenticator._get_github_headers() assert headers == { From 197592495615d85e6086ed11f13fe5db6de1ec12 Mon Sep 17 00:00:00 2001 From: codgician <15964984+codgician@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:27:33 +0800 Subject: [PATCH 08/11] fix(github-copilot): constrain OAuth endpoints --- litellm/llms/github_copilot/authenticator.py | 68 +++++++++++++------ .../github_copilot/chat/transformation.py | 2 +- .../embedding/transformation.py | 2 +- .../responses/transformation.py | 2 +- ...github_copilot_embedding_transformation.py | 8 ++- ...github_copilot_responses_transformation.py | 12 ++-- .../test_github_copilot_authenticator.py | 52 +++++++++++++- .../test_github_copilot_transformation.py | 10 +-- 8 files changed, 119 insertions(+), 37 deletions(-) diff --git a/litellm/llms/github_copilot/authenticator.py b/litellm/llms/github_copilot/authenticator.py index 3ad35ebd620..e7d84835fee 100644 --- a/litellm/llms/github_copilot/authenticator.py +++ b/litellm/llms/github_copilot/authenticator.py @@ -23,15 +23,43 @@ 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" -def _is_secure_api_base(api_base: str) -> bool: - parsed_api_base = urlsplit(api_base) - return ( - parsed_api_base.scheme.lower() == "https" - and parsed_api_base.hostname is not None - and parsed_api_base.username is None - and parsed_api_base.password is None - and not parsed_api_base.query - and not parsed_api_base.fragment +def _https_hostname(url: str) -> str | None: + parsed_url = urlsplit(url) + if ( + parsed_url.scheme.lower() != "https" + or parsed_url.hostname is None + or parsed_url.username is not None + or parsed_url.password is not None + or parsed_url.query + or parsed_url.fragment + ): + return None + return parsed_url.hostname.lower() + + +def _configured_allowed_api_hosts() -> frozenset[str]: + configured_hosts = os.getenv("GITHUB_COPILOT_ALLOWED_API_HOSTS", "") + return frozenset(host.strip().lower() for host in configured_hosts.split(",") if host.strip()) + + +def _configured_oauth_hosts() -> tuple[str, ...]: + oauth_urls = ( + os.getenv("GITHUB_COPILOT_DEVICE_CODE_URL", DEFAULT_GITHUB_DEVICE_CODE_URL), + os.getenv("GITHUB_COPILOT_ACCESS_TOKEN_URL", DEFAULT_GITHUB_ACCESS_TOKEN_URL), + ) + return tuple(hostname for url in oauth_urls if (hostname := _https_hostname(url)) is not None) + + +def _is_trusted_api_base(api_base: str) -> bool: + hostname = _https_hostname(api_base) + if hostname is None: + return False + if hostname == "githubcopilot.com" or hostname.endswith(".githubcopilot.com"): + return True + if hostname in _configured_allowed_api_hosts(): + return True + return any( + hostname == oauth_host or hostname.endswith(f".{oauth_host}") for oauth_host in _configured_oauth_hosts() ) @@ -95,16 +123,18 @@ class Authenticator: status_code=401, ) - def get_api_base(self) -> str | None: - configured_api_base = os.getenv("GITHUB_COPILOT_API_BASE") - if configured_api_base is None: - return None - if not _is_secure_api_base(configured_api_base): - verbose_logger.warning( - "Ignoring GITHUB_COPILOT_API_BASE because it must be an HTTPS URL without credentials, query, or fragment" - ) - return None - return configured_api_base + def get_api_base(self, api_base: str | None = None) -> str | None: + candidates = ( + ("deployment api_base", api_base), + ("GITHUB_COPILOT_API_BASE", os.getenv("GITHUB_COPILOT_API_BASE")), + ) + for source, candidate in candidates: + if candidate is None: + continue + if _is_trusted_api_base(candidate): + return candidate + verbose_logger.warning(f"Ignoring {source} because it is not a trusted HTTPS GitHub Copilot endpoint") + return None def _ensure_token_dir(self) -> None: """Ensure the token directory exists.""" diff --git a/litellm/llms/github_copilot/chat/transformation.py b/litellm/llms/github_copilot/chat/transformation.py index 2130dd10a9f..0609de186fa 100644 --- a/litellm/llms/github_copilot/chat/transformation.py +++ b/litellm/llms/github_copilot/chat/transformation.py @@ -34,7 +34,7 @@ class GithubCopilotConfig(OpenAIConfig): api_key: str | None, custom_llm_provider: str, ) -> tuple[str | None, str | None, str]: - dynamic_api_base: Final = self.authenticator.get_api_base() or DEFAULT_GITHUB_COPILOT_API_BASE + dynamic_api_base: Final = self.authenticator.get_api_base(api_base) or DEFAULT_GITHUB_COPILOT_API_BASE try: dynamic_api_key: Final = self.authenticator.get_api_key() except GetAPIKeyError as e: diff --git a/litellm/llms/github_copilot/embedding/transformation.py b/litellm/llms/github_copilot/embedding/transformation.py index e5e35059ce2..8740c633b76 100644 --- a/litellm/llms/github_copilot/embedding/transformation.py +++ b/litellm/llms/github_copilot/embedding/transformation.py @@ -97,7 +97,7 @@ class GithubCopilotEmbeddingConfig(BaseEmbeddingConfig): """ Get the complete URL for GitHub Copilot Embedding API endpoint. """ - effective_api_base = self.authenticator.get_api_base() or DEFAULT_GITHUB_COPILOT_API_BASE + effective_api_base = self.authenticator.get_api_base(api_base) or DEFAULT_GITHUB_COPILOT_API_BASE # Remove trailing slashes effective_api_base = effective_api_base.rstrip("/") diff --git a/litellm/llms/github_copilot/responses/transformation.py b/litellm/llms/github_copilot/responses/transformation.py index 526b240354f..759f1c66627 100644 --- a/litellm/llms/github_copilot/responses/transformation.py +++ b/litellm/llms/github_copilot/responses/transformation.py @@ -247,7 +247,7 @@ class GithubCopilotResponsesAPIConfig(OpenAIResponsesAPIConfig): """ Get the complete URL for GitHub Copilot Responses API endpoint. """ - effective_api_base = self.authenticator.get_api_base() or DEFAULT_GITHUB_COPILOT_API_BASE + effective_api_base = self.authenticator.get_api_base(api_base) or DEFAULT_GITHUB_COPILOT_API_BASE # Remove trailing slashes effective_api_base = effective_api_base.rstrip("/") 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 87921a66297..2d4177634bf 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 @@ -76,7 +76,9 @@ def test_github_copilot_embedding_config_get_complete_url(): assert url == "https://api.githubcopilot.com/embeddings" # Test with custom API base from authenticator - config.authenticator.get_api_base.return_value = "https://api.enterprise.githubcopilot.com" + config.authenticator.get_api_base.side_effect = lambda api_base=None: ( + api_base or "https://api.enterprise.githubcopilot.com" + ) url = config.get_complete_url( api_base=None, api_key=None, @@ -87,13 +89,13 @@ def test_github_copilot_embedding_config_get_complete_url(): assert url == "https://api.enterprise.githubcopilot.com/embeddings" url = config.get_complete_url( - api_base="https://untrusted.example.com", + api_base="https://api.business.githubcopilot.com", api_key=None, model="github_copilot/text-embedding-3-small", optional_params={}, litellm_params={}, ) - assert url == "https://api.enterprise.githubcopilot.com/embeddings" + assert url == "https://api.business.githubcopilot.com/embeddings" def test_github_copilot_embedding_config_transform_request(): 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 de0fc41a6a9..925912bfb31 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 @@ -56,7 +56,9 @@ class TestGithubCopilotResponsesAPITransformation: """Test that get_complete_url returns correct GitHub Copilot endpoint""" # Mock authenticator to return default base mock_auth_instance = MagicMock() - mock_auth_instance.get_api_base.return_value = "https://api.individual.githubcopilot.com" + mock_auth_instance.get_api_base.side_effect = lambda api_base=None: ( + api_base or "https://api.individual.githubcopilot.com" + ) mock_authenticator_class.return_value = mock_auth_instance config = GithubCopilotResponsesAPIConfig() @@ -67,11 +69,11 @@ class TestGithubCopilotResponsesAPITransformation: f"Expected GitHub Copilot responses endpoint, got {url}" ) - custom_url = config.get_complete_url(api_base="https://untrusted.example.com", litellm_params={}) - assert custom_url == "https://api.individual.githubcopilot.com/responses" + custom_url = config.get_complete_url(api_base="https://api.business.githubcopilot.com", litellm_params={}) + assert custom_url == "https://api.business.githubcopilot.com/responses" - url_with_slash = config.get_complete_url(api_base="https://untrusted.example.com/", litellm_params={}) - assert url_with_slash == "https://api.individual.githubcopilot.com/responses" + url_with_slash = config.get_complete_url(api_base="https://api.githubcopilot.com/", litellm_params={}) + assert url_with_slash == "https://api.githubcopilot.com/responses" @patch("litellm.llms.github_copilot.responses.transformation.Authenticator") def test_validate_environment_default_headers(self, mock_authenticator_class, monkeypatch): 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 79fe94dc5c1..4e5023d11de 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 @@ -55,10 +55,10 @@ class TestGitHubCopilotAuthenticator: def test_get_api_base_prefers_environment(self, authenticator): with patch.dict( os.environ, - {"GITHUB_COPILOT_API_BASE": "https://configured.githubcopilot.example"}, + {"GITHUB_COPILOT_API_BASE": "https://api.enterprise.githubcopilot.com"}, clear=True, ): - assert authenticator.get_api_base() == "https://configured.githubcopilot.example" + assert authenticator.get_api_base() == "https://api.enterprise.githubcopilot.com" @pytest.mark.parametrize( "api_base", @@ -67,6 +67,7 @@ class TestGitHubCopilotAuthenticator: "https://user:password@api.githubcopilot.com", "https://api.githubcopilot.com?tenant=example", "https://api.githubcopilot.com#fragment", + "https://attacker.example.com", ), ) def test_get_api_base_rejects_insecure_configuration(self, authenticator, api_base): @@ -77,13 +78,58 @@ class TestGitHubCopilotAuthenticator: assert authenticator.get_api_base() is None mock_warning.assert_called_once_with( - "Ignoring GITHUB_COPILOT_API_BASE because it must be an HTTPS URL without credentials, query, or fragment" + "Ignoring GITHUB_COPILOT_API_BASE because it is not a trusted HTTPS GitHub Copilot endpoint" ) def test_get_api_base_uses_default_when_unconfigured(self, authenticator): with patch.dict(os.environ, {}, clear=True): assert authenticator.get_api_base() is None + def test_get_api_base_trusts_enterprise_oauth_domain(self, authenticator): + environment = { + "GITHUB_COPILOT_API_BASE": "https://copilot-api.company.ghe.com", + "GITHUB_COPILOT_DEVICE_CODE_URL": "https://company.ghe.com/login/device/code", + "GITHUB_COPILOT_ACCESS_TOKEN_URL": "https://company.ghe.com/login/oauth/access_token", + } + with patch.dict(os.environ, environment, clear=True): + assert authenticator.get_api_base() == "https://copilot-api.company.ghe.com" + + def test_get_api_base_trusts_explicit_allowed_host(self, authenticator): + environment = { + "GITHUB_COPILOT_API_BASE": "https://copilot-proxy.example.com", + "GITHUB_COPILOT_ALLOWED_API_HOSTS": "copilot-proxy.example.com", + } + with patch.dict(os.environ, environment, clear=True): + assert authenticator.get_api_base() == "https://copilot-proxy.example.com" + + def test_get_api_base_prefers_trusted_deployment_endpoint(self, authenticator): + with patch.dict( + os.environ, + {"GITHUB_COPILOT_API_BASE": "https://api.individual.githubcopilot.com"}, + clear=True, + ): + assert ( + authenticator.get_api_base("https://api.enterprise.githubcopilot.com") + == "https://api.enterprise.githubcopilot.com" + ) + + def test_get_api_base_falls_back_from_untrusted_deployment_endpoint(self, authenticator): + with ( + patch.dict( + os.environ, + {"GITHUB_COPILOT_API_BASE": "https://api.individual.githubcopilot.com"}, + clear=True, + ), + patch("litellm.llms.github_copilot.authenticator.verbose_logger.warning") as mock_warning, + ): + assert ( + authenticator.get_api_base("https://attacker.example.com") == "https://api.individual.githubcopilot.com" + ) + + mock_warning.assert_called_once_with( + "Ignoring deployment api_base because it is not a trusted HTTPS GitHub Copilot endpoint" + ) + def test_get_github_headers(self, authenticator): headers = authenticator._get_github_headers() assert headers == { diff --git a/tests/test_litellm/llms/github_copilot/test_github_copilot_transformation.py b/tests/test_litellm/llms/github_copilot/test_github_copilot_transformation.py index 856a99d0859..e71224b1030 100644 --- a/tests/test_litellm/llms/github_copilot/test_github_copilot_transformation.py +++ b/tests/test_litellm/llms/github_copilot/test_github_copilot_transformation.py @@ -39,7 +39,9 @@ def test_github_copilot_config_get_openai_compatible_provider_info(): config.authenticator = MagicMock() config.authenticator.get_api_key.return_value = mock_api_key # Test with dynamic endpoint - config.authenticator.get_api_base.return_value = "https://api.enterprise.githubcopilot.com" + config.authenticator.get_api_base.side_effect = lambda api_base=None: ( + api_base or "https://api.enterprise.githubcopilot.com" + ) # Test with default values model = "github_copilot/gpt-4" @@ -59,14 +61,14 @@ def test_github_copilot_config_get_openai_compatible_provider_info(): assert custom_llm_provider == "github_copilot" api_base, _, _ = config._get_openai_compatible_provider_info( model=model, - api_base="https://untrusted.example.com", + api_base="https://api.business.githubcopilot.com", api_key=None, custom_llm_provider="github_copilot", ) - assert api_base == "https://api.enterprise.githubcopilot.com" + assert api_base == "https://api.business.githubcopilot.com" # Test fallback to default if no dynamic endpoint - config.authenticator.get_api_base.return_value = None + config.authenticator.get_api_base.side_effect = lambda api_base=None: None ( api_base, dynamic_api_key, From b5f11bc4b2dea0c982917df359bd149d3fd2f409 Mon Sep 17 00:00:00 2001 From: codgician <15964984+codgician@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:44:07 +0800 Subject: [PATCH 09/11] fix(github-copilot): narrow Enterprise host trust --- litellm/llms/github_copilot/authenticator.py | 4 +--- .../test_github_copilot_authenticator.py | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/litellm/llms/github_copilot/authenticator.py b/litellm/llms/github_copilot/authenticator.py index e7d84835fee..9f7c33c4487 100644 --- a/litellm/llms/github_copilot/authenticator.py +++ b/litellm/llms/github_copilot/authenticator.py @@ -58,9 +58,7 @@ def _is_trusted_api_base(api_base: str) -> bool: return True if hostname in _configured_allowed_api_hosts(): return True - return any( - hostname == oauth_host or hostname.endswith(f".{oauth_host}") for oauth_host in _configured_oauth_hosts() - ) + return any(hostname == f"copilot-api.{oauth_host}" for oauth_host in _configured_oauth_hosts()) class Authenticator: 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 4e5023d11de..0e6a7fea489 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 @@ -94,6 +94,22 @@ class TestGitHubCopilotAuthenticator: with patch.dict(os.environ, environment, clear=True): assert authenticator.get_api_base() == "https://copilot-api.company.ghe.com" + def test_get_api_base_rejects_other_oauth_subdomains(self, authenticator): + environment = { + "GITHUB_COPILOT_API_BASE": "https://evil.company.ghe.com", + "GITHUB_COPILOT_DEVICE_CODE_URL": "https://company.ghe.com/login/device/code", + "GITHUB_COPILOT_ACCESS_TOKEN_URL": "https://company.ghe.com/login/oauth/access_token", + } + with ( + patch.dict(os.environ, environment, clear=True), + patch("litellm.llms.github_copilot.authenticator.verbose_logger.warning") as mock_warning, + ): + assert authenticator.get_api_base() is None + + mock_warning.assert_called_once_with( + "Ignoring GITHUB_COPILOT_API_BASE because it is not a trusted HTTPS GitHub Copilot endpoint" + ) + def test_get_api_base_trusts_explicit_allowed_host(self, authenticator): environment = { "GITHUB_COPILOT_API_BASE": "https://copilot-proxy.example.com", From e552f53f802a5c8e65ef613961ff4bc75522cdae Mon Sep 17 00:00:00 2001 From: codgician <15964984+codgician@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:02:40 +0800 Subject: [PATCH 10/11] fix(github-copilot): preserve configured endpoint trust --- litellm/llms/github_copilot/authenticator.py | 30 ++-------- .../test_github_copilot_authenticator.py | 55 +++---------------- 2 files changed, 14 insertions(+), 71 deletions(-) diff --git a/litellm/llms/github_copilot/authenticator.py b/litellm/llms/github_copilot/authenticator.py index 9f7c33c4487..495ede79bfd 100644 --- a/litellm/llms/github_copilot/authenticator.py +++ b/litellm/llms/github_copilot/authenticator.py @@ -37,28 +37,8 @@ def _https_hostname(url: str) -> str | None: return parsed_url.hostname.lower() -def _configured_allowed_api_hosts() -> frozenset[str]: - configured_hosts = os.getenv("GITHUB_COPILOT_ALLOWED_API_HOSTS", "") - return frozenset(host.strip().lower() for host in configured_hosts.split(",") if host.strip()) - - -def _configured_oauth_hosts() -> tuple[str, ...]: - oauth_urls = ( - os.getenv("GITHUB_COPILOT_DEVICE_CODE_URL", DEFAULT_GITHUB_DEVICE_CODE_URL), - os.getenv("GITHUB_COPILOT_ACCESS_TOKEN_URL", DEFAULT_GITHUB_ACCESS_TOKEN_URL), - ) - return tuple(hostname for url in oauth_urls if (hostname := _https_hostname(url)) is not None) - - -def _is_trusted_api_base(api_base: str) -> bool: - hostname = _https_hostname(api_base) - if hostname is None: - return False - if hostname == "githubcopilot.com" or hostname.endswith(".githubcopilot.com"): - return True - if hostname in _configured_allowed_api_hosts(): - return True - return any(hostname == f"copilot-api.{oauth_host}" for oauth_host in _configured_oauth_hosts()) +def _is_secure_api_base(api_base: str) -> bool: + return _https_hostname(api_base) is not None class Authenticator: @@ -129,9 +109,11 @@ class Authenticator: for source, candidate in candidates: if candidate is None: continue - if _is_trusted_api_base(candidate): + if _is_secure_api_base(candidate): return candidate - verbose_logger.warning(f"Ignoring {source} because it is not a trusted HTTPS GitHub Copilot endpoint") + verbose_logger.warning( + f"Ignoring {source} because it must be an HTTPS URL without credentials, query, or fragment" + ) return None def _ensure_token_dir(self) -> None: 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 0e6a7fea489..49906d3dd62 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 @@ -55,10 +55,10 @@ class TestGitHubCopilotAuthenticator: def test_get_api_base_prefers_environment(self, authenticator): with patch.dict( os.environ, - {"GITHUB_COPILOT_API_BASE": "https://api.enterprise.githubcopilot.com"}, + {"GITHUB_COPILOT_API_BASE": "https://configured.example.com"}, clear=True, ): - assert authenticator.get_api_base() == "https://api.enterprise.githubcopilot.com" + assert authenticator.get_api_base() == "https://configured.example.com" @pytest.mark.parametrize( "api_base", @@ -67,7 +67,6 @@ class TestGitHubCopilotAuthenticator: "https://user:password@api.githubcopilot.com", "https://api.githubcopilot.com?tenant=example", "https://api.githubcopilot.com#fragment", - "https://attacker.example.com", ), ) def test_get_api_base_rejects_insecure_configuration(self, authenticator, api_base): @@ -78,72 +77,34 @@ class TestGitHubCopilotAuthenticator: assert authenticator.get_api_base() is None mock_warning.assert_called_once_with( - "Ignoring GITHUB_COPILOT_API_BASE because it is not a trusted HTTPS GitHub Copilot endpoint" + "Ignoring GITHUB_COPILOT_API_BASE because it must be an HTTPS URL without credentials, query, or fragment" ) def test_get_api_base_uses_default_when_unconfigured(self, authenticator): with patch.dict(os.environ, {}, clear=True): assert authenticator.get_api_base() is None - def test_get_api_base_trusts_enterprise_oauth_domain(self, authenticator): - environment = { - "GITHUB_COPILOT_API_BASE": "https://copilot-api.company.ghe.com", - "GITHUB_COPILOT_DEVICE_CODE_URL": "https://company.ghe.com/login/device/code", - "GITHUB_COPILOT_ACCESS_TOKEN_URL": "https://company.ghe.com/login/oauth/access_token", - } - with patch.dict(os.environ, environment, clear=True): - assert authenticator.get_api_base() == "https://copilot-api.company.ghe.com" - - def test_get_api_base_rejects_other_oauth_subdomains(self, authenticator): - environment = { - "GITHUB_COPILOT_API_BASE": "https://evil.company.ghe.com", - "GITHUB_COPILOT_DEVICE_CODE_URL": "https://company.ghe.com/login/device/code", - "GITHUB_COPILOT_ACCESS_TOKEN_URL": "https://company.ghe.com/login/oauth/access_token", - } - with ( - patch.dict(os.environ, environment, clear=True), - patch("litellm.llms.github_copilot.authenticator.verbose_logger.warning") as mock_warning, - ): - assert authenticator.get_api_base() is None - - mock_warning.assert_called_once_with( - "Ignoring GITHUB_COPILOT_API_BASE because it is not a trusted HTTPS GitHub Copilot endpoint" - ) - - def test_get_api_base_trusts_explicit_allowed_host(self, authenticator): - environment = { - "GITHUB_COPILOT_API_BASE": "https://copilot-proxy.example.com", - "GITHUB_COPILOT_ALLOWED_API_HOSTS": "copilot-proxy.example.com", - } - with patch.dict(os.environ, environment, clear=True): - assert authenticator.get_api_base() == "https://copilot-proxy.example.com" - def test_get_api_base_prefers_trusted_deployment_endpoint(self, authenticator): with patch.dict( os.environ, - {"GITHUB_COPILOT_API_BASE": "https://api.individual.githubcopilot.com"}, + {"GITHUB_COPILOT_API_BASE": "https://configured.example.com"}, clear=True, ): - assert ( - authenticator.get_api_base("https://api.enterprise.githubcopilot.com") - == "https://api.enterprise.githubcopilot.com" - ) + assert authenticator.get_api_base("https://deployment.example.com") == "https://deployment.example.com" def test_get_api_base_falls_back_from_untrusted_deployment_endpoint(self, authenticator): with ( patch.dict( os.environ, - {"GITHUB_COPILOT_API_BASE": "https://api.individual.githubcopilot.com"}, + {"GITHUB_COPILOT_API_BASE": "https://configured.example.com"}, clear=True, ), patch("litellm.llms.github_copilot.authenticator.verbose_logger.warning") as mock_warning, ): - assert ( - authenticator.get_api_base("https://attacker.example.com") == "https://api.individual.githubcopilot.com" - ) + assert authenticator.get_api_base("http://attacker.example.com") == "https://configured.example.com" mock_warning.assert_called_once_with( - "Ignoring deployment api_base because it is not a trusted HTTPS GitHub Copilot endpoint" + "Ignoring deployment api_base because it must be an HTTPS URL without credentials, query, or fragment" ) def test_get_github_headers(self, authenticator): From 56d338cc708f2e9b05cf7582355bc350b3dcc140 Mon Sep 17 00:00:00 2001 From: codgician <15964984+codgician@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:00:24 +0800 Subject: [PATCH 11/11] test(github-copilot): update rebased header defaults --- .../responses/test_github_copilot_responses_transformation.py | 4 ++-- .../llms/github_copilot/test_github_copilot_authenticator.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) 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 925912bfb31..73c7ebe1fcc 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 @@ -96,8 +96,8 @@ class TestGithubCopilotResponsesAPITransformation: assert headers["content-type"] == "application/json" assert headers["copilot-integration-id"] == "vscode-chat" 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 headers["editor-plugin-version"] == "copilot-chat/0.26.7" + assert headers["user-agent"] == "GitHubCopilotChat/0.26.7" assert "openai-intent" not in headers assert "x-github-api-version" not in headers assert "x-request-id" not in headers 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 49906d3dd62..beca19f82e4 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 @@ -114,8 +114,8 @@ class TestGitHubCopilotAuthenticator: "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", + "editor-plugin-version": "copilot-chat/0.26.7", + "user-agent": "GitHubCopilotChat/0.26.7", } def test_auth_requests_support_opencode_identity(self, authenticator, mock_http_client):