From 2e1a262cdd4a63c4adb8a002fb7a11cc1a480a81 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/32] fix(github-copilot): make client headers configurable --- litellm/llms/github_copilot/authenticator.py | 12 +-- litellm/llms/github_copilot/common_utils.py | 59 ++++++++++----- ...github_copilot_embedding_transformation.py | 4 +- ...github_copilot_responses_transformation.py | 73 ++++++++++++++++--- .../test_github_copilot_authenticator.py | 70 ++++++++++++++++-- 5 files changed, 169 insertions(+), 49 deletions(-) diff --git a/litellm/llms/github_copilot/authenticator.py b/litellm/llms/github_copilot/authenticator.py index 59e1804f453..129abd71c8e 100644 --- a/litellm/llms/github_copilot/authenticator.py +++ b/litellm/llms/github_copilot/authenticator.py @@ -18,6 +18,7 @@ from .common_utils import ( GetAPIKeyError, GetDeviceCodeError, RefreshAPIKeyError, + get_copilot_default_headers, ) # Constants (default values — overridable via environment variables at call time) @@ -237,16 +238,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. - """ - return github_api_headers(access_token) + return get_copilot_default_headers(access_token=access_token) def _get_device_code(self) -> _DeviceCode: """ diff --git a/litellm/llms/github_copilot/common_utils.py b/litellm/llms/github_copilot/common_utils.py index 515b7bf4fb5..1722b605137 100644 --- a/litellm/llms/github_copilot/common_utils.py +++ b/litellm/llms/github_copilot/common_utils.py @@ -2,20 +2,39 @@ Constants for Copilot integration """ +import os from collections.abc import MutableMapping from typing import Final -from uuid import uuid4 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): @@ -58,26 +77,26 @@ class GetAPIKeyError(GithubCopilotError): pass -def get_copilot_default_headers( - api_key: str, -) -> dict[str, str]: # mutable-ok: callers merge caller headers into the returned 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]: # mutable-ok: callers merge caller headers into the returned dict + 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} def pin_session_authorization( diff --git a/tests/unit/llms/github_copilot/embedding/test_github_copilot_embedding_transformation.py b/tests/unit/llms/github_copilot/embedding/test_github_copilot_embedding_transformation.py index 88fc8ba6e6a..18eaaf1c59c 100644 --- a/tests/unit/llms/github_copilot/embedding/test_github_copilot_embedding_transformation.py +++ b/tests/unit/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/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py b/tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py index c3a62592039..3aa3077122a 100644 --- a/tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py +++ b/tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py @@ -73,27 +73,78 @@ class TestGithubCopilotResponsesAPITransformation: assert url_with_slash == "https://api.githubcopilot.com/responses", "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/unit/llms/github_copilot/test_github_copilot_authenticator.py b/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py index 21d4901cf81..ed101a7ef67 100644 --- a/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py +++ b/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py @@ -59,15 +59,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 f4ee39748c631f2911d08a0e5d0b3b4f7ee31191 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/32] fix(github-copilot): support direct OAuth tokens --- litellm/llms/github_copilot/authenticator.py | 6 ++ .../test_github_copilot_authenticator.py | 94 +++++++++---------- 2 files changed, 48 insertions(+), 52 deletions(-) diff --git a/litellm/llms/github_copilot/authenticator.py b/litellm/llms/github_copilot/authenticator.py index 129abd71c8e..7031bd91ebf 100644 --- a/litellm/llms/github_copilot/authenticator.py +++ b/litellm/llms/github_copilot/authenticator.py @@ -60,6 +60,8 @@ def github_api_headers( if access_token: headers["authorization"] = f"token {access_token}" return headers +def _use_oauth_token() -> bool: + return os.getenv("GITHUB_COPILOT_USE_OAUTH_TOKEN", "").strip().lower() in {"1", "true", "yes", "on"} class Authenticator: @@ -136,6 +138,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) @@ -185,6 +189,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/unit/llms/github_copilot/test_github_copilot_authenticator.py b/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py index ed101a7ef67..f290140a676 100644 --- a/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py +++ b/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py @@ -72,60 +72,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", + "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.""" @@ -193,9 +193,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() @@ -204,9 +202,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(), @@ -304,20 +300,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): From f5f02e6eacef37e8502a0e152480a2d78548c389 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/32] refactor(github-copilot): use unified OAuth token flow --- litellm/llms/github_copilot/authenticator.py | 122 ++---------------- .../github_copilot/chat/transformation.py | 12 +- litellm/llms/github_copilot/common_utils.py | 38 +++--- .../embedding/transformation.py | 3 - .../responses/transformation.py | 3 - ...github_copilot_embedding_transformation.py | 6 +- ...github_copilot_responses_transformation.py | 10 +- .../test_github_copilot_authenticator.py | 117 +++-------------- .../test_github_copilot_transformation.py | 31 ++--- 9 files changed, 69 insertions(+), 273 deletions(-) diff --git a/litellm/llms/github_copilot/authenticator.py b/litellm/llms/github_copilot/authenticator.py index 7031bd91ebf..42c4aca3333 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 from pydantic import ConfigDict, TypeAdapter, with_config @@ -13,12 +12,10 @@ from litellm.litellm_core_utils.asyncify import can_block_current_thread 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) @@ -60,8 +57,6 @@ def github_api_headers( if access_token: headers["authorization"] = f"token {access_token}" return headers -def _use_oauth_token() -> bool: - return os.getenv("GITHUB_COPILOT_USE_OAUTH_TOKEN", "").strip().lower() in {"1", "true", "yes", "on"} class Authenticator: @@ -76,7 +71,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: @@ -119,7 +113,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 @@ -129,122 +123,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) -> _DeviceCode: """ diff --git a/litellm/llms/github_copilot/chat/transformation.py b/litellm/llms/github_copilot/chat/transformation.py index 9553327db97..b865b12271e 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 collections.abc import Mapping, Sequence from typing import TYPE_CHECKING, Final, cast # noqa: TID251 # spread dict loses the role literal without a cast @@ -34,13 +33,8 @@ class GithubCopilotConfig(OpenAIConfig): super().__init__() self.authenticator = Authenticator() - def api_base_without_login(self, api_base: str | None = None) -> str: - return ( - api_base - or self.authenticator.get_api_base() - or os.getenv("GITHUB_COPILOT_API_BASE") - or DEFAULT_GITHUB_COPILOT_API_BASE - ) + def api_base_without_login(self) -> str: + return self.authenticator.get_api_base() or DEFAULT_GITHUB_COPILOT_API_BASE def _get_openai_compatible_provider_info( self, @@ -49,7 +43,7 @@ class GithubCopilotConfig(OpenAIConfig): api_key: str | None, custom_llm_provider: str, ) -> tuple[str | None, str | None, str]: - dynamic_api_base: Final = self.api_base_without_login(api_base) + dynamic_api_base: Final = self.api_base_without_login() 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 1722b605137..e8204cb6491 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,19 +78,25 @@ 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, -) -> dict[str, str]: # mutable-ok: callers merge caller headers into the returned dict - configured_headers = { +def _get_configured_copilot_headers( + config: tuple[tuple[str, str, str | None], ...], +) -> dict[str, str]: + 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}", + } def pin_session_authorization( diff --git a/litellm/llms/github_copilot/embedding/transformation.py b/litellm/llms/github_copilot/embedding/transformation.py index a6250afdd03..e42602156d9 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, @@ -122,9 +121,7 @@ class GithubCopilotEmbeddingConfig(BaseEmbeddingConfig): ) effective_api_base = ( (user_session.api_base if user_session is not None else None) - or api_base or self.authenticator.get_api_base() - or os.getenv("GITHUB_COPILOT_API_BASE") or DEFAULT_GITHUB_COPILOT_API_BASE ) diff --git a/litellm/llms/github_copilot/responses/transformation.py b/litellm/llms/github_copilot/responses/transformation.py index d468889b626..c87b0d023ae 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, @@ -268,9 +267,7 @@ class GithubCopilotResponsesAPIConfig(OpenAIResponsesAPIConfig): ) effective_api_base = ( (user_session.api_base if user_session is not None else None) - or api_base or self.authenticator.get_api_base() - or os.getenv("GITHUB_COPILOT_API_BASE") or DEFAULT_GITHUB_COPILOT_API_BASE ) diff --git a/tests/unit/llms/github_copilot/embedding/test_github_copilot_embedding_transformation.py b/tests/unit/llms/github_copilot/embedding/test_github_copilot_embedding_transformation.py index 18eaaf1c59c..f04745b02a4 100644 --- a/tests/unit/llms/github_copilot/embedding/test_github_copilot_embedding_transformation.py +++ b/tests/unit/llms/github_copilot/embedding/test_github_copilot_embedding_transformation.py @@ -86,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/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py b/tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py index 3aa3077122a..9633934cac9 100644 --- a/tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py +++ b/tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py @@ -64,13 +64,11 @@ class TestGithubCopilotResponsesAPITransformation: 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 custom_url == "https://custom.githubcopilot.com/responses", f"Expected custom endpoint, got {custom_url}" + custom_url = config.get_complete_url(api_base="https://untrusted.example.com", litellm_params={}) + assert custom_url == "https://api.individual.githubcopilot.com/responses" - # 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" + 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): diff --git a/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py b/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py index f290140a676..9cf670d7787 100644 --- a/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py +++ b/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py @@ -1,4 +1,3 @@ -import json import os import time from concurrent.futures import ThreadPoolExecutor @@ -9,11 +8,10 @@ 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, ) @@ -46,7 +44,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): @@ -69,9 +66,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 = ( @@ -88,7 +82,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", } @@ -104,27 +99,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): @@ -190,68 +192,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.""" @@ -310,19 +258,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 @@ -381,15 +316,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/unit/llms/github_copilot/test_github_copilot_transformation.py b/tests/unit/llms/github_copilot/test_github_copilot_transformation.py index 2dbfae07526..9c403d90f80 100644 --- a/tests/unit/llms/github_copilot/test_github_copilot_transformation.py +++ b/tests/unit/llms/github_copilot/test_github_copilot_transformation.py @@ -19,11 +19,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, ) @@ -37,9 +35,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" @@ -57,6 +53,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 @@ -368,7 +371,6 @@ def test_x_initiator_header_system_only_messages(): - def test_copilot_vision_request_header_with_image(): """Test that Copilot-Vision-Request header is added when messages contain images""" config = GithubCopilotConfig() @@ -693,13 +695,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.""" @@ -867,12 +864,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 dce6f455dc3f17ed660de3fa87530bb8c511e0af 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/32] fix(github-copilot): preserve legacy endpoint routing --- litellm/llms/github_copilot/authenticator.py | 34 ++++++++++++++++- .../test_github_copilot_authenticator.py | 37 +++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/litellm/llms/github_copilot/authenticator.py b/litellm/llms/github_copilot/authenticator.py index 42c4aca3333..c98723f476b 100644 --- a/litellm/llms/github_copilot/authenticator.py +++ b/litellm/llms/github_copilot/authenticator.py @@ -4,7 +4,7 @@ import time from typing import Final import httpx -from pydantic import ConfigDict, TypeAdapter, with_config +from pydantic import ConfigDict, TypeAdapter, ValidationError, with_config from typing_extensions import NotRequired, ReadOnly, TypedDict from litellm._logging import verbose_logger @@ -18,6 +18,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" @@ -71,6 +82,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: @@ -132,7 +151,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/unit/llms/github_copilot/test_github_copilot_authenticator.py b/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py index 9cf670d7787..1714923e83b 100644 --- a/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py +++ b/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py @@ -44,6 +44,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): @@ -55,6 +56,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 48dc0e3d338dedc7ee67a03fcc1b7c9ccda0f4c0 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/32] test(github-copilot): cover invalid legacy cache --- .../test_github_copilot_authenticator.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py b/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py index 1714923e83b..812b96e7ba7 100644 --- a/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py +++ b/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py @@ -76,6 +76,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 795d1d8593397b81065d814d6db471965091e3fb 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/32] test(github-copilot): cover OAuth token persistence --- .../test_github_copilot_authenticator.py | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py b/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py index 812b96e7ba7..4363367190f 100644 --- a/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py +++ b/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py @@ -186,17 +186,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") @pytest.mark.asyncio async def test_get_access_token_refuses_device_code_login_in_event_loop(self, authenticator): From ed5ef8761868b229c398e911901016753708dfe6 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/32] fix(github-copilot): validate OAuth API base --- litellm/llms/github_copilot/authenticator.py | 48 +++++++--------- .../test_github_copilot_authenticator.py | 56 +++++++------------ 2 files changed, 40 insertions(+), 64 deletions(-) diff --git a/litellm/llms/github_copilot/authenticator.py b/litellm/llms/github_copilot/authenticator.py index c98723f476b..f5313e0058b 100644 --- a/litellm/llms/github_copilot/authenticator.py +++ b/litellm/llms/github_copilot/authenticator.py @@ -2,9 +2,10 @@ import json import os import time from typing import Final +from urllib.parse import urlsplit import httpx -from pydantic import ConfigDict, TypeAdapter, ValidationError, with_config +from pydantic import ConfigDict, TypeAdapter, with_config from typing_extensions import NotRequired, ReadOnly, TypedDict from litellm._logging import verbose_logger @@ -19,16 +20,6 @@ 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" @@ -70,6 +61,18 @@ def github_api_headers( return headers +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.""" @@ -82,14 +85,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: @@ -152,17 +147,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/unit/llms/github_copilot/test_github_copilot_authenticator.py b/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py index 4363367190f..084bfc9d076 100644 --- a/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py +++ b/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py @@ -44,7 +44,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): @@ -57,52 +56,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 287e2d423347af1333a832c374357aa31c143d1b 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/32] fix(github-copilot): constrain OAuth endpoints --- litellm/llms/github_copilot/authenticator.py | 68 +++++++++++++------ .../github_copilot/chat/transformation.py | 6 +- .../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, 121 insertions(+), 39 deletions(-) diff --git a/litellm/llms/github_copilot/authenticator.py b/litellm/llms/github_copilot/authenticator.py index f5313e0058b..684305244d4 100644 --- a/litellm/llms/github_copilot/authenticator.py +++ b/litellm/llms/github_copilot/authenticator.py @@ -61,15 +61,43 @@ def github_api_headers( return headers -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() ) @@ -145,16 +173,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 b865b12271e..570bb5aafe8 100644 --- a/litellm/llms/github_copilot/chat/transformation.py +++ b/litellm/llms/github_copilot/chat/transformation.py @@ -33,8 +33,8 @@ class GithubCopilotConfig(OpenAIConfig): super().__init__() self.authenticator = Authenticator() - def api_base_without_login(self) -> str: - return self.authenticator.get_api_base() or DEFAULT_GITHUB_COPILOT_API_BASE + def api_base_without_login(self, api_base: str | None = None) -> str: + return self.authenticator.get_api_base(api_base) or DEFAULT_GITHUB_COPILOT_API_BASE def _get_openai_compatible_provider_info( self, @@ -43,7 +43,7 @@ class GithubCopilotConfig(OpenAIConfig): api_key: str | None, custom_llm_provider: str, ) -> tuple[str | None, str | None, str]: - dynamic_api_base: Final = self.api_base_without_login() + dynamic_api_base: Final = self.api_base_without_login(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 e42602156d9..421a6a3de91 100644 --- a/litellm/llms/github_copilot/embedding/transformation.py +++ b/litellm/llms/github_copilot/embedding/transformation.py @@ -121,7 +121,7 @@ class GithubCopilotEmbeddingConfig(BaseEmbeddingConfig): ) effective_api_base = ( (user_session.api_base if user_session is not None else None) - or self.authenticator.get_api_base() + or self.authenticator.get_api_base(api_base) or DEFAULT_GITHUB_COPILOT_API_BASE ) diff --git a/litellm/llms/github_copilot/responses/transformation.py b/litellm/llms/github_copilot/responses/transformation.py index c87b0d023ae..43c1a037256 100644 --- a/litellm/llms/github_copilot/responses/transformation.py +++ b/litellm/llms/github_copilot/responses/transformation.py @@ -267,7 +267,7 @@ class GithubCopilotResponsesAPIConfig(OpenAIResponsesAPIConfig): ) effective_api_base = ( (user_session.api_base if user_session is not None else None) - or self.authenticator.get_api_base() + or self.authenticator.get_api_base(api_base) or DEFAULT_GITHUB_COPILOT_API_BASE ) diff --git a/tests/unit/llms/github_copilot/embedding/test_github_copilot_embedding_transformation.py b/tests/unit/llms/github_copilot/embedding/test_github_copilot_embedding_transformation.py index f04745b02a4..0888619baef 100644 --- a/tests/unit/llms/github_copilot/embedding/test_github_copilot_embedding_transformation.py +++ b/tests/unit/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/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py b/tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py index 9633934cac9..f733116514d 100644 --- a/tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py +++ b/tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py @@ -53,7 +53,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() @@ -64,11 +66,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/unit/llms/github_copilot/test_github_copilot_authenticator.py b/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py index 084bfc9d076..39e5794c433 100644 --- a/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py +++ b/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py @@ -58,10 +58,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", @@ -70,6 +70,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): @@ -80,13 +81,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/unit/llms/github_copilot/test_github_copilot_transformation.py b/tests/unit/llms/github_copilot/test_github_copilot_transformation.py index 9c403d90f80..b20261bb6ac 100644 --- a/tests/unit/llms/github_copilot/test_github_copilot_transformation.py +++ b/tests/unit/llms/github_copilot/test_github_copilot_transformation.py @@ -35,7 +35,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" @@ -55,14 +57,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 3d407aeaff42109264cd05731349fe8d2df92d95 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/32] 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 684305244d4..b5b9f5bd9d9 100644 --- a/litellm/llms/github_copilot/authenticator.py +++ b/litellm/llms/github_copilot/authenticator.py @@ -96,9 +96,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/unit/llms/github_copilot/test_github_copilot_authenticator.py b/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py index 39e5794c433..6a9d6323745 100644 --- a/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py +++ b/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py @@ -97,6 +97,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 b7a4f83b673c22bfec0f2a6fa1e457e1760d9ba5 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/32] 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 b5b9f5bd9d9..dd94c25cc77 100644 --- a/litellm/llms/github_copilot/authenticator.py +++ b/litellm/llms/github_copilot/authenticator.py @@ -75,28 +75,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: @@ -179,9 +159,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/unit/llms/github_copilot/test_github_copilot_authenticator.py b/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py index 6a9d6323745..843601e6ee1 100644 --- a/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py +++ b/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py @@ -58,10 +58,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", @@ -70,7 +70,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): @@ -81,72 +80,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 02875aaed82599d6dcab1214460b0bbcd9a2d02f 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/32] 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/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py b/tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py index f733116514d..b6e08cdf22f 100644 --- a/tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py +++ b/tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py @@ -93,8 +93,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/unit/llms/github_copilot/test_github_copilot_authenticator.py b/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py index 843601e6ee1..fdef7227964 100644 --- a/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py +++ b/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py @@ -117,8 +117,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): From 1335b27e1ce7df536375409d66f7232b01569ffd Mon Sep 17 00:00:00 2001 From: codgician <15964984+codgician@users.noreply.github.com> Date: Sun, 13 Sep 2026 16:39:00 +0800 Subject: [PATCH 12/32] fix(github-copilot): resolve CI lint failures after rebase - Auto-fix ruff import sorting, unused imports, and explicit str conversion - Replace verbose_logger patches with caplog per repo convention - Add missing os import in responses transformation test - Suppress two new Authenticator patches matching this file's existing convention --- litellm/llms/github_copilot/authenticator.py | 3 +-- ...github_copilot_responses_transformation.py | 11 +++++++--- .../test_github_copilot_authenticator.py | 21 +++++++++++-------- 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/litellm/llms/github_copilot/authenticator.py b/litellm/llms/github_copilot/authenticator.py index dd94c25cc77..139e565f810 100644 --- a/litellm/llms/github_copilot/authenticator.py +++ b/litellm/llms/github_copilot/authenticator.py @@ -19,7 +19,6 @@ from .common_utils import ( 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" @@ -147,7 +146,7 @@ class Authenticator: return self.get_access_token() except GetAccessTokenError as e: raise GetAPIKeyError( - message=f"Failed to get OAuth access token: {str(e)}", + message=f"Failed to get OAuth access token: {e!s}", status_code=401, ) diff --git a/tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py b/tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py index b6e08cdf22f..5d0a6d20dad 100644 --- a/tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py +++ b/tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py @@ -7,7 +7,8 @@ transformations for the Responses API. Source: litellm/llms/github_copilot/responses/transformation.py """ -from unittest.mock import patch, MagicMock +import os +from unittest.mock import MagicMock, patch import httpx import pytest @@ -100,7 +101,9 @@ class TestGithubCopilotResponsesAPITransformation: 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") + @patch( # test-quality-ok: matches the Authenticator-mocking convention already used throughout this file + "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" @@ -133,7 +136,9 @@ class TestGithubCopilotResponsesAPITransformation: 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") + @patch( # test-quality-ok: matches the Authenticator-mocking convention already used throughout this file + "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" diff --git a/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py b/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py index fdef7227964..19aa0c3bc91 100644 --- a/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py +++ b/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py @@ -1,3 +1,4 @@ +import logging import os import time from concurrent.futures import ThreadPoolExecutor @@ -72,15 +73,16 @@ class TestGitHubCopilotAuthenticator: "https://api.githubcopilot.com#fragment", ), ) - def test_get_api_base_rejects_insecure_configuration(self, authenticator, api_base): + def test_get_api_base_rejects_insecure_configuration(self, authenticator, api_base, caplog): with ( patch.dict(os.environ, {"GITHUB_COPILOT_API_BASE": api_base}, clear=True), - patch("litellm.llms.github_copilot.authenticator.verbose_logger.warning") as mock_warning, + caplog.at_level(logging.WARNING, logger="LiteLLM"), ): assert authenticator.get_api_base() is None - mock_warning.assert_called_once_with( + assert ( "Ignoring GITHUB_COPILOT_API_BASE because it must be an HTTPS URL without credentials, query, or fragment" + in caplog.text ) def test_get_api_base_uses_default_when_unconfigured(self, authenticator): @@ -95,19 +97,20 @@ class TestGitHubCopilotAuthenticator: ): 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): + def test_get_api_base_falls_back_from_untrusted_deployment_endpoint(self, authenticator, caplog): with ( patch.dict( os.environ, {"GITHUB_COPILOT_API_BASE": "https://configured.example.com"}, clear=True, ), - patch("litellm.llms.github_copilot.authenticator.verbose_logger.warning") as mock_warning, + caplog.at_level(logging.WARNING, logger="LiteLLM"), ): assert authenticator.get_api_base("http://attacker.example.com") == "https://configured.example.com" - mock_warning.assert_called_once_with( + assert ( "Ignoring deployment api_base because it must be an HTTPS URL without credentials, query, or fragment" + in caplog.text ) def test_get_github_headers(self, authenticator): @@ -206,19 +209,19 @@ class TestGitHubCopilotAuthenticator: mock_login.assert_called_once() write_open().write.assert_called_once_with(mock_token) - def test_get_access_token_survives_persistence_failure(self, authenticator): + def test_get_access_token_survives_persistence_failure(self, authenticator, caplog): mock_token = "mock-access-token" with ( 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, + caplog.at_level(logging.ERROR, logger="LiteLLM"), ): token = authenticator.get_access_token() assert token == mock_token mock_login.assert_called_once() - mock_error.assert_called_once_with("Error saving access token to file") + assert "Error saving access token to file" in caplog.text @pytest.mark.asyncio async def test_get_access_token_refuses_device_code_login_in_event_loop(self, authenticator): From f06ed3f3628a6f1818c645c28e79cacb2b011704 Mon Sep 17 00:00:00 2001 From: codgician <15964984+codgician@users.noreply.github.com> Date: Sat, 26 Sep 2026 15:29:17 +0800 Subject: [PATCH 13/32] fix(github-copilot): use lazy logging for rejected API bases --- litellm/llms/github_copilot/authenticator.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/litellm/llms/github_copilot/authenticator.py b/litellm/llms/github_copilot/authenticator.py index 139e565f810..f1ade43fb53 100644 --- a/litellm/llms/github_copilot/authenticator.py +++ b/litellm/llms/github_copilot/authenticator.py @@ -161,7 +161,8 @@ class Authenticator: if _is_secure_api_base(candidate): return candidate verbose_logger.warning( - f"Ignoring {source} because it must be an HTTPS URL without credentials, query, or fragment" + "Ignoring %s because it must be an HTTPS URL without credentials, query, or fragment", + source, ) return None From b8575d8582b3c66cb01b8c0ae33a9059c2674f52 Mon Sep 17 00:00:00 2001 From: codgician <15964984+codgician@users.noreply.github.com> Date: Sat, 26 Sep 2026 15:30:51 +0800 Subject: [PATCH 14/32] test(github-copilot): align rebased tests with unified OAuth flow --- .../llm_response_utils/test_get_api_base.py | 4 ++-- .../llms/github_copilot/test_github_copilot_transformation.py | 3 --- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/tests/unit/litellm_core_utils/llm_response_utils/test_get_api_base.py b/tests/unit/litellm_core_utils/llm_response_utils/test_get_api_base.py index 90fd5ba6c88..9a09347c076 100644 --- a/tests/unit/litellm_core_utils/llm_response_utils/test_get_api_base.py +++ b/tests/unit/litellm_core_utils/llm_response_utils/test_get_api_base.py @@ -53,7 +53,7 @@ class TestDeclaredAuthenticatingProvider: assert resolution_lookups == [] assert api_base == expected - def test_copilot_keeps_the_enterprise_endpoint_from_disk(self, isolated_token_dirs, resolution_lookups): + def test_copilot_ignores_the_legacy_endpoint_on_disk(self, isolated_token_dirs, resolution_lookups): token_dir = isolated_token_dirs / "github_copilot" token_dir.mkdir() (token_dir / "api-key.json").write_text( @@ -63,7 +63,7 @@ class TestDeclaredAuthenticatingProvider: api_base = litellm.get_api_base(model="github_copilot/gpt-4o", optional_params={}) assert resolution_lookups == [] - assert api_base == "https://api.enterprise.githubcopilot.com" + assert api_base == DEFAULT_GITHUB_COPILOT_API_BASE def test_explicit_api_base_still_wins(self, isolated_token_dirs, resolution_lookups): api_base = litellm.get_api_base( diff --git a/tests/unit/llms/github_copilot/test_github_copilot_transformation.py b/tests/unit/llms/github_copilot/test_github_copilot_transformation.py index b20261bb6ac..e3278f5396b 100644 --- a/tests/unit/llms/github_copilot/test_github_copilot_transformation.py +++ b/tests/unit/llms/github_copilot/test_github_copilot_transformation.py @@ -370,9 +370,6 @@ def test_x_initiator_header_system_only_messages(): assert headers["X-Initiator"] == "user" - - - def test_copilot_vision_request_header_with_image(): """Test that Copilot-Vision-Request header is added when messages contain images""" config = GithubCopilotConfig() From b73c04dac761d6993c355a5e4e7a4ff6e626ab4f Mon Sep 17 00:00:00 2001 From: codgician <15964984+codgician@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:28:09 +0800 Subject: [PATCH 15/32] test(router): seed Copilot OAuth token in authenticating-provider tests --- tests/unit/router_strategy/test_complexity_router.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/tests/unit/router_strategy/test_complexity_router.py b/tests/unit/router_strategy/test_complexity_router.py index 253850bbd79..68222301d5f 100644 --- a/tests/unit/router_strategy/test_complexity_router.py +++ b/tests/unit/router_strategy/test_complexity_router.py @@ -5059,11 +5059,8 @@ class TestRouterPreRoutingAliasOverrides: string, and model info adopts the declared prefix. The recording wrapper raises for a copilot-directed resolution rather than calling through, so a regression fails on the recorded call instead of hanging the suite in a device-code poll.""" - import json - import time - monkeypatch.setenv("GITHUB_COPILOT_TOKEN_DIR", str(tmp_path)) - (tmp_path / "api-key.json").write_text(json.dumps({"token": "tid=test", "expires_at": int(time.time()) + 3600})) + (tmp_path / "access-token").write_text("gho_test") router = Router( model_list=[ { @@ -14624,11 +14621,8 @@ class TestContextWindowEscalation: """Resolving github_copilot runs its OAuth device flow, so a window question must adopt the declaration instead of resolving: the copilot group reads as unknown-window and the request stays put, with zero copilot resolutions recorded.""" - import json - import time - monkeypatch.setenv("GITHUB_COPILOT_TOKEN_DIR", str(tmp_path)) - (tmp_path / "api-key.json").write_text(json.dumps({"token": "tid=test", "expires_at": int(time.time()) + 3600})) + (tmp_path / "access-token").write_text("gho_test") router = ComplexityRouter( model_name="test-router", litellm_router_instance=Router( From ea962df67d55e0d7383cfbd35f9e4677771cc431 Mon Sep 17 00:00:00 2001 From: codgician <15964984+codgician@users.noreply.github.com> Date: Sun, 11 Oct 2026 10:20:38 +0800 Subject: [PATCH 16/32] refactor(github-copilot): unify shared and per-user OAuth flows Per-user connections now send the stored GitHub OAuth token directly to Copilot like the shared login, instead of exchanging it through copilot_internal/v2/token. Both paths share one configurable header builder for GitHub OAuth calls and one API-base resolver, which accepts http and https and still rejects credentials, query, and fragment. The connect-time seat check calls Copilot's /models with the user's token. The exchange session cache, its eviction hooks, and the unused listing helper are removed. --- litellm/constants.py | 1 - .../get_llm_provider_logic.py | 8 +- litellm/llms/github_copilot/authenticator.py | 52 -- .../github_copilot/chat/transformation.py | 4 +- litellm/llms/github_copilot/common_utils.py | 42 +- .../embedding/transformation.py | 17 +- .../github_copilot/messages/transformation.py | 23 +- litellm/llms/github_copilot/per_user_auth.py | 325 ++----------- .../responses/transformation.py | 17 +- .../proxy/credential_endpoints/endpoints.py | 18 +- .../user_provider_credentials.py | 7 - litellm/utils.py | 4 +- tests/integration/_support/device_login.py | 26 +- .../test_device_code_login_guard_boot.py | 10 +- .../test_device_code_login_guard_wire.py | 89 +--- ...responses_minted_reasoning_replay_chaos.py | 7 +- .../sdk/test_device_code_login_guard_sdk.py | 4 +- .../llm_response_utils/test_get_api_base.py | 17 +- ...github_copilot_embedding_transformation.py | 99 ++-- ..._github_copilot_messages_transformation.py | 186 ++++---- ...github_copilot_responses_transformation.py | 73 +-- .../test_github_copilot_authenticator.py | 74 ++- .../test_github_copilot_transformation.py | 54 +-- .../llms/github_copilot/test_per_user_auth.py | 445 ++++++------------ .../credential_endpoints/test_endpoints.py | 39 +- .../proxy/proxy_server/test_routes_utils.py | 10 - .../router_utils/test_cooldown_handlers.py | 85 ++-- 27 files changed, 607 insertions(+), 1129 deletions(-) diff --git a/litellm/constants.py b/litellm/constants.py index 38d16d61d7b..8e4d9766996 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -15,7 +15,6 @@ RESPONSE_CACHE_EXCLUDED_PROVIDERS: Final = frozenset({"microsoft_365_copilot"}) MICROSOFT_365_COPILOT_DEFAULT_TIME_ZONE: Final = "UTC" GITHUB_COPILOT_PER_USER_AUTH_TYPE: Final = "per_user_oauth" GITHUB_COPILOT_AUTH_TYPE_KEY: Final = "github_copilot_auth_type" -GITHUB_COPILOT_USER_TOKEN_SAFETY_MARGIN_SECONDS: Final = 60 GITHUB_COPILOT_USER_CREDENTIAL_CACHE_TTL_SECONDS: Final = 60 GITHUB_COPILOT_DEVICE_FLOW_CACHE_PREFIX: Final = "github_copilot_device_flow" USER_PROVIDER_CREDENTIAL_CACHE_PREFIX: Final = "user_provider_credential" diff --git a/litellm/litellm_core_utils/get_llm_provider_logic.py b/litellm/litellm_core_utils/get_llm_provider_logic.py index c9e5167e7a9..128a396748c 100644 --- a/litellm/litellm_core_utils/get_llm_provider_logic.py +++ b/litellm/litellm_core_utils/get_llm_provider_logic.py @@ -795,7 +795,7 @@ def _get_openai_compatible_provider_info( api_base = api_base or get_secret("GALADRIEL_API_BASE") or "https://api.galadriel.com/v1" dynamic_api_key = api_key or get_secret_str("GALADRIEL_API_KEY") elif custom_llm_provider == "github_copilot": - from litellm.llms.github_copilot.common_utils import DEFAULT_GITHUB_COPILOT_API_BASE + from litellm.llms.github_copilot.common_utils import get_copilot_api_base from litellm.llms.github_copilot.per_user_auth import ( github_copilot_per_user_credential_name, github_copilot_user_session_from, @@ -805,7 +805,7 @@ def _get_openai_compatible_provider_info( cast("dict[str, object]", litellm_params) # cast-ok: litellm_params arrives as an untyped request dict ) if user_session is not None: - api_base = user_session.api_base # rebind-ok: resolves provider args in place + api_base = get_copilot_api_base(api_base) # rebind-ok: resolves provider args in place dynamic_api_key = user_session.token # rebind-ok: resolves provider args in place elif ( github_copilot_per_user_credential_name( @@ -814,8 +814,8 @@ def _get_openai_compatible_provider_info( is not None ): # per-user deployments resolve no token at provider-info time; the - # caller's session supplies base + key per request - api_base = api_base or DEFAULT_GITHUB_COPILOT_API_BASE # rebind-ok: resolves provider args in place + # caller's session supplies the key per request + api_base = get_copilot_api_base(api_base) # rebind-ok: resolves provider args in place dynamic_api_key = None # rebind-ok: resolves provider args in place else: ( diff --git a/litellm/llms/github_copilot/authenticator.py b/litellm/llms/github_copilot/authenticator.py index f1ade43fb53..2a7d927dd27 100644 --- a/litellm/llms/github_copilot/authenticator.py +++ b/litellm/llms/github_copilot/authenticator.py @@ -2,7 +2,6 @@ import json import os import time from typing import Final -from urllib.parse import urlsplit import httpx from pydantic import ConfigDict, TypeAdapter, with_config @@ -23,7 +22,6 @@ from .common_utils import ( 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" @with_config(ConfigDict(extra="allow", strict=True)) @@ -44,40 +42,6 @@ _DEVICE_CODE: Final = TypeAdapter(_DeviceCode) _ACCESS_TOKEN_POLL: Final = TypeAdapter(_AccessTokenPoll) -def github_api_headers( - access_token: str | None = None, -) -> dict[str, str]: # mutable-ok: returned straight into httpx handlers whose headers params require dict - 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", - "content-type": "application/json", - } - if access_token: - headers["authorization"] = f"token {access_token}" - return headers - - -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 _is_secure_api_base(api_base: str) -> bool: - return _https_hostname(api_base) is not None - - class Authenticator: def __init__(self) -> None: """Initialize the GitHub Copilot authenticator with configurable token paths.""" @@ -150,22 +114,6 @@ class Authenticator: status_code=401, ) - 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_secure_api_base(candidate): - return candidate - verbose_logger.warning( - "Ignoring %s because it must be an HTTPS URL without credentials, query, or fragment", - source, - ) - return None - def _ensure_token_dir(self) -> None: """Ensure the token directory exists.""" if not os.path.exists(self.token_dir): diff --git a/litellm/llms/github_copilot/chat/transformation.py b/litellm/llms/github_copilot/chat/transformation.py index 570bb5aafe8..1eed0470562 100644 --- a/litellm/llms/github_copilot/chat/transformation.py +++ b/litellm/llms/github_copilot/chat/transformation.py @@ -12,8 +12,8 @@ from litellm.types.utils import ModelResponse from ..authenticator import Authenticator from ..common_utils import ( - DEFAULT_GITHUB_COPILOT_API_BASE, GetAPIKeyError, + get_copilot_api_base, get_copilot_default_headers, ) from ..per_user_auth import require_github_copilot_user_session @@ -34,7 +34,7 @@ class GithubCopilotConfig(OpenAIConfig): self.authenticator = Authenticator() def api_base_without_login(self, api_base: str | None = None) -> str: - return self.authenticator.get_api_base(api_base) or DEFAULT_GITHUB_COPILOT_API_BASE + return get_copilot_api_base(api_base) def _get_openai_compatible_provider_info( self, diff --git a/litellm/llms/github_copilot/common_utils.py b/litellm/llms/github_copilot/common_utils.py index e8204cb6491..9efe96e49a2 100644 --- a/litellm/llms/github_copilot/common_utils.py +++ b/litellm/llms/github_copilot/common_utils.py @@ -5,9 +5,11 @@ Constants for Copilot integration import os from collections.abc import MutableMapping from typing import Final +from urllib.parse import urlsplit import httpx +from litellm._logging import verbose_logger from litellm.llms.base_llm.chat.transformation import BaseLLMException COPILOT_VERSION: Final = "0.26.7" @@ -88,17 +90,51 @@ def _get_configured_copilot_headers( } -def get_copilot_auth_headers() -> dict[str, str]: - return _get_configured_copilot_headers(_COPILOT_AUTH_HEADER_CONFIG) +def get_copilot_auth_headers( + access_token: str | None = None, +) -> dict[str, str]: # mutable-ok: returned straight into httpx handlers whose headers params require dict + headers: Final = _get_configured_copilot_headers(_COPILOT_AUTH_HEADER_CONFIG) + return {**headers, "authorization": f"token {access_token}"} if access_token else headers -def get_copilot_default_headers(api_key: str) -> dict[str, str]: +def get_copilot_default_headers( + api_key: str, +) -> dict[str, str]: # mutable-ok: callers merge caller headers into the returned dict return { **_get_configured_copilot_headers(_COPILOT_REQUEST_HEADER_CONFIG), "Authorization": f"Bearer {api_key}", } +def _is_valid_api_base(api_base: str) -> bool: + parsed_api_base: Final = urlsplit(api_base) + return ( + parsed_api_base.scheme.lower() in ("http", "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 get_copilot_api_base(api_base: str | None = None) -> str: + candidates: Final = ( + ("deployment api_base", api_base), + ("GITHUB_COPILOT_API_BASE", os.getenv("GITHUB_COPILOT_API_BASE")), + ) + for source, candidate in candidates: + if not candidate: + continue + if _is_valid_api_base(candidate): + return candidate + verbose_logger.warning( + "Ignoring %s because it must be an HTTP(S) URL without credentials, query, or fragment", + source, + ) + return DEFAULT_GITHUB_COPILOT_API_BASE + + def pin_session_authorization( headers: MutableMapping[str, str], # mutable-ok: the helper fixes caller-owned headers in place session_token: str, diff --git a/litellm/llms/github_copilot/embedding/transformation.py b/litellm/llms/github_copilot/embedding/transformation.py index 421a6a3de91..a6e22f3a26e 100644 --- a/litellm/llms/github_copilot/embedding/transformation.py +++ b/litellm/llms/github_copilot/embedding/transformation.py @@ -26,8 +26,8 @@ from litellm.utils import convert_to_model_response_object from ..authenticator import Authenticator from ..common_utils import ( - DEFAULT_GITHUB_COPILOT_API_BASE, GetAPIKeyError, + get_copilot_api_base, get_copilot_default_headers, pin_session_authorization, ) @@ -116,20 +116,7 @@ class GithubCopilotEmbeddingConfig(BaseEmbeddingConfig): """ Get the complete URL for GitHub Copilot Embedding API endpoint. """ - user_session: Final = require_github_copilot_user_session( - cast("dict[str, object]", litellm_params) # cast-ok: litellm_params arrives as an untyped request dict - ) - effective_api_base = ( - (user_session.api_base if user_session is not None else None) - or self.authenticator.get_api_base(api_base) - or DEFAULT_GITHUB_COPILOT_API_BASE - ) - - # Remove trailing slashes - effective_api_base = effective_api_base.rstrip("/") - - # Return the embeddings endpoint - return f"{effective_api_base}/embeddings" + return f"{get_copilot_api_base(api_base).rstrip('/')}/embeddings" def transform_embedding_request( self, diff --git a/litellm/llms/github_copilot/messages/transformation.py b/litellm/llms/github_copilot/messages/transformation.py index be138755f85..14cb392d4f0 100644 --- a/litellm/llms/github_copilot/messages/transformation.py +++ b/litellm/llms/github_copilot/messages/transformation.py @@ -7,8 +7,8 @@ from litellm.llms.anthropic.pass_through.messages.transformation import ( from ..authenticator import Authenticator from ..common_utils import ( - DEFAULT_GITHUB_COPILOT_API_BASE, GetAPIKeyError, + get_copilot_api_base, get_copilot_default_headers, pin_session_authorization, ) @@ -66,18 +66,13 @@ class GithubCopilotAnthropicMessagesConfig(AnthropicMessagesConfig): request anywhere other than the authenticated Copilot endpoint would leak the Copilot bearer token to a caller-controlled URL. """ - # Always use the Copilot endpoint resolved from the authenticated - # session, never the caller-supplied api_base. rstrip so a - # tenant-specific base with a trailing slash does not yield a + # Always use the configured Copilot endpoint, never the caller-supplied + # api_base. rstrip so a base with a trailing slash does not yield a # double-slash URL once "/v1/messages" is appended downstream. user_session: Final = require_github_copilot_user_session( cast("dict[str, object]", litellm_params) # cast-ok: litellm_params arrives as an untyped request dict ) - dynamic_api_base: Final = ( - user_session.api_base - if user_session is not None - else (self.authenticator.get_api_base() or DEFAULT_GITHUB_COPILOT_API_BASE) - ).rstrip("/") + dynamic_api_base: Final = get_copilot_api_base().rstrip("/") try: dynamic_api_key: Final = ( user_session.token if user_session is not None else self.authenticator.get_api_key() @@ -132,13 +127,5 @@ class GithubCopilotAnthropicMessagesConfig(AnthropicMessagesConfig): reuse it to avoid a second authenticator read, falling back to a fresh resolution only if it was not provided. """ - user_session: Final = require_github_copilot_user_session( - cast("dict[str, object]", litellm_params) # cast-ok: litellm_params arrives as an untyped request dict - ) - resolved: Final = ( - (user_session.api_base if user_session is not None else None) - or api_base - or self.authenticator.get_api_base() - or DEFAULT_GITHUB_COPILOT_API_BASE - ).rstrip("/") + resolved: Final = (api_base or get_copilot_api_base()).rstrip("/") return resolved if resolved.endswith("/v1/messages") else f"{resolved}/v1/messages" diff --git a/litellm/llms/github_copilot/per_user_auth.py b/litellm/llms/github_copilot/per_user_auth.py index 10e5e12dec3..307c7fa4738 100644 --- a/litellm/llms/github_copilot/per_user_auth.py +++ b/litellm/llms/github_copilot/per_user_auth.py @@ -1,14 +1,10 @@ -"""Per-user GitHub Copilot OAuth: exchange a caller's stored GitHub token for a -short-lived Copilot token, plus the device-flow helpers the proxy endpoints use -to establish that connection. The shared device login (Authenticator) is never +"""Per-user GitHub Copilot OAuth: send a caller's stored GitHub OAuth token +directly to Copilot, plus the device-flow helpers the proxy endpoints use to +establish that connection. The shared device login (Authenticator) is never touched on the per-user path. """ -import asyncio -import hashlib import os -import threading -import time from collections.abc import Callable, Mapping from dataclasses import dataclass, field from typing import ( @@ -18,17 +14,14 @@ from typing import ( TypeAlias, cast, # noqa: TID251 # casts pin the untyped litellm clients/caches to the local Protocols ) -from urllib.parse import urlparse import httpx -from pydantic import ConfigDict, TypeAdapter, ValidationError, with_config +from pydantic import ConfigDict, TypeAdapter, with_config from typing_extensions import NotRequired, ReadOnly, TypedDict -from litellm.caching.in_memory_cache import InMemoryCache from litellm.constants import ( GITHUB_COPILOT_AUTH_TYPE_KEY, GITHUB_COPILOT_PER_USER_AUTH_TYPE, - GITHUB_COPILOT_USER_TOKEN_SAFETY_MARGIN_SECONDS, ) from litellm.exceptions import ( APIConnectionError, @@ -40,35 +33,14 @@ from litellm.exceptions import ( from .authenticator import ( DEFAULT_GITHUB_ACCESS_TOKEN_URL, - DEFAULT_GITHUB_API_KEY_URL, DEFAULT_GITHUB_CLIENT_ID, DEFAULT_GITHUB_DEVICE_CODE_URL, - github_api_headers, ) -from .common_utils import DEFAULT_GITHUB_COPILOT_API_BASE +from .common_utils import get_copilot_api_base, get_copilot_auth_headers, get_copilot_default_headers GITHUB_COPILOT_USER_SESSION_KWARG_KEY: Final = "github_copilot_user_session" _LLM_PROVIDER: Final = "github_copilot" _DEVICE_FLOW_SCOPE: Final = "read:user" -_SESSION_CACHE_MAX_SIZE: Final = 1000 -_SYNC_LOCK_COUNT: Final = 64 - - -class _SessionCache(Protocol): - def get_cache(self, key: str) -> object: ... - - def set_cache(self, key: str, value: object, ttl: float | None = None) -> object: ... - - def delete_cache(self, key: str) -> object: ... - - -class _SyncGetClient(Protocol): - def get( - self, - url: str, - params: dict[str, str] | None = None, # mutable-ok: mirrors the untyped client's dict parameters - headers: dict[str, str] | None = None, # mutable-ok: mirrors the untyped client's dict parameters - ) -> httpx.Response: ... class _AsyncGitHubClient(Protocol): @@ -89,26 +61,6 @@ class _AsyncGitHubClient(Protocol): ) -> httpx.Response: ... -_SESSION_CACHE: Final = cast( # cast-ok: pins the untyped module cache to _SessionCache - _SessionCache, - InMemoryCache( - max_size_in_memory=_SESSION_CACHE_MAX_SIZE, - default_ttl=GITHUB_COPILOT_USER_TOKEN_SAFETY_MARGIN_SECONDS, - ), -) -_EXCHANGE_LOCKS: Final = tuple(threading.Lock() for _ in range(_SYNC_LOCK_COUNT)) -_IN_FLIGHT: Final[ # mutable-ok: single-flight dedup registry mutated under its own locking - dict[str, "asyncio.Future[GithubCopilotUserSession]"] -] = {} - - -@with_config(ConfigDict(extra="allow", strict=True)) -class _CopilotTokenPayload(TypedDict): - token: ReadOnly[str] - expires_at: ReadOnly[NotRequired[object]] - endpoints: ReadOnly[NotRequired[object]] - - @with_config(ConfigDict(extra="allow", strict=True)) class _DeviceCodePayload(TypedDict): device_code: ReadOnly[str] @@ -126,35 +78,26 @@ class _AccessTokenPollPayload(TypedDict): interval: ReadOnly[NotRequired[object]] -@with_config(ConfigDict(extra="allow", strict=True)) -class _CopilotEndpointsPayload(TypedDict): - api: ReadOnly[NotRequired[object]] - - @with_config(ConfigDict(extra="allow", strict=True)) class _GithubUserPayload(TypedDict): login: ReadOnly[str] -_COPILOT_TOKEN: Final = TypeAdapter(_CopilotTokenPayload) _DEVICE_CODE: Final = TypeAdapter(_DeviceCodePayload) _ACCESS_TOKEN_POLL: Final = TypeAdapter(_AccessTokenPollPayload) _GITHUB_USER: Final = TypeAdapter(_GithubUserPayload) -_COPILOT_ENDPOINTS: Final = TypeAdapter(_CopilotEndpointsPayload) -_CACHED_SESSION_ENTRY: Final = TypeAdapter(tuple[str, str, float]) @dataclass(frozen=True, slots=True, repr=False) class GithubCopilotUserSession: - """A resolved per-user Copilot token plus the validated host it may be sent - to. Only this module constructs it, so ``isinstance`` checks downstream are - unforgeable by request JSON.""" + """A caller's stored GitHub OAuth token, resolved for this request. Only this + module constructs it, so ``isinstance`` checks downstream are unforgeable by + request JSON.""" token: str = field(repr=False) - api_base: str def __repr__(self) -> str: - return f"GithubCopilotUserSession(api_base={self.api_base!r}, token=REDACTED)" + return "GithubCopilotUserSession(token=REDACTED)" __str__ = __repr__ @@ -178,55 +121,6 @@ class GithubCopilotDeviceFlowPoll: access_token: str | None = field(default=None, repr=False) -def validated_copilot_api_base(endpoints_api: object) -> str: - """Only a genuine *.githubcopilot.com HTTPS endpoint may receive a Copilot - token; anything else falls back to the default base.""" - if not isinstance(endpoints_api, str): - return DEFAULT_GITHUB_COPILOT_API_BASE - parsed: Final = urlparse(endpoints_api) - hostname: Final = (parsed.hostname or "").lower() - if ( - parsed.scheme.lower() != "https" - or not (hostname == "githubcopilot.com" or hostname.endswith(".githubcopilot.com")) - or parsed.username is not None - or parsed.password is not None - ): - return DEFAULT_GITHUB_COPILOT_API_BASE - try: - port: Final = parsed.port - except ValueError: - return DEFAULT_GITHUB_COPILOT_API_BASE - if port not in (None, 443): - return DEFAULT_GITHUB_COPILOT_API_BASE - return endpoints_api.rstrip("/") - - -def _session_cache_key(user_id: str, github_token: str) -> str: - return hashlib.sha256(f"{user_id}\x00{github_token}".encode()).hexdigest() - - -def evict_copilot_user_session(user_id: str, github_token: str) -> None: - _SESSION_CACHE.delete_cache(_session_cache_key(user_id, github_token)) - - -def _cached_session(cache_key: str) -> GithubCopilotUserSession | None: - cached: Final[object] = _SESSION_CACHE.get_cache(cache_key) - try: - token, api_base, expires_at = _CACHED_SESSION_ENTRY.validate_python(cached) - except ValidationError: - return None - if expires_at - GITHUB_COPILOT_USER_TOKEN_SAFETY_MARGIN_SECONDS <= time.time(): - return None - return GithubCopilotUserSession(token=token, api_base=api_base) - - -def _cache_session(cache_key: str, session: GithubCopilotUserSession, expires_at: float) -> None: - ttl: Final = expires_at - GITHUB_COPILOT_USER_TOKEN_SAFETY_MARGIN_SECONDS - time.time() - if ttl <= 0: - return - _SESSION_CACHE.set_cache(cache_key, (session.token, session.api_base, expires_at), ttl=ttl) - - def _reject_connection(credential_name: str) -> CallerCredentialAuthenticationError: return CallerCredentialAuthenticationError( message=( @@ -238,43 +132,6 @@ def _reject_connection(credential_name: str) -> CallerCredentialAuthenticationEr ) -def _session_from_response(response: httpx.Response, credential_name: str, cache_key: str) -> GithubCopilotUserSession: - if response.status_code in (401, 403, 404): - _SESSION_CACHE.delete_cache(cache_key) - raise _reject_connection(credential_name) - if response.status_code == 429: - raise CallerCredentialRateLimitError( - message="GitHub rate limited the GitHub Copilot token exchange", - llm_provider=_LLM_PROVIDER, - model="", - ) - if not response.is_success: - raise ServiceUnavailableError( - message=f"GitHub Copilot token exchange failed with status {response.status_code}", - llm_provider=_LLM_PROVIDER, - model="", - ) - try: - payload: Final = _COPILOT_TOKEN.validate_python(response.json()) - except Exception as e: - raise ServiceUnavailableError( - message="GitHub Copilot token exchange returned an invalid response", - llm_provider=_LLM_PROVIDER, - model="", - ) from e - - endpoints: Final[object] = payload.get("endpoints") - endpoints_api: Final[object] = ( - _COPILOT_ENDPOINTS.validate_python(endpoints).get("api") if isinstance(endpoints, Mapping) else None - ) - api_base: Final = validated_copilot_api_base(endpoints_api) - session: Final = GithubCopilotUserSession(token=payload["token"], api_base=api_base) - expires_at: Final = _expires_at_seconds(payload.get("expires_at")) - if expires_at > 0: - _cache_session(cache_key, session, expires_at) - return session - - def _to_int(raw: object) -> int: if isinstance(raw, bool): return 0 @@ -290,39 +147,6 @@ def _to_int(raw: object) -> int: return 0 -def _expires_at_seconds(raw: object) -> float: - if isinstance(raw, bool): - return 0.0 - if isinstance(raw, (int, float)): - return float(raw) - if isinstance(raw, str): - try: - return float(raw) - except ValueError: - return 0.0 - return 0.0 - - -def _do_exchange(github_token: str, credential_name: str, cache_key: str) -> GithubCopilotUserSession: - import litellm - - try: - client: Final = cast( # cast-ok: pins the untyped litellm client to the local Protocol - _SyncGetClient, litellm.module_level_client - ) - response: Final = client.get( - DEFAULT_GITHUB_API_KEY_URL, - headers=github_api_headers(github_token), - ) - except Exception as e: - raise APIConnectionError( - message="GitHub Copilot token exchange request failed", - llm_provider=_LLM_PROVIDER, - model="", - ) from e - return _session_from_response(response, credential_name, cache_key) - - def _new_async_github_client() -> _AsyncGitHubClient: from litellm.llms.custom_httpx import http_handler @@ -332,70 +156,6 @@ def _new_async_github_client() -> _AsyncGitHubClient: )(_LLM_PROVIDER) -async def _do_aexchange(github_token: str, credential_name: str, cache_key: str) -> GithubCopilotUserSession: - client: Final = _new_async_github_client() - try: - response: Final = await client.get( - DEFAULT_GITHUB_API_KEY_URL, - headers=github_api_headers(github_token), - ) - except Exception as e: - raise APIConnectionError( - message="GitHub Copilot token exchange request failed", - llm_provider=_LLM_PROVIDER, - model="", - ) from e - return _session_from_response(response, credential_name, cache_key) - - -def exchange_github_token( - user_id: str, - github_token: str, - credential_name: str, -) -> GithubCopilotUserSession: - cache_key: Final = _session_cache_key(user_id, github_token) - cached: Final = _cached_session(cache_key) - if cached is not None: - return cached - lock: Final = _EXCHANGE_LOCKS[int(cache_key, 16) % _SYNC_LOCK_COUNT] - with lock: - rechecked: Final = _cached_session(cache_key) - if rechecked is not None: - return rechecked - return _do_exchange(github_token, credential_name, cache_key) - - -async def aexchange_github_token( - user_id: str, - github_token: str, - credential_name: str, -) -> GithubCopilotUserSession: - cache_key: Final = _session_cache_key(user_id, github_token) - cached: Final = _cached_session(cache_key) - if cached is not None: - return cached - loop: Final = asyncio.get_running_loop() - in_flight: Final = _IN_FLIGHT.get(cache_key) - if in_flight is not None and not in_flight.done() and in_flight.get_loop() is loop: - return await asyncio.shield(in_flight) - future: Final = loop.create_future() - _IN_FLIGHT[cache_key] = future - try: - session: Final = await _do_aexchange(github_token, credential_name, cache_key) - except BaseException as e: - if not future.done(): - future.set_exception(e) - future.exception() # consume so unawaited waiters do not log "exception was never retrieved" - raise - else: - if not future.done(): - future.set_result(session) - return session - finally: - if _IN_FLIGHT.get(cache_key) is future: - _IN_FLIGHT.pop(cache_key, None) - - async def astart_device_flow() -> GithubCopilotDeviceFlowStart: client: Final = _new_async_github_client() device_code_url: Final = os.getenv("GITHUB_COPILOT_DEVICE_CODE_URL", DEFAULT_GITHUB_DEVICE_CODE_URL) @@ -403,7 +163,7 @@ async def astart_device_flow() -> GithubCopilotDeviceFlowStart: try: response: Final = await client.post( device_code_url, - headers=github_api_headers(), + headers=get_copilot_auth_headers(), json={"client_id": client_id, "scope": _DEVICE_FLOW_SCOPE}, ) except httpx.HTTPStatusError as e: @@ -442,7 +202,7 @@ async def apoll_device_flow(device_code: str) -> GithubCopilotDeviceFlowPoll: try: response: Final = await client.post( access_token_url, - headers=github_api_headers(), + headers=get_copilot_auth_headers(), json={ "client_id": client_id, "device_code": device_code, @@ -505,7 +265,7 @@ async def afetch_github_login(github_token: str) -> str: try: response: Final = await client.get( "https://api.github.com/user", - headers=github_api_headers(github_token), + headers=get_copilot_auth_headers(github_token), ) except Exception as e: raise APIConnectionError( @@ -530,12 +290,33 @@ async def afetch_github_login(github_token: str) -> str: return payload["login"] -async def acheck_copilot_seat(user_id: str, github_token: str, credential_name: str) -> GithubCopilotUserSession: - return await aexchange_github_token( - user_id=user_id, - github_token=github_token, - credential_name=credential_name, - ) +async def acheck_copilot_seat(github_token: str, credential_name: str) -> None: + client: Final = _new_async_github_client() + try: + response: Final = await client.get( + f"{get_copilot_api_base().rstrip('/')}/models", + headers=get_copilot_default_headers(github_token), + ) + except Exception as e: + raise APIConnectionError( + message="GitHub Copilot seat check request failed", + llm_provider=_LLM_PROVIDER, + model="", + ) from e + if response.status_code in (401, 403, 404): + raise _reject_connection(credential_name) + if response.status_code == 429: + raise CallerCredentialRateLimitError( + message="GitHub Copilot rate limited the seat check", + llm_provider=_LLM_PROVIDER, + model="", + ) + if not response.is_success: + raise ServiceUnavailableError( + message=f"GitHub Copilot seat check failed with status {response.status_code}", + llm_provider=_LLM_PROVIDER, + model="", + ) def github_copilot_auth_mode(litellm_credential_name: object, auth_type_value: object) -> bool: @@ -711,31 +492,7 @@ def attach_github_copilot_user_session( if isinstance(kwargs.get(GITHUB_COPILOT_USER_SESSION_KWARG_KEY), GithubCopilotUserSession): return credential_name: Final = credential_name_obj if isinstance(credential_name_obj, str) else "" - user_id, github_token = _caller_connection(kwargs, credential_name) - session: Final = exchange_github_token( - user_id=user_id, - github_token=github_token, - credential_name=credential_name, - ) - kwargs[GITHUB_COPILOT_USER_SESSION_KWARG_KEY] = session # rebind-ok: kwargs is the request mutation channel - _strip_caller_authorization(kwargs) - - -async def aattach_github_copilot_user_session( - kwargs: dict[str, object], # mutable-ok: kwargs is the request mutation channel -) -> None: - credential_name_obj: Final[object] = kwargs.get("litellm_credential_name") - auth_type_obj: Final[object] = kwargs.get(GITHUB_COPILOT_AUTH_TYPE_KEY) - if not github_copilot_auth_mode(credential_name_obj, auth_type_obj): - return - if isinstance(kwargs.get(GITHUB_COPILOT_USER_SESSION_KWARG_KEY), GithubCopilotUserSession): - return - credential_name: Final = credential_name_obj if isinstance(credential_name_obj, str) else "" - user_id, github_token = _caller_connection(kwargs, credential_name) - session: Final = await aexchange_github_token( - user_id=user_id, - github_token=github_token, - credential_name=credential_name, - ) + _, github_token = _caller_connection(kwargs, credential_name) + session: Final = GithubCopilotUserSession(token=github_token) kwargs[GITHUB_COPILOT_USER_SESSION_KWARG_KEY] = session # rebind-ok: kwargs is the request mutation channel _strip_caller_authorization(kwargs) diff --git a/litellm/llms/github_copilot/responses/transformation.py b/litellm/llms/github_copilot/responses/transformation.py index 43c1a037256..dcd2362ac0a 100644 --- a/litellm/llms/github_copilot/responses/transformation.py +++ b/litellm/llms/github_copilot/responses/transformation.py @@ -31,8 +31,8 @@ from litellm.utils import cached_get_model_info_helper from ..authenticator import Authenticator from ..common_utils import ( - DEFAULT_GITHUB_COPILOT_API_BASE, GetAPIKeyError, + get_copilot_api_base, get_copilot_default_headers, pin_session_authorization, ) @@ -262,20 +262,7 @@ class GithubCopilotResponsesAPIConfig(OpenAIResponsesAPIConfig): """ Get the complete URL for GitHub Copilot Responses API endpoint. """ - user_session: Final = require_github_copilot_user_session( - cast("dict[str, object]", litellm_params) # cast-ok: litellm_params arrives as an untyped request dict - ) - effective_api_base = ( - (user_session.api_base if user_session is not None else None) - or self.authenticator.get_api_base(api_base) - or DEFAULT_GITHUB_COPILOT_API_BASE - ) - - # Remove trailing slashes - effective_api_base = effective_api_base.rstrip("/") - - # Return the responses endpoint - return f"{effective_api_base}/responses" + return f"{get_copilot_api_base(api_base).rstrip('/')}/responses" def _handle_reasoning_item(self, item: dict[str, object]) -> dict[str, object]: """ diff --git a/litellm/proxy/credential_endpoints/endpoints.py b/litellm/proxy/credential_endpoints/endpoints.py index 1584b12fd68..725a8f13728 100644 --- a/litellm/proxy/credential_endpoints/endpoints.py +++ b/litellm/proxy/credential_endpoints/endpoints.py @@ -72,7 +72,6 @@ from .user_provider_credentials import ( delete_user_provider_credentials_for_credential, invalidate_user_provider_credential_cache, list_user_provider_credentials, - list_user_provider_credentials_for_credential, set_user_provider_credential_cache, upsert_user_provider_credential, ) @@ -698,7 +697,7 @@ async def poll_user_connection( if not github_token: raise HTTPException(status_code=502, detail="GitHub device flow completed without a token") try: - await acheck_copilot_seat(user_id=user_id, github_token=github_token, credential_name=credential_name) + await acheck_copilot_seat(github_token=github_token, credential_name=credential_name) except litellm.CallerCredentialAuthenticationError: return UserConnectionPollResponse(status="no_copilot_seat") github_login: Final = await afetch_github_login(github_token) @@ -741,7 +740,6 @@ async def delete_user_connection( user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), # noqa: B008 # FastAPI resolves the dependency from the default ) -> UserConnectionDeleteResponse: """Disconnect the calling user's stored GitHub token for a per-user credential. Idempotent.""" - from litellm.llms.github_copilot.per_user_auth import evict_copilot_user_session from litellm.proxy.proxy_server import prisma_client, user_api_key_cache try: @@ -763,9 +761,7 @@ async def delete_user_connection( status_code=503, detail={"error": "could not revoke cached connection, retry"}, ) - prior: Final = await delete_user_provider_credential(prisma_client, user_id, credential_name) - if prior is not None: - evict_copilot_user_session(user_id, prior.access_token) + await delete_user_provider_credential(prisma_client, user_id, credential_name) return UserConnectionDeleteResponse(status="disconnected") except Exception as e: # noqa: BLE001 # endpoint boundary: every failure becomes the proxy error contract raise handle_exception_on_proxy(e) @@ -777,29 +773,19 @@ async def _purge_user_connections_for_credential(credential_name: str) -> None: Shared cleanup for DELETE (credential removed) and PATCH (renamed or no longer per-user).""" - from litellm.llms.github_copilot.per_user_auth import evict_copilot_user_session from litellm.proxy.proxy_server import prisma_client, user_api_key_cache if prisma_client is None: return try: - prior_rows: Final = await list_user_provider_credentials_for_credential(prisma_client, credential_name) user_ids: Final = await delete_user_provider_credentials_for_credential(prisma_client, credential_name) except Exception: # noqa: BLE001 # best-effort purge must not break credential deletion verbose_proxy_logger.exception( "_purge_user_connections_for_credential: failed to drop user connections for %s", credential_name ) return - access_tokens: Final[dict[str, str]] = {} # mutable-ok: accumulates one entry per deleted row - for row in prior_rows: - decoded = decode_user_provider_credential(row.credential_b64) - if decoded is not None: - access_tokens[row.user_id] = decoded.access_token for user_id in user_ids: await invalidate_user_provider_credential_cache(user_api_key_cache, user_id, credential_name) - token = access_tokens.get(user_id) - if token: - evict_copilot_user_session(user_id, token) @router.delete( diff --git a/litellm/proxy/credential_endpoints/user_provider_credentials.py b/litellm/proxy/credential_endpoints/user_provider_credentials.py index 4253483a228..bb2dc317bcf 100644 --- a/litellm/proxy/credential_endpoints/user_provider_credentials.py +++ b/litellm/proxy/credential_endpoints/user_provider_credentials.py @@ -149,13 +149,6 @@ async def list_user_provider_credentials( return await _table(prisma_client).find_many(where={"user_id": user_id}) -async def list_user_provider_credentials_for_credential( - prisma_client: "PrismaClient", - credential_name: str, -) -> Sequence["prisma_models.LiteLLM_UserProviderCredentials"]: - return await _table(prisma_client).find_many(where={"credential_name": credential_name}) - - class _StringKeyCache(Protocol): async def async_get_cache( self, diff --git a/litellm/utils.py b/litellm/utils.py index 44dc75f45d7..07004245814 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -2037,9 +2037,9 @@ def client(original_function): kwargs["litellm_logging_obj"] = logging_obj ## LOAD CREDENTIALS load_credentials_from_list(kwargs) - from litellm.llms.github_copilot.per_user_auth import aattach_github_copilot_user_session + from litellm.llms.github_copilot.per_user_auth import attach_github_copilot_user_session - await aattach_github_copilot_user_session(kwargs) # pyright: ignore[reportUnknownArgumentType] # kwargs is the untyped request dict + attach_github_copilot_user_session(kwargs) # pyright: ignore[reportUnknownArgumentType] # kwargs is the untyped request dict logging_obj.llm_caching_handler = _llm_caching_handler # [OPTIONAL] CHECK BUDGET if litellm.max_budget: diff --git a/tests/integration/_support/device_login.py b/tests/integration/_support/device_login.py index 9b990fd2167..57188b9ea50 100644 --- a/tests/integration/_support/device_login.py +++ b/tests/integration/_support/device_login.py @@ -73,12 +73,9 @@ COPILOT_USER_CODE: Final = "COPI-LEAK" COPILOT_ACCESS: Final = "device-login-github-access" COPILOT_REJECTED_ACCESS: Final = "device-login-github-access-rejected" COPILOT_FIRST_LOGIN_ACCESS: Final = "device-login-github-access-first-login" -COPILOT_STORED_KEY: Final = "tid=device-login;stored" -COPILOT_MINTED_KEY: Final = "tid=device-login;minted" -COPILOT_REJECTED_KEY: Final = "tid=device-login;rejected" CONTROL_KEY: Final = "device-login-control-key" ACCEPTED_BEARERS: Final = frozenset( - {CHATGPT_STORED, CHATGPT_REFRESHED, CHATGPT_FIRST_LOGIN, COPILOT_STORED_KEY, COPILOT_MINTED_KEY, CONTROL_KEY} + {CHATGPT_STORED, CHATGPT_REFRESHED, CHATGPT_FIRST_LOGIN, COPILOT_ACCESS, COPILOT_FIRST_LOGIN_ACCESS, CONTROL_KEY} ) REJECTED_DETAIL: Final = "the scripted provider rejected this bearer token" _DEVICE_AUTH_ID: Final = "device-auth-synthetic" @@ -131,20 +128,11 @@ def _oauth_token(request: Request, grant: threading.Event) -> Reply: return _json(400, {"error": "invalid_grant", "error_description": "refresh token was revoked"}) -def _copilot_api_key(request: Request, api_url: str) -> Reply: - if request.headers.get("authorization") not in (f"token {COPILOT_ACCESS}", f"token {COPILOT_FIRST_LOGIN_ACCESS}"): - return _json(401, {"message": "Bad credentials"}) - return _json( - 200, - {"token": COPILOT_MINTED_KEY, "expires_at": int(time.time()) + 1800, "endpoints": {"api": api_url}}, - ) - - def _switched_off() -> Reply: return _json(503, {"error": "device login is switched off in this scenario"}) -def _auth_hosts(api_url: str, grant: threading.Event) -> Callable[[Request], Reply]: +def _auth_hosts(grant: threading.Event) -> Callable[[Request], Reply]: def respond(request: Request) -> Reply: match (request.headers.get("host", ""), request.method, urlsplit(request.target).path): case ("auth.openai.com", "POST", "/oauth/token"): @@ -173,8 +161,6 @@ def _auth_hosts(api_url: str, grant: threading.Event) -> Callable[[Request], Rep ) case ("github.com", "POST", "/login/oauth/access_token") if grant.is_set(): return _json(200, {"access_token": COPILOT_FIRST_LOGIN_ACCESS, "token_type": "bearer"}) - case ("api.github.com", "GET", "/copilot_internal/v2/token"): - return _copilot_api_key(request, api_url) case _: return _switched_off() @@ -341,7 +327,7 @@ def device_login_peers(directory: Path) -> Generator[Peers]: hangups: Final[SimpleQueue[Hangup]] = SimpleQueue() with ( wire_server(_provider_api(rv.ResponsesVendor())) as api, - wire_server(_auth_hosts(api.url, switches.grant), tls=server_context(cert, key)) as auth, + wire_server(_auth_hosts(switches.grant), tls=server_context(cert, key)) as auth, _tunnel(auth, switches, authorities, hangups) as tunnel, ): yield Peers(api, auth, tunnel, cert, authorities, hangups, switches) @@ -367,12 +353,6 @@ def write_chatgpt(directory: Path, record: Mapping[str, JsonValue]) -> None: (directory / "auth.json").write_text(json.dumps(dict(record))) -def write_copilot_key(directory: Path, token: str, api_url: str, expires_in: float = 3600) -> None: - (directory / "api-key.json").write_text( - json.dumps({"token": token, "expires_at": time.time() + expires_in, "endpoints": {"api": api_url}}) - ) - - def write_copilot_access(directory: Path, access_token: str) -> None: (directory / "access-token").write_text(access_token) diff --git a/tests/integration/providers/test_device_code_login_guard_boot.py b/tests/integration/providers/test_device_code_login_guard_boot.py index ecec6402aee..e4650d5bbe8 100644 --- a/tests/integration/providers/test_device_code_login_guard_boot.py +++ b/tests/integration/providers/test_device_code_login_guard_boot.py @@ -71,9 +71,9 @@ class _TokenDirs: def environment(self) -> Mapping[str, str]: return MappingProxyType({"CHATGPT_TOKEN_DIR": str(self.chatgpt), "GITHUB_COPILOT_TOKEN_DIR": str(self.copilot)}) - def store_valid_tokens(self, api_url: str) -> None: + def store_valid_tokens(self) -> None: dl.write_chatgpt(self.chatgpt, dl.chatgpt_record(dl.CHATGPT_STORED)) - dl.write_copilot_key(self.copilot, dl.COPILOT_STORED_KEY, api_url) + dl.write_copilot_access(self.copilot, dl.COPILOT_ACCESS) def _token_dirs(directory: Path) -> _TokenDirs: @@ -257,7 +257,7 @@ def test_booting_without_tokens_drops_the_login_deployments_and_a_reload_after_m assert peers.auth_connections() == () assert dl.bearer(peers.api.drain()[-1]) == dl.CONTROL_KEY - dirs.store_valid_tokens(peers.api.url) + dirs.store_valid_tokens() _reload(owned) _every_worker_lists(owned, workers, login_models=True) for served_logins, name in enumerate(_LOGIN_NAMES, start=1): @@ -266,7 +266,7 @@ def test_booting_without_tokens_drops_the_login_deployments_and_a_reload_after_m _successes_logged(key, at_least=_CONTROL_CALLS_BEFORE_THE_RELOAD + served_logins) assert peers.auth_connections() == () forwarded: Final = peers.api.drain() - assert {dl.bearer(request) for request in forwarded} == {dl.CHATGPT_STORED, dl.COPILOT_STORED_KEY} + assert {dl.bearer(request) for request in forwarded} == {dl.CHATGPT_STORED, dl.COPILOT_ACCESS} dl.write_chatgpt(dirs.chatgpt, dl.chatgpt_record(dl.CHATGPT_EXPIRED, refresh_token=dl.GOOD_REFRESH)) peers.switches.hold_connect.set() @@ -302,7 +302,7 @@ def test_a_token_removed_after_boot_turns_every_request_into_the_refusal( ) -> None: dirs: Final = _token_dirs(tmp_path) with dl.device_login_peers(tmp_path) as peers: - dirs.store_valid_tokens(peers.api.url) + dirs.store_valid_tokens() with owned_proxy_process( gateway, tmp_path, diff --git a/tests/integration/providers/test_device_code_login_guard_wire.py b/tests/integration/providers/test_device_code_login_guard_wire.py index 072de4f3782..a1299d55bc8 100644 --- a/tests/integration/providers/test_device_code_login_guard_wire.py +++ b/tests/integration/providers/test_device_code_login_guard_wire.py @@ -50,7 +50,7 @@ _REFUSALS: Final[Mapping[Provider, str]] = MappingProxyType( {"chatgpt": dl.CHATGPT_REFUSAL, "copilot": dl.COPILOT_REFUSAL} ) _STORED_BEARERS: Final[Mapping[Provider, str]] = MappingProxyType( - {"chatgpt": dl.CHATGPT_STORED, "copilot": dl.COPILOT_STORED_KEY} + {"chatgpt": dl.CHATGPT_STORED, "copilot": dl.COPILOT_ACCESS} ) _CONTROL_MODEL: Final = "device-login-control" _EXTRA: Final[Mapping[str, JsonValue]] = MappingProxyType({"num_retries": 0, "cache": {"no-cache": True}}) @@ -152,7 +152,7 @@ class _Scene: if provider == "chatgpt": dl.write_chatgpt(self.rig.chatgpt_dir, dl.chatgpt_record(dl.CHATGPT_STORED)) return - dl.write_copilot_key(self.rig.copilot_dir, dl.COPILOT_STORED_KEY, self.rig.peers.api.url) + dl.write_copilot_access(self.rig.copilot_dir, dl.COPILOT_ACCESS) def _config(peers: dl.Peers, directory: Path) -> Path: @@ -485,7 +485,7 @@ def test_missing_token_is_refused_on_the_other_model_routes(scene: _Scene, route assert _describe(scene.rig.peers.api.drain()) == () -def test_copilot_embeddings_are_served_with_the_stored_api_key(scene: _Scene) -> None: +def test_copilot_embeddings_are_served_with_the_stored_access_token(scene: _Scene) -> None: scene.store_valid_token("copilot") served: Final = scene.post( "/v1/embeddings", {"model": "github_copilot/text-embedding-3-small", "input": "login", **_EXTRA} @@ -494,7 +494,7 @@ def test_copilot_embeddings_are_served_with_the_stored_api_key(scene: _Scene) -> (item,) = rv.ITEMS.validate_python(rv.JSON_OBJECT.validate_json(served.text)["data"]) assert item["embedding"] == [0.25, 0.5, 0.75], served.text (forwarded,) = scene.rig.peers.api.drain() - assert (forwarded.method, dl.bearer(forwarded)) == ("POST", dl.COPILOT_STORED_KEY), forwarded.target + assert (forwarded.method, dl.bearer(forwarded)) == ("POST", dl.COPILOT_ACCESS), forwarded.target assert forwarded.target.endswith("/embeddings"), forwarded.target assert scene.rig.peers.auth_connections() == () @@ -560,61 +560,18 @@ def test_stored_token_the_provider_rejects_surfaces_its_401_without_a_login( if provider == "chatgpt": dl.write_chatgpt(scene.rig.chatgpt_dir, dl.chatgpt_record(dl.CHATGPT_REJECTED)) else: - dl.write_copilot_key(scene.rig.copilot_dir, dl.COPILOT_REJECTED_KEY, scene.rig.peers.api.url) + dl.write_copilot_access(scene.rig.copilot_dir, dl.COPILOT_REJECTED_ACCESS) call: Final = scene.call(_Cell(provider, endpoint, False, "httpx")) served: Final = _serve(call) assert served.status == 401, served.text assert dl.REJECTED_DETAIL in served.text, served.text received: Final = scene.rig.peers.api.drain() assert {dl.bearer(request) for request in _carrying(received, call.marker)} == { - dl.CHATGPT_REJECTED if provider == "chatgpt" else dl.COPILOT_REJECTED_KEY + dl.CHATGPT_REJECTED if provider == "chatgpt" else dl.COPILOT_REJECTED_ACCESS }, _describe(received) assert scene.rig.peers.auth_connections() == () -@pytest.mark.parametrize("stale_key", (False, True), ids=("missing-api-key", "expired-api-key")) -def test_copilot_api_key_is_minted_once_from_the_stored_access_token(scene: _Scene, stale_key: bool) -> None: - dl.write_copilot_access(scene.rig.copilot_dir, dl.COPILOT_ACCESS) - if stale_key: - dl.write_copilot_key(scene.rig.copilot_dir, dl.COPILOT_REJECTED_KEY, scene.rig.peers.api.url, -3600) - call: Final = scene.call(_Cell("copilot", "chat", False, "httpx")) - served: Final = _serve(call) - assert served.status == 200, served.text - assert set(rv.MARKER.findall(served.text)) == {call.marker}, served.text - (minted,) = scene.rig.peers.auth.drain() - assert (minted.method, minted.target, minted.headers.get("host"), minted.headers.get("authorization")) == ( - "GET", - "/copilot_internal/v2/token", - dl.GITHUB_API_HOST, - f"token {dl.COPILOT_ACCESS}", - ) - (forwarded,) = _carrying(scene.rig.peers.api.drain(), call.marker) - assert dl.bearer(forwarded) == dl.COPILOT_MINTED_KEY - assert json.loads((scene.rig.copilot_dir / "api-key.json").read_text())["token"] == dl.COPILOT_MINTED_KEY - - -def test_copilot_access_token_github_rejects_fails_the_request_without_a_device_login(scene: _Scene) -> None: - dl.write_copilot_access(scene.rig.copilot_dir, dl.COPILOT_REJECTED_ACCESS) - served: Final = _serve(scene.call(_Cell("copilot", "chat", False, "httpx"))) - assert served.status == 400, served.text - assert "Failed to refresh API key" in _error_message(served.text), served.text - _failure_logged(served) - reached: Final = scene.rig.peers.auth.drain() - assert {(request.method, request.target) for request in reached} == {("GET", "/copilot_internal/v2/token")}, ( - _describe(reached) - ) - assert _describe(scene.rig.peers.api.drain()) == () - - -def test_expired_copilot_api_key_with_no_access_token_is_refused_before_any_auth_host_call(scene: _Scene) -> None: - dl.write_copilot_key(scene.rig.copilot_dir, dl.COPILOT_STORED_KEY, scene.rig.peers.api.url, -3600) - served: Final = _serve(scene.call(_Cell("copilot", "responses", False, "httpx"))) - assert served.status == 400, served.text - assert dl.COPILOT_REFUSAL in _error_message(served.text), served.text - assert scene.rig.peers.auth_connections() == () - assert _describe(scene.rig.peers.api.drain()) == () - - _UNUSABLE_CHATGPT_FILES: Final[Mapping[str, str]] = MappingProxyType( { "invalid-json": "{not json", @@ -653,41 +610,19 @@ def test_chatgpt_token_with_a_text_expiry_falls_back_to_the_expiry_inside_the_to assert scene.rig.peers.auth_connections() == () -_PROVIDER_RESOLUTION_ERROR: Final = "GetLLMProvider Exception" - - -@dataclass(frozen=True, slots=True) -class _UnusableCopilotFiles: - files: Mapping[str, str] - answer: str - - -_UNUSABLE_COPILOT_FILES: Final[Mapping[str, _UnusableCopilotFiles]] = MappingProxyType( - { - "empty-access-token": _UnusableCopilotFiles(MappingProxyType({"access-token": ""}), dl.COPILOT_REFUSAL), - "blank-access-token": _UnusableCopilotFiles(MappingProxyType({"access-token": " \n"}), dl.COPILOT_REFUSAL), - "invalid-api-key-json": _UnusableCopilotFiles( - MappingProxyType({"api-key.json": "{not json"}), dl.COPILOT_REFUSAL - ), - "api-key-list": _UnusableCopilotFiles(MappingProxyType({"api-key.json": "[]"}), _PROVIDER_RESOLUTION_ERROR), - "api-key-expiry-text": _UnusableCopilotFiles( - MappingProxyType({"api-key.json": json.dumps({"token": "copilot-text-expiry", "expires_at": "soon"})}), - _PROVIDER_RESOLUTION_ERROR, - ), - } +_UNUSABLE_COPILOT_ACCESS_TOKENS: Final[Mapping[str, str]] = MappingProxyType( + {"empty-access-token": "", "blank-access-token": " \n"} ) -@pytest.mark.parametrize("name", tuple(_UNUSABLE_COPILOT_FILES)) -def test_unusable_copilot_token_files_are_refused_like_missing_ones_each_time(scene: _Scene, name: str) -> None: - variant: Final = _UNUSABLE_COPILOT_FILES[name] - for file_name, content in variant.files.items(): - (scene.rig.copilot_dir / file_name).write_text(content) +@pytest.mark.parametrize("name", tuple(_UNUSABLE_COPILOT_ACCESS_TOKENS)) +def test_unusable_copilot_access_token_is_refused_like_a_missing_one_each_time(scene: _Scene, name: str) -> None: + dl.write_copilot_access(scene.rig.copilot_dir, _UNUSABLE_COPILOT_ACCESS_TOKENS[name]) first: Final = _serve(scene.call(_Cell("copilot", "chat", False, "httpx"))) second: Final = _serve(scene.call(_Cell("copilot", "chat", False, "httpx"))) for served in (first, second): assert served.status == 400, served.text - assert variant.answer in _error_message(served.text), served.text + assert dl.COPILOT_REFUSAL in _error_message(served.text), served.text assert dl.COPILOT_USER_CODE not in served.text assert scene.rig.peers.auth_connections() == () assert _describe(scene.rig.peers.api.drain()) == () diff --git a/tests/integration/providers/test_responses_minted_reasoning_replay_chaos.py b/tests/integration/providers/test_responses_minted_reasoning_replay_chaos.py index d62b67e08d6..345e0185612 100644 --- a/tests/integration/providers/test_responses_minted_reasoning_replay_chaos.py +++ b/tests/integration/providers/test_responses_minted_reasoning_replay_chaos.py @@ -386,11 +386,7 @@ def rig(tmp_path_factory: pytest.TempPathFactory) -> Iterator[_Rig]: copilot.mkdir() chatgpt.mkdir() with gateway_from_environment() as gateway, wire_server(rv.ResponsesVendor().respond) as wire: - (copilot / "api-key.json").write_text( - json.dumps( - {"token": "synthetic-copilot-token", "expires_at": time.time() + 3600, "endpoints": {"api": wire.url}} - ) - ) + (copilot / "access-token").write_text("synthetic-copilot-token") (chatgpt / "auth.json").write_text( json.dumps( { @@ -402,6 +398,7 @@ def rig(tmp_path_factory: pytest.TempPathFactory) -> Iterator[_Rig]: ) overrides: Final = { "GITHUB_COPILOT_TOKEN_DIR": str(copilot), + "GITHUB_COPILOT_API_BASE": wire.url, "CHATGPT_TOKEN_DIR": str(chatgpt), "CHATGPT_API_BASE": wire.url, "SSL_CERT_FILE": str(cert), diff --git a/tests/integration/sdk/test_device_code_login_guard_sdk.py b/tests/integration/sdk/test_device_code_login_guard_sdk.py index 51e955ea6ec..0d5781a548a 100644 --- a/tests/integration/sdk/test_device_code_login_guard_sdk.py +++ b/tests/integration/sdk/test_device_code_login_guard_sdk.py @@ -165,7 +165,7 @@ def clean(bench: _Bench) -> Iterator[_Bench]: _FIRST_LOGIN_BEARERS: Final[Mapping[Provider, str]] = MappingProxyType( - {"chatgpt": dl.CHATGPT_FIRST_LOGIN, "copilot": dl.COPILOT_MINTED_KEY} + {"chatgpt": dl.CHATGPT_FIRST_LOGIN, "copilot": dl.COPILOT_FIRST_LOGIN_ACCESS} ) @@ -186,7 +186,7 @@ def test_sync_call_on_the_main_thread_keeps_the_interactive_first_login( assert {dl.bearer(request) for request in forwarded} == {_FIRST_LOGIN_BEARERS[provider]}, [ (request.method, request.target) for request in forwarded ] - expected_files: Final = ("auth.json",) if provider == "chatgpt" else ("access-token", "api-key.json") + expected_files: Final = ("auth.json",) if provider == "chatgpt" else ("access-token",) assert clean.token_files() == expected_files diff --git a/tests/unit/litellm_core_utils/llm_response_utils/test_get_api_base.py b/tests/unit/litellm_core_utils/llm_response_utils/test_get_api_base.py index 9a09347c076..517a5ac43bc 100644 --- a/tests/unit/litellm_core_utils/llm_response_utils/test_get_api_base.py +++ b/tests/unit/litellm_core_utils/llm_response_utils/test_get_api_base.py @@ -1,5 +1,3 @@ -import json - import pytest import litellm @@ -53,17 +51,13 @@ class TestDeclaredAuthenticatingProvider: assert resolution_lookups == [] assert api_base == expected - def test_copilot_ignores_the_legacy_endpoint_on_disk(self, isolated_token_dirs, resolution_lookups): - token_dir = isolated_token_dirs / "github_copilot" - token_dir.mkdir() - (token_dir / "api-key.json").write_text( - json.dumps({"endpoints": {"api": "https://api.enterprise.githubcopilot.com"}}) - ) + def test_copilot_reports_configured_environment_base(self, isolated_token_dirs, resolution_lookups, monkeypatch): + monkeypatch.setenv("GITHUB_COPILOT_API_BASE", "http://configured.example.com/v1") api_base = litellm.get_api_base(model="github_copilot/gpt-4o", optional_params={}) assert resolution_lookups == [] - assert api_base == DEFAULT_GITHUB_COPILOT_API_BASE + assert api_base == "http://configured.example.com/v1" def test_explicit_api_base_still_wins(self, isolated_token_dirs, resolution_lookups): api_base = litellm.get_api_base( @@ -82,7 +76,10 @@ class TestDeclaredAuthenticatingProvider: @pytest.mark.parametrize( "model, expected", [ - ("gemini/gemini-2.5-pro", "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent"), + ( + "gemini/gemini-2.5-pro", + "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent", + ), ("openai/gpt-4o", "https://api.openai.com"), ], ) diff --git a/tests/unit/llms/github_copilot/embedding/test_github_copilot_embedding_transformation.py b/tests/unit/llms/github_copilot/embedding/test_github_copilot_embedding_transformation.py index 0888619baef..65a5ae612a9 100644 --- a/tests/unit/llms/github_copilot/embedding/test_github_copilot_embedding_transformation.py +++ b/tests/unit/llms/github_copilot/embedding/test_github_copilot_embedding_transformation.py @@ -1,15 +1,15 @@ -from unittest.mock import MagicMock, patch +from typing import Final +from unittest.mock import MagicMock import httpx import pytest from pydantic import ValidationError - from litellm.exceptions import AuthenticationError +from litellm.llms.github_copilot.common_utils import DEFAULT_GITHUB_COPILOT_API_BASE, GetAPIKeyError from litellm.llms.github_copilot.embedding.transformation import ( GithubCopilotEmbeddingConfig, ) -from litellm.llms.github_copilot.common_utils import GetAPIKeyError from litellm.types.utils import EmbeddingResponse @@ -59,43 +59,41 @@ def test_github_copilot_embedding_config_validate_environment(): assert "Failed to get API key" in str(excinfo.value) -def test_github_copilot_embedding_config_get_complete_url(): - """Test the GitHub Copilot embedding configuration URL generation.""" - config = GithubCopilotEmbeddingConfig() +@pytest.mark.parametrize( + ("deployment_base", "environment_base", "expected_base"), + [ + (None, None, DEFAULT_GITHUB_COPILOT_API_BASE), + (None, "https://copilot.example.test/", "https://copilot.example.test"), + (None, "http://localhost:8080/", "http://localhost:8080"), + ("https://deployment.example.test/", "https://env.example.test", "https://deployment.example.test"), + ("http://localhost:9090/", "https://env.example.test", "http://localhost:9090"), + ("not-a-url", "https://env.example.test", "https://env.example.test"), + (None, "not-a-url", DEFAULT_GITHUB_COPILOT_API_BASE), + ], +) +def test_github_copilot_embedding_config_get_complete_url( + monkeypatch: pytest.MonkeyPatch, + deployment_base: str | None, + environment_base: str | None, + expected_base: str, +) -> None: + if environment_base is None: + monkeypatch.delenv("GITHUB_COPILOT_API_BASE", raising=False) + else: + monkeypatch.setenv("GITHUB_COPILOT_API_BASE", environment_base) + config: Final = GithubCopilotEmbeddingConfig() config.authenticator = MagicMock() - # Test with default API base - config.authenticator.get_api_base.return_value = None - url = config.get_complete_url( - api_base=None, + url: Final = config.get_complete_url( + api_base=deployment_base, api_key=None, model="github_copilot/text-embedding-3-small", optional_params={}, litellm_params={}, ) - assert url == "https://api.githubcopilot.com/embeddings" - # Test with custom API base from authenticator - 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, - model="github_copilot/text-embedding-3-small", - optional_params={}, - litellm_params={}, - ) - assert url == "https://api.enterprise.githubcopilot.com/embeddings" - - url = config.get_complete_url( - 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.business.githubcopilot.com/embeddings" + assert url == f"{expected_base}/embeddings" + assert config.authenticator.mock_calls == [] def test_github_copilot_embedding_config_transform_request(): @@ -267,7 +265,7 @@ def test_validate_environment_uses_per_user_session_and_skips_authenticator(): config.authenticator = MagicMock() config.authenticator.get_api_key.side_effect = AssertionError("shared authenticator must not run") - session = GithubCopilotUserSession(token="user-copilot-token", api_base="https://api.githubcopilot.com") + session = GithubCopilotUserSession(token="user-copilot-token") headers = config.validate_environment( headers={}, model="github_copilot/text-embedding-3-small", @@ -279,33 +277,48 @@ def test_validate_environment_uses_per_user_session_and_skips_authenticator(): config.authenticator.get_api_key.assert_not_called() -def test_per_user_session_token_wins_over_caller_authorization(): +@pytest.mark.parametrize("authorization_header", ["Authorization", "authorization", "AUTHORIZATION"]) +def test_per_user_session_token_wins_over_caller_authorization(authorization_header: str): from litellm.llms.github_copilot.per_user_auth import GithubCopilotUserSession config = GithubCopilotEmbeddingConfig() config.authenticator = MagicMock() - session = GithubCopilotUserSession(token="user-copilot-token", api_base="https://api.githubcopilot.com") + session = GithubCopilotUserSession(token="user-copilot-token") headers = config.validate_environment( - headers={"Authorization": "Bearer caller-token"}, + headers={authorization_header: "Bearer caller-token", "x-custom": "preserved"}, model="github_copilot/text-embedding-3-small", messages=[], optional_params={}, litellm_params={"github_copilot_user_session": session}, ) assert headers["Authorization"] == "Bearer user-copilot-token" + assert {key: value for key, value in headers.items() if key.lower() == "authorization"} == { + "Authorization": "Bearer user-copilot-token" + } + assert headers["x-custom"] == "preserved" + assert config.authenticator.mock_calls == [] -def test_get_complete_url_prefers_per_user_session_api_base(): +@pytest.mark.parametrize( + ("deployment_base", "expected_base"), + [(None, "http://localhost:8080"), ("https://deployment.example.test/", "https://deployment.example.test")], +) +def test_get_complete_url_uses_configured_base_with_per_user_session( + monkeypatch: pytest.MonkeyPatch, deployment_base: str | None, expected_base: str +) -> None: from litellm.llms.github_copilot.per_user_auth import GithubCopilotUserSession - config = GithubCopilotEmbeddingConfig() + monkeypatch.setenv("GITHUB_COPILOT_API_BASE", "http://localhost:8080/") + config: Final = GithubCopilotEmbeddingConfig() config.authenticator = MagicMock() - session = GithubCopilotUserSession(token="t", api_base="https://tenant.githubcopilot.com") - url = config.get_complete_url( - api_base="https://attacker.example", + session: Final = GithubCopilotUserSession(token="user-oauth-token") + url: Final = config.get_complete_url( + api_base=deployment_base, api_key=None, - model="m", + model="github_copilot/text-embedding-3-small", optional_params={}, litellm_params={"github_copilot_user_session": session}, ) - assert url == "https://tenant.githubcopilot.com/embeddings" + + assert url == f"{expected_base}/embeddings" + assert config.authenticator.mock_calls == [] diff --git a/tests/unit/llms/github_copilot/messages/test_github_copilot_messages_transformation.py b/tests/unit/llms/github_copilot/messages/test_github_copilot_messages_transformation.py index 5cbb9a0a7b4..e2505c3e861 100644 --- a/tests/unit/llms/github_copilot/messages/test_github_copilot_messages_transformation.py +++ b/tests/unit/llms/github_copilot/messages/test_github_copilot_messages_transformation.py @@ -1,97 +1,60 @@ +from typing import Final from unittest.mock import MagicMock import httpx import pytest - from litellm.exceptions import AuthenticationError -from litellm.llms.github_copilot.common_utils import GetAPIKeyError +from litellm.llms.github_copilot.common_utils import DEFAULT_GITHUB_COPILOT_API_BASE, GetAPIKeyError from litellm.llms.github_copilot.messages.transformation import ( GithubCopilotAnthropicMessagesConfig, ) -def test_github_copilot_anthropic_messages_get_complete_url(): - """get_complete_url builds the /v1/messages URL from the base it is handed. - - In the request flow that ``api_base`` is the value already resolved by - validate_anthropic_messages_environment (the authenticated Copilot host); the - caller-supplied base is discarded there, not here (see the validate tests). - """ - config = GithubCopilotAnthropicMessagesConfig() +@pytest.mark.parametrize( + ("resolved_base", "environment_base", "expected_base"), + [ + (None, None, DEFAULT_GITHUB_COPILOT_API_BASE), + (None, "https://copilot.example.test/", "https://copilot.example.test"), + (None, "http://localhost:8080/", "http://localhost:8080"), + ("https://resolved.example.test", "https://env.example.test", "https://resolved.example.test"), + ("https://resolved.example.test/", "https://env.example.test", "https://resolved.example.test"), + ("https://resolved.example.test/v1/messages", None, "https://resolved.example.test"), + ], +) +def test_github_copilot_anthropic_messages_get_complete_url( + monkeypatch: pytest.MonkeyPatch, + resolved_base: str | None, + environment_base: str | None, + expected_base: str, +) -> None: + if environment_base is None: + monkeypatch.delenv("GITHUB_COPILOT_API_BASE", raising=False) + else: + monkeypatch.setenv("GITHUB_COPILOT_API_BASE", environment_base) + config: Final = GithubCopilotAnthropicMessagesConfig() config.authenticator = MagicMock() - config.authenticator.get_api_base.return_value = None - # No api_base supplied and no authenticator base -> default Copilot endpoint. - url = config.get_complete_url( - api_base=None, + url: Final = config.get_complete_url( + api_base=resolved_base, api_key=None, model="github_copilot/claude-haiku-4.5", optional_params={}, litellm_params={}, ) - assert url == "https://api.githubcopilot.com/v1/messages" - # Falls back to a single authenticator read, not a hard-coded second one. - config.authenticator.get_api_base.assert_called() - # The resolved (validated) base passed in is reused verbatim; no extra read. - config.authenticator.get_api_base.reset_mock() - url = config.get_complete_url( - api_base="https://api.business.githubcopilot.com", - api_key=None, - model="github_copilot/claude-haiku-4.5", - optional_params={}, - litellm_params={}, - ) - assert url == "https://api.business.githubcopilot.com/v1/messages" - config.authenticator.get_api_base.assert_not_called() - - # A trailing slash on the base must not produce a double-slash URL. - url = config.get_complete_url( - api_base="https://api.business.githubcopilot.com/", - api_key=None, - model="github_copilot/claude-haiku-4.5", - optional_params={}, - litellm_params={}, - ) - assert url == "https://api.business.githubcopilot.com/v1/messages" - - # An already-complete /v1/messages base is left untouched. - url = config.get_complete_url( - api_base="https://api.githubcopilot.com/v1/messages", - api_key=None, - model="github_copilot/claude-haiku-4.5", - optional_params={}, - litellm_params={}, - ) - assert url == "https://api.githubcopilot.com/v1/messages" + assert url == f"{expected_base}/v1/messages" + assert config.authenticator.mock_calls == [] -def test_github_copilot_anthropic_messages_get_complete_url_normalizes_authenticator_trailing_slash(): - """A tenant base with a trailing slash from the authenticator fallback must - not yield a double-slash URL.""" - config = GithubCopilotAnthropicMessagesConfig() - config.authenticator = MagicMock() - config.authenticator.get_api_base.return_value = "https://api.business.githubcopilot.com/" - - url = config.get_complete_url( - api_base=None, - api_key=None, - model="github_copilot/claude-haiku-4.5", - optional_params={}, - litellm_params={}, - ) - assert url == "https://api.business.githubcopilot.com/v1/messages" - - -def test_github_copilot_anthropic_messages_validate_environment(): +def test_github_copilot_anthropic_messages_validate_environment(monkeypatch: pytest.MonkeyPatch): """Test environment validation and header injection.""" + monkeypatch.delenv("GITHUB_COPILOT_API_BASE", raising=False) config = GithubCopilotAnthropicMessagesConfig() # Mock the authenticator config.authenticator = MagicMock() config.authenticator.get_api_key.return_value = "gh.test-key-123" - config.authenticator.get_api_base.return_value = None headers = {} # Pass a hostile api_base to confirm it is ignored. @@ -107,7 +70,7 @@ def test_github_copilot_anthropic_messages_validate_environment(): assert "copilot-integration-id" in validated_headers assert validated_headers["copilot-integration-id"] == "vscode-chat" - assert "Authorization" in validated_headers + assert validated_headers["Authorization"] == "Bearer gh.test-key-123" assert "anthropic-version" in validated_headers assert validated_headers["anthropic-version"] == "2023-06-01" # /v1/messages must use the messages-proxy intent so the Copilot backend @@ -115,7 +78,7 @@ def test_github_copilot_anthropic_messages_validate_environment(): assert validated_headers["openai-intent"] == "messages-proxy" assert validated_headers["x-interaction-type"] == "messages-proxy" assert validated_headers["x-github-api-version"] == "2026-06-01" - assert api_base == "https://api.githubcopilot.com" + assert api_base == DEFAULT_GITHUB_COPILOT_API_BASE def test_github_copilot_anthropic_messages_validate_environment_injects_beta_headers(): @@ -125,7 +88,6 @@ def test_github_copilot_anthropic_messages_validate_environment_injects_beta_hea config = GithubCopilotAnthropicMessagesConfig() config.authenticator = MagicMock() config.authenticator.get_api_key.return_value = "gh.test-key" - config.authenticator.get_api_base.return_value = None validated_headers, _ = config.validate_anthropic_messages_environment( headers={}, @@ -146,7 +108,6 @@ def test_github_copilot_anthropic_messages_validate_environment_preserves_caller config = GithubCopilotAnthropicMessagesConfig() config.authenticator = MagicMock() config.authenticator.get_api_key.return_value = "gh.test-key" - config.authenticator.get_api_base.return_value = None validated_headers, _ = config.validate_anthropic_messages_environment( headers={"anthropic-version": "2024-10-22"}, @@ -167,7 +128,6 @@ def test_github_copilot_anthropic_messages_validate_environment_injects_context_ config = GithubCopilotAnthropicMessagesConfig() config.authenticator = MagicMock() config.authenticator.get_api_key.return_value = "gh.test-key" - config.authenticator.get_api_base.return_value = None validated_headers, _ = config.validate_anthropic_messages_environment( headers={}, @@ -245,15 +205,27 @@ def test_provider_config_manager_skips_non_claude_copilot_models(): assert config is None -def test_github_copilot_anthropic_messages_validate_environment_normalizes_trailing_slash(): - """A tenant base with a trailing slash from the authenticator must be - normalized so the URL built downstream has no double slash.""" - config = GithubCopilotAnthropicMessagesConfig() +@pytest.mark.parametrize( + ("environment_base", "expected_base"), + [ + (None, DEFAULT_GITHUB_COPILOT_API_BASE), + ("https://copilot.example.test/", "https://copilot.example.test"), + ("http://localhost:8080/", "http://localhost:8080"), + ("https://user:password@copilot.example.test", DEFAULT_GITHUB_COPILOT_API_BASE), + ], +) +def test_validate_environment_ignores_deployment_base_and_resolves_configured_endpoint( + monkeypatch: pytest.MonkeyPatch, environment_base: str | None, expected_base: str +) -> None: + if environment_base is None: + monkeypatch.delenv("GITHUB_COPILOT_API_BASE", raising=False) + else: + monkeypatch.setenv("GITHUB_COPILOT_API_BASE", environment_base) + config: Final = GithubCopilotAnthropicMessagesConfig() config.authenticator = MagicMock() config.authenticator.get_api_key.return_value = "gh.test-key" - config.authenticator.get_api_base.return_value = "https://api.business.githubcopilot.com/" - _, api_base = config.validate_anthropic_messages_environment( + headers, api_base = config.validate_anthropic_messages_environment( headers={}, model="github_copilot/claude-haiku-4.5", messages=[{"role": "user", "content": "Hello"}], @@ -263,7 +235,18 @@ def test_github_copilot_anthropic_messages_validate_environment_normalizes_trail api_base="https://attacker.example.com", ) - assert api_base == "https://api.business.githubcopilot.com" + assert api_base == expected_base + assert headers["Authorization"] == "Bearer gh.test-key" + assert ( + config.get_complete_url( + api_base=api_base, + api_key=None, + model="github_copilot/claude-haiku-4.5", + optional_params={}, + litellm_params={}, + ) + == f"{expected_base}/v1/messages" + ) def test_github_copilot_config_disables_anthropic_beta_filtering(): @@ -281,7 +264,6 @@ def test_github_copilot_config_disables_anthropic_beta_filtering(): config.authenticator = MagicMock() config.authenticator.get_api_key.return_value = "gh.test-key" - config.authenticator.get_api_base.return_value = None headers, _ = config.validate_anthropic_messages_environment( headers={}, @@ -325,42 +307,68 @@ def test_github_copilot_messages_config_probes_capabilities_under_copilot_namesp assert GithubCopilotAnthropicMessagesConfig().custom_llm_provider == "github_copilot" -def test_validate_environment_uses_per_user_session_and_skips_authenticator(): +@pytest.mark.parametrize( + ("environment_base", "expected_base"), + [(None, DEFAULT_GITHUB_COPILOT_API_BASE), ("http://localhost:8080/", "http://localhost:8080")], +) +def test_validate_environment_uses_per_user_session_and_skips_authenticator( + monkeypatch: pytest.MonkeyPatch, environment_base: str | None, expected_base: str +) -> None: from litellm.llms.github_copilot.per_user_auth import GithubCopilotUserSession + if environment_base is None: + monkeypatch.delenv("GITHUB_COPILOT_API_BASE", raising=False) + else: + monkeypatch.setenv("GITHUB_COPILOT_API_BASE", environment_base) + config = GithubCopilotAnthropicMessagesConfig() config.authenticator = MagicMock() config.authenticator.get_api_key.side_effect = AssertionError("shared authenticator must not run") - config.authenticator.get_api_base.side_effect = AssertionError("shared authenticator must not run") - session = GithubCopilotUserSession(token="user-copilot-token", api_base="https://tenant.githubcopilot.com/") + session = GithubCopilotUserSession(token="user-copilot-token") headers, api_base = config.validate_anthropic_messages_environment( headers={}, model="github_copilot/claude-sonnet-5.5", messages=[], optional_params={}, litellm_params={"github_copilot_user_session": session}, + api_base="https://attacker.example.test", ) assert headers["Authorization"] == "Bearer user-copilot-token" - assert api_base == "https://tenant.githubcopilot.com" - config.authenticator.get_api_key.assert_not_called() + assert api_base == expected_base + assert config.authenticator.mock_calls == [] + assert ( + config.get_complete_url( + api_base=api_base, + api_key=None, + model="github_copilot/claude-sonnet-5.5", + optional_params={}, + litellm_params={"github_copilot_user_session": session}, + ) + == f"{expected_base}/v1/messages" + ) -def test_per_user_session_token_wins_over_caller_authorization(): +@pytest.mark.parametrize("authorization_header", ["Authorization", "authorization", "AUTHORIZATION"]) +def test_per_user_session_token_wins_over_caller_authorization(authorization_header: str): from litellm.llms.github_copilot.per_user_auth import GithubCopilotUserSession config = GithubCopilotAnthropicMessagesConfig() config.authenticator = MagicMock() - config.authenticator.get_api_base.return_value = "https://api.githubcopilot.com" - session = GithubCopilotUserSession(token="user-copilot-token", api_base="https://api.githubcopilot.com") + session = GithubCopilotUserSession(token="user-copilot-token") headers, _ = config.validate_anthropic_messages_environment( - headers={"authorization": "Bearer caller-token"}, + headers={authorization_header: "Bearer caller-token", "x-custom": "preserved"}, model="github_copilot/claude-sonnet-4.5", messages=[], optional_params={}, litellm_params={"github_copilot_user_session": session}, ) assert headers["Authorization"] == "Bearer user-copilot-token" + assert {key: value for key, value in headers.items() if key.lower() == "authorization"} == { + "Authorization": "Bearer user-copilot-token" + } + assert headers["x-custom"] == "preserved" + assert config.authenticator.mock_calls == [] def test_transform_response_carries_upstream_usage(): diff --git a/tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py b/tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py index 5d0a6d20dad..0929b3f3451 100644 --- a/tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py +++ b/tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py @@ -12,14 +12,17 @@ from unittest.mock import MagicMock, patch import httpx import pytest + import litellm from litellm.litellm_core_utils.get_model_cost_map import get_model_cost_map -from litellm.types.utils import LlmProviders -from litellm.utils import ProviderConfigManager +from litellm.llms.github_copilot.common_utils import DEFAULT_GITHUB_COPILOT_API_BASE +from litellm.llms.github_copilot.per_user_auth import GithubCopilotUserSession from litellm.llms.github_copilot.responses.transformation import ( GithubCopilotResponsesAPIConfig, ) from litellm.types.llms.openai import ResponsesAPIOptionalRequestParams +from litellm.types.utils import LlmProviders +from litellm.utils import ProviderConfigManager @pytest.fixture(autouse=True) @@ -49,29 +52,40 @@ class TestGithubCopilotResponsesAPITransformation: ) 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.side_effect = lambda api_base=None: ( - api_base or "https://api.individual.githubcopilot.com" - ) - mock_authenticator_class.return_value = mock_auth_instance - + @pytest.mark.parametrize( + ("api_base", "environment_base", "expected_base"), + ( + (None, "https://copilot-env.example", "https://copilot-env.example"), + ("http://localhost:8080", "https://copilot-env.example", "http://localhost:8080"), + ("https://copilot-deployment.example/", None, "https://copilot-deployment.example"), + (None, None, DEFAULT_GITHUB_COPILOT_API_BASE), + ), + ) + @pytest.mark.parametrize("per_user", (False, True)) + def test_github_copilot_responses_endpoint_url( + self, + api_base: str | None, + environment_base: str | None, + expected_base: str, + per_user: bool, + monkeypatch: pytest.MonkeyPatch, + ): + if environment_base is None: + monkeypatch.delenv("GITHUB_COPILOT_API_BASE", raising=False) + else: + monkeypatch.setenv("GITHUB_COPILOT_API_BASE", environment_base) 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}" + config.authenticator = MagicMock() + url = config.get_complete_url( + api_base=api_base, + litellm_params=( + {"github_copilot_user_session": GithubCopilotUserSession(token="user-github-oauth-token")} + if per_user + else {} + ), ) - - 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://api.githubcopilot.com/", litellm_params={}) - assert url_with_slash == "https://api.githubcopilot.com/responses" + assert url == f"{expected_base}/responses" + config.authenticator.get_api_key.assert_not_called() @patch("litellm.llms.github_copilot.responses.transformation.Authenticator") def test_validate_environment_default_headers(self, mock_authenticator_class, monkeypatch): @@ -812,37 +826,34 @@ class TestGithubCopilotReasoningStreamItemIdNormalization: def test_validate_environment_uses_per_user_session_and_skips_authenticator(): - from litellm.llms.github_copilot.per_user_auth import GithubCopilotUserSession - config = GithubCopilotResponsesAPIConfig() config.authenticator = MagicMock() config.authenticator.get_api_key.side_effect = AssertionError("shared authenticator must not run") - session = GithubCopilotUserSession(token="user-copilot-token", api_base="https://api.githubcopilot.com") + session = GithubCopilotUserSession(token="user-github-oauth-token") headers = config.validate_environment( headers={}, model="github_copilot/gpt-5.4", litellm_params={"github_copilot_user_session": session}, ) - assert headers["Authorization"] == "Bearer user-copilot-token" + assert headers["Authorization"] == f"Bearer {session.token}" config.authenticator.get_api_key.assert_not_called() def test_per_user_session_token_wins_over_caller_authorization(): """extra_headers["Authorization"] (any casing) must never displace the per-user session token on the outgoing request.""" - from litellm.llms.github_copilot.per_user_auth import GithubCopilotUserSession - config = GithubCopilotResponsesAPIConfig() config.authenticator = MagicMock() - session = GithubCopilotUserSession(token="user-copilot-token", api_base="https://api.githubcopilot.com") + session = GithubCopilotUserSession(token="user-github-oauth-token") headers = config.validate_environment( headers={"Authorization": "Bearer caller-token", "authorization": "Bearer caller-token-lower"}, model="github_copilot/gpt-5.1", litellm_params={"github_copilot_user_session": session}, ) - assert headers["Authorization"] == "Bearer user-copilot-token" + assert headers["Authorization"] == f"Bearer {session.token}" assert "authorization" not in headers + config.authenticator.get_api_key.assert_not_called() def test_shared_mode_keeps_caller_authorization(): diff --git a/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py b/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py index 19aa0c3bc91..5bc96048ccb 100644 --- a/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py +++ b/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py @@ -1,17 +1,21 @@ import logging import os -import time from concurrent.futures import ThreadPoolExecutor -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 ( + DEFAULT_COPILOT_EDITOR_PLUGIN_VERSION, + DEFAULT_COPILOT_EDITOR_VERSION, + DEFAULT_COPILOT_INTEGRATION_ID, + DEFAULT_COPILOT_USER_AGENT, + DEFAULT_GITHUB_COPILOT_API_BASE, GetAccessTokenError, GetAPIKeyError, GetDeviceCodeError, + get_copilot_api_base, get_copilot_default_headers, ) @@ -56,48 +60,63 @@ 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): + @pytest.mark.parametrize("scheme", ["http", "https"]) + def test_get_api_base_prefers_environment(self, scheme): with patch.dict( os.environ, - {"GITHUB_COPILOT_API_BASE": "https://configured.example.com"}, + {"GITHUB_COPILOT_API_BASE": f"{scheme}://configured.example.com"}, clear=True, ): - assert authenticator.get_api_base() == "https://configured.example.com" + assert get_copilot_api_base() == f"{scheme}://configured.example.com" @pytest.mark.parametrize( "api_base", ( - "http://api.githubcopilot.com", + "ftp://api.githubcopilot.com", "https://user:password@api.githubcopilot.com", "https://api.githubcopilot.com?tenant=example", "https://api.githubcopilot.com#fragment", + "api.githubcopilot.com", + "https:///missing-host", ), ) - def test_get_api_base_rejects_insecure_configuration(self, authenticator, api_base, caplog): + def test_get_api_base_rejects_invalid_configuration(self, api_base, caplog): with ( patch.dict(os.environ, {"GITHUB_COPILOT_API_BASE": api_base}, clear=True), caplog.at_level(logging.WARNING, logger="LiteLLM"), ): - assert authenticator.get_api_base() is None + assert get_copilot_api_base() == DEFAULT_GITHUB_COPILOT_API_BASE assert ( - "Ignoring GITHUB_COPILOT_API_BASE because it must be an HTTPS URL without credentials, query, or fragment" + "Ignoring GITHUB_COPILOT_API_BASE because it must be an HTTP(S) URL without credentials, query, or fragment" in caplog.text ) - def test_get_api_base_uses_default_when_unconfigured(self, authenticator): + def test_get_api_base_uses_default_when_unconfigured(self): with patch.dict(os.environ, {}, clear=True): - assert authenticator.get_api_base() is None + assert get_copilot_api_base() == DEFAULT_GITHUB_COPILOT_API_BASE - def test_get_api_base_prefers_trusted_deployment_endpoint(self, authenticator): + @pytest.mark.parametrize("scheme", ["http", "https"]) + def test_get_api_base_prefers_deployment_endpoint(self, scheme): with patch.dict( os.environ, {"GITHUB_COPILOT_API_BASE": "https://configured.example.com"}, clear=True, ): - assert authenticator.get_api_base("https://deployment.example.com") == "https://deployment.example.com" + assert get_copilot_api_base(f"{scheme}://deployment.example.com") == f"{scheme}://deployment.example.com" - def test_get_api_base_falls_back_from_untrusted_deployment_endpoint(self, authenticator, caplog): + @pytest.mark.parametrize( + "api_base", + [ + "ftp://deployment.example.com", + "https://user:pw@deployment.example.com", + "https://deployment.example.com?q=1", + "https://deployment.example.com#fragment", + "https:///missing-host", + "deployment.example.com", + ], + ) + def test_get_api_base_falls_back_from_invalid_deployment_endpoint(self, api_base, caplog): with ( patch.dict( os.environ, @@ -106,22 +125,31 @@ class TestGitHubCopilotAuthenticator: ), caplog.at_level(logging.WARNING, logger="LiteLLM"), ): - assert authenticator.get_api_base("http://attacker.example.com") == "https://configured.example.com" + assert get_copilot_api_base(api_base) == "https://configured.example.com" assert ( - "Ignoring deployment api_base because it must be an HTTPS URL without credentials, query, or fragment" + "Ignoring deployment api_base because it must be an HTTP(S) URL without credentials, query, or fragment" in caplog.text ) + @pytest.mark.parametrize("deployment_base", [None, ""]) + @pytest.mark.parametrize("environment_base", [None, ""]) + def test_get_api_base_skips_empty_candidates_without_warning(self, deployment_base, environment_base, caplog): + environment = {} if environment_base is None else {"GITHUB_COPILOT_API_BASE": environment_base} + with patch.dict(os.environ, environment, clear=True), caplog.at_level(logging.WARNING, logger="LiteLLM"): + assert get_copilot_api_base(deployment_base) == DEFAULT_GITHUB_COPILOT_API_BASE + assert caplog.text == "" + def test_get_github_headers(self, authenticator): - headers = authenticator._get_github_headers() + with patch.dict(os.environ, {}, clear=True): + headers = authenticator._get_github_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.26.7", - "user-agent": "GitHubCopilotChat/0.26.7", + "copilot-integration-id": DEFAULT_COPILOT_INTEGRATION_ID, + "editor-version": DEFAULT_COPILOT_EDITOR_VERSION, + "editor-plugin-version": DEFAULT_COPILOT_EDITOR_PLUGIN_VERSION, + "user-agent": DEFAULT_COPILOT_USER_AGENT, } def test_auth_requests_support_opencode_identity(self, authenticator, mock_http_client): @@ -156,7 +184,6 @@ class TestGitHubCopilotAuthenticator: authenticator._get_device_code() 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_auth_headers = { @@ -183,7 +210,7 @@ class TestGitHubCopilotAuthenticator: **expected_auth_headers, "openai-intent": "conversation-edits", "x-github-api-version": "2026-06-01", - "Authorization": "Bearer opencode-oauth-token", + "Authorization": "Bearer " + "opencode-oauth-token", } mock_client.get.assert_not_called() @@ -388,4 +415,3 @@ class TestGitHubCopilotAuthenticator: ): authenticator._poll_for_access_token("dc") assert mock_client.post.call_args[1]["json"]["client_id"] == custom_id - diff --git a/tests/unit/llms/github_copilot/test_github_copilot_transformation.py b/tests/unit/llms/github_copilot/test_github_copilot_transformation.py index e3278f5396b..2d1b0281a3d 100644 --- a/tests/unit/llms/github_copilot/test_github_copilot_transformation.py +++ b/tests/unit/llms/github_copilot/test_github_copilot_transformation.py @@ -1,31 +1,22 @@ -import asyncio import json -from datetime import datetime, timedelta -from typing import AsyncGenerator -from unittest.mock import AsyncMock, MagicMock, mock_open, patch - -import pytest - +from unittest.mock import MagicMock, patch import httpx -from respx import MockRouter +import pytest import litellm # Import at the top to make the patch work correctly -import litellm.llms.github_copilot.chat.transformation -from litellm import Choices, Message, ModelResponse, Usage, acompletion, completion +from litellm import ModelResponse, completion 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 ( - GetAccessTokenError, + DEFAULT_GITHUB_COPILOT_API_BASE, GetAPIKeyError, - GetDeviceCodeError, ) -def test_github_copilot_config_get_openai_compatible_provider_info(): +def test_github_copilot_config_get_openai_compatible_provider_info(monkeypatch: pytest.MonkeyPatch): """Test the GitHub Copilot configuration provider info retrieval.""" config = GithubCopilotConfig() @@ -34,10 +25,7 @@ def test_github_copilot_config_get_openai_compatible_provider_info(): mock_api_key = "gh.test-key-123456789" config.authenticator = MagicMock() config.authenticator.get_api_key.return_value = mock_api_key - # Test with dynamic endpoint - config.authenticator.get_api_base.side_effect = lambda api_base=None: ( - api_base or "https://api.enterprise.githubcopilot.com" - ) + monkeypatch.setenv("GITHUB_COPILOT_API_BASE", "https://api.enterprise.githubcopilot.com") # Test with default values model = "github_copilot/gpt-4" @@ -63,8 +51,7 @@ def test_github_copilot_config_get_openai_compatible_provider_info(): ) assert api_base == "https://api.business.githubcopilot.com" - # Test fallback to default if no dynamic endpoint - config.authenticator.get_api_base.side_effect = lambda api_base=None: None + monkeypatch.delenv("GITHUB_COPILOT_API_BASE") ( api_base, dynamic_api_key, @@ -75,7 +62,7 @@ def test_github_copilot_config_get_openai_compatible_provider_info(): api_key=None, custom_llm_provider="github_copilot", ) - assert api_base == "https://api.githubcopilot.com" + assert api_base == DEFAULT_GITHUB_COPILOT_API_BASE # Test with authentication failure config.authenticator.get_api_key.side_effect = GetAPIKeyError( @@ -150,7 +137,6 @@ def test_completion_github_copilot_mock_response( def test_transform_messages_disable_copilot_system_to_assistant(monkeypatch): """Test that system messages are converted to assistant unless disable_copilot_system_to_assistant is True.""" - import litellm from litellm.llms.github_copilot.chat.transformation import GithubCopilotConfig # Save original value @@ -190,7 +176,6 @@ def test_x_initiator_header_user_request(): # Mock the authenticator config.authenticator = MagicMock() config.authenticator.get_api_key.return_value = "gh.test-key-123" - config.authenticator.get_api_base.return_value = None messages = [ {"role": "system", "content": "You are an assistant."}, @@ -217,7 +202,6 @@ def test_x_initiator_header_agent_request_with_assistant(): # Mock the authenticator config.authenticator = MagicMock() config.authenticator.get_api_key.return_value = "gh.test-key-123" - config.authenticator.get_api_base.return_value = None messages = [ {"role": "system", "content": "You are an assistant."}, @@ -244,7 +228,6 @@ def test_x_initiator_header_agent_request_with_tool(): # Mock the authenticator config.authenticator = MagicMock() config.authenticator.get_api_key.return_value = "gh.test-key-123" - config.authenticator.get_api_base.return_value = None messages = [ {"role": "system", "content": "You are an assistant."}, @@ -271,7 +254,6 @@ def test_x_initiator_header_mixed_messages_with_agent_roles(): # Mock the authenticator config.authenticator = MagicMock() config.authenticator.get_api_key.return_value = "gh.test-key-123" - config.authenticator.get_api_base.return_value = None messages = [ {"role": "user", "content": "Hello"}, @@ -299,7 +281,6 @@ def test_x_initiator_header_user_only_messages(): # Mock the authenticator config.authenticator = MagicMock() config.authenticator.get_api_key.return_value = "gh.test-key-123" - config.authenticator.get_api_base.return_value = None messages = [ {"role": "system", "content": "You are an assistant."}, @@ -327,7 +308,6 @@ def test_x_initiator_header_empty_messages(): # Mock the authenticator config.authenticator = MagicMock() config.authenticator.get_api_key.return_value = "gh.test-key-123" - config.authenticator.get_api_base.return_value = None messages = [] @@ -351,7 +331,6 @@ def test_x_initiator_header_system_only_messages(): # Mock the authenticator config.authenticator = MagicMock() config.authenticator.get_api_key.return_value = "gh.test-key-123" - config.authenticator.get_api_base.return_value = None messages = [ {"role": "system", "content": "You are an assistant."}, @@ -377,7 +356,6 @@ def test_copilot_vision_request_header_with_image(): # Mock the authenticator config.authenticator = MagicMock() config.authenticator.get_api_key.return_value = "gh.test-key-123" - config.authenticator.get_api_base.return_value = None messages = [ { @@ -413,7 +391,6 @@ def test_copilot_vision_request_header_text_only(): # Mock the authenticator config.authenticator = MagicMock() config.authenticator.get_api_key.return_value = "gh.test-key-123" - config.authenticator.get_api_base.return_value = None messages = [ {"role": "user", "content": "Just a text message"}, @@ -440,7 +417,6 @@ def test_copilot_vision_request_header_with_type_image_url(): # Mock the authenticator config.authenticator = MagicMock() config.authenticator.get_api_key.return_value = "gh.test-key-123" - config.authenticator.get_api_base.return_value = None messages = [ { @@ -919,12 +895,16 @@ def test_validate_environment_uses_per_user_session_and_skips_authenticator(): config.authenticator = MagicMock() config.authenticator.get_api_key.side_effect = AssertionError("shared authenticator must not run") - session = GithubCopilotUserSession(token="user-copilot-token", api_base="https://api.githubcopilot.com") + session = GithubCopilotUserSession(token="user-github-oauth-token") headers = config.validate_environment( - headers={}, model="github_copilot/gpt-4o", messages=[], optional_params={}, + headers={"Authorization": "Bearer caller-token"}, + model="github_copilot/gpt-4o", + messages=[], + optional_params={}, litellm_params={"github_copilot_user_session": session}, + api_key="caller-api-key", ) - assert headers["Authorization"] == "Bearer user-copilot-token" + assert headers["Authorization"] == f"Bearer {session.token}" config.authenticator.get_api_key.assert_not_called() @@ -938,9 +918,7 @@ def test_transform_response_carries_upstream_usage(): "object": "chat.completion", "created": 1, "model": "github_copilot/gpt-4o", - "choices": [ - {"index": 0, "message": {"role": "assistant", "content": "hi"}, "finish_reason": "stop"} - ], + "choices": [{"index": 0, "message": {"role": "assistant", "content": "hi"}, "finish_reason": "stop"}], "usage": {"prompt_tokens": 11, "completion_tokens": 7, "total_tokens": 18}, }, ) diff --git a/tests/unit/llms/github_copilot/test_per_user_auth.py b/tests/unit/llms/github_copilot/test_per_user_auth.py index 9b16d3c0aa7..19d6399e354 100644 --- a/tests/unit/llms/github_copilot/test_per_user_auth.py +++ b/tests/unit/llms/github_copilot/test_per_user_auth.py @@ -1,8 +1,6 @@ """Unit tests for per-user GitHub Copilot OAuth connections (per_user_auth).""" -import asyncio import datetime -import time from collections.abc import Callable from contextlib import contextmanager from typing import Final @@ -13,6 +11,7 @@ import pytest import litellm from litellm.exceptions import ( + APIConnectionError, AuthenticationError, BadRequestError, CallerCredentialAuthenticationError, @@ -21,24 +20,18 @@ from litellm.exceptions import ( ) from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler from litellm.llms.github_copilot.per_user_auth import ( - _SESSION_CACHE, GITHUB_COPILOT_USER_SESSION_KWARG_KEY, GithubCopilotUserSession, - _session_cache_key, - aattach_github_copilot_user_session, - aexchange_github_token, + acheck_copilot_seat, + afetch_github_login, apoll_device_flow, astart_device_flow, attach_github_copilot_user_session, - evict_copilot_user_session, - exchange_github_token, github_copilot_auth_mode, github_copilot_user_session_from, - validated_copilot_api_base, ) from litellm.types.utils import CredentialItem, ModelResponse -EXCHANGE_URL = "https://api.github.com/copilot_internal/v2/token" DEVICE_CODE_URL = "https://github.com/login/device/code" ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token" @@ -48,14 +41,6 @@ def _credential(auth_type="per_user_oauth"): return CredentialItem(credential_name="copilot-cred", credential_values=values, credential_info={}) -def _exchange_payload(token="copilot-token", expires_at=None, api_base="https://api.githubcopilot.com"): - return { - "token": token, - "expires_at": expires_at if expires_at is not None else int(time.time()) + 3600, - "endpoints": {"api": api_base}, - } - - @contextmanager def github_http(respond: Callable[[httpx.Request], httpx.Response]): """Swap both module-level HTTP clients for httpx MockTransport versions and @@ -74,26 +59,11 @@ def github_http(respond: Callable[[httpx.Request], httpx.Response]): patch("litellm.llms.custom_httpx.llm_http_handler.get_async_httpx_client", return_value=async_client), patch("litellm.llms.custom_httpx.llm_http_handler.get_httpx_client", return_value=sync_client), patch.object(litellm, "module_level_client", sync_client), + patch.object(litellm, "client_session", sync_client.client), ): yield requests -def _exchange_responder(payload=None, status=200): - def respond(request: httpx.Request) -> httpx.Response: - if str(request.url) == EXCHANGE_URL: - return httpx.Response(status, json=payload or _exchange_payload(), request=request) - pytest.fail(f"unexpected request to {request.url}") - - return respond - - -@pytest.fixture(autouse=True) -def _clear_session_cache(): - _SESSION_CACHE.flush_cache() - yield - _SESSION_CACHE.flush_cache() - - @pytest.fixture def per_user_credential(): with patch.object(litellm, "credential_list", [_credential()]): @@ -125,29 +95,8 @@ def test_unknown_named_credential_falls_back_to_kwargs(): assert github_copilot_auth_mode("missing-cred", None) is False -@pytest.mark.parametrize( - "raw,expected", - [ - ("https://api.githubcopilot.com", "https://api.githubcopilot.com"), - ("https://api.githubcopilot.com/", "https://api.githubcopilot.com"), - ("https://tenant.githubcopilot.com:443", "https://tenant.githubcopilot.com:443"), - ("githubcopilot.com", "https://api.githubcopilot.com"), # no scheme -> default - ("http://api.githubcopilot.com", "https://api.githubcopilot.com"), # http rejected - ("https://evil.com", "https://api.githubcopilot.com"), - ("https://githubcopilot.com.evil.com", "https://api.githubcopilot.com"), - ("https://user:pw@api.githubcopilot.com", "https://api.githubcopilot.com"), - ("https://api.githubcopilot.com:8080", "https://api.githubcopilot.com"), - (None, "https://api.githubcopilot.com"), - (1234, "https://api.githubcopilot.com"), - ("https://API.GITHUBCOPILOT.COM", "https://API.GITHUBCOPILOT.COM"), - ], -) -def test_validated_copilot_api_base(raw, expected): - assert validated_copilot_api_base(raw) == expected - - def test_session_repr_and_str_hide_the_token(caplog): - session = GithubCopilotUserSession(token="secret-copilot-token", api_base="https://api.githubcopilot.com") + session = GithubCopilotUserSession(token="secret-copilot-token") assert "secret-copilot-token" not in repr(session) assert "secret-copilot-token" not in str(session) with caplog.at_level("DEBUG"): @@ -157,76 +106,6 @@ def test_session_repr_and_str_hide_the_token(caplog): assert "secret-copilot-token" not in caplog.text -def test_exchange_returns_session_and_caches_per_user(): - with github_http(_exchange_responder()) as requests: - s1 = exchange_github_token("user-a", "gh-token", "copilot-cred") - assert len(requests) == 1 - assert requests[0].headers["Authorization"] == "token gh-token" - s2 = exchange_github_token("user-a", "gh-token", "copilot-cred") - assert len(requests) == 1 - assert s2.token == s1.token == "copilot-token" - # different user -> separate exchange - exchange_github_token("user-b", "gh-token", "copilot-cred") - assert len(requests) == 2 - # different github token -> separate exchange - exchange_github_token("user-a", "gh-token-2", "copilot-cred") - assert len(requests) == 3 - - -@pytest.mark.parametrize("status", [401, 403, 404]) -def test_exchange_auth_failures_raise_caller_auth_error_and_evict(status): - with github_http(_exchange_responder(status=status)): - with pytest.raises(CallerCredentialAuthenticationError) as exc: - exchange_github_token("user-a", "gh-token", "copilot-cred") - assert "Reconnect GitHub Copilot for credential 'copilot-cred' in the LiteLLM UI (LLM Credentials)" in str( - exc.value - ) - assert _SESSION_CACHE.get_cache(_session_cache_key("user-a", "gh-token")) is None - - -def test_exchange_429_raises_caller_rate_limit(): - with github_http(_exchange_responder(status=429)): - with pytest.raises(CallerCredentialRateLimitError): - exchange_github_token("user-a", "gh-token", "copilot-cred") - - -def test_exchange_other_failure_raises_service_unavailable_without_body(): - with github_http(_exchange_responder(status=500, payload={"secret": "body"})): - with pytest.raises(ServiceUnavailableError) as exc: - exchange_github_token("user-a", "gh-token", "copilot-cred") - assert "body" not in str(exc.value) - - -def test_exchange_expiry_margin_refetches_near_expiry(): - with github_http(_exchange_responder(_exchange_payload(expires_at=int(time.time()) + 30))) as requests: - exchange_github_token("user-a", "gh-token", "copilot-cred") - # ttl under the 60s safety margin -> not cached, second call refetches - exchange_github_token("user-a", "gh-token", "copilot-cred") - assert len(requests) == 2 - - -@pytest.mark.asyncio -async def test_aexchange_single_flight_one_http_call(): - def respond(request: httpx.Request) -> httpx.Response: - return httpx.Response(200, json=_exchange_payload(), request=request) - - with github_http(respond) as requests: - s1, s2 = await asyncio.gather( - aexchange_github_token("user-a", "gh-token", "copilot-cred"), - aexchange_github_token("user-a", "gh-token", "copilot-cred"), - ) - assert len(requests) == 1 - assert s1.token == s2.token == "copilot-token" - - -@pytest.mark.asyncio -async def test_aexchange_401_raises_and_evicts(): - with github_http(_exchange_responder(status=401)): - with pytest.raises(CallerCredentialAuthenticationError): - await aexchange_github_token("user-a", "gh-token", "copilot-cred") - assert _SESSION_CACHE.get_cache(_session_cache_key("user-a", "gh-token")) is None - - def _kwargs_with_connection(token="gho_user_token"): return { "litellm_credential_name": "copilot-cred", @@ -237,21 +116,91 @@ def _kwargs_with_connection(token="gho_user_token"): } -@pytest.mark.asyncio -async def test_aattach_injects_session_into_kwargs(per_user_credential): - with github_http(_exchange_responder()): - kwargs = _kwargs_with_connection() - await aattach_github_copilot_user_session(kwargs) - session = github_copilot_user_session_from(kwargs) - assert session is not None - assert session.token == "copilot-token" - - -def test_attach_sync_injects_session(per_user_credential): - with github_http(_exchange_responder()): - kwargs = _kwargs_with_connection() +def test_attach_uses_stored_github_token_without_network(per_user_credential): + kwargs = _kwargs_with_connection() + with github_http(lambda request: pytest.fail(f"unexpected request to {request.url}")) as requests: attach_github_copilot_user_session(kwargs) - assert isinstance(kwargs[GITHUB_COPILOT_USER_SESSION_KWARG_KEY], GithubCopilotUserSession) + assert github_copilot_user_session_from(kwargs).token == "gho_user_token" + assert requests == [] + + +@pytest.mark.parametrize("header_location", ["headers", "extra_headers", "optional_params", "litellm_params"]) +def test_attach_strips_authorization_without_mutating_caller_headers(per_user_credential, header_location): + headers = {"Authorization": "Bearer caller", "authorization": "Bearer other", "x-custom": "keep"} + kwargs = { + **_kwargs_with_connection(), + header_location: {"extra_headers": headers} + if header_location in ("optional_params", "litellm_params") + else headers, + } + attach_github_copilot_user_session(kwargs) + actual = kwargs[header_location] + if header_location in ("optional_params", "litellm_params"): + actual = actual["extra_headers"] + assert actual == {"x-custom": "keep"} + assert headers == {"Authorization": "Bearer caller", "authorization": "Bearer other", "x-custom": "keep"} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("api_base", ["http://configured.example.com/", "https://configured.example.com/"]) +async def test_seat_check_sends_stored_token_to_configured_models_endpoint(monkeypatch, api_base): + monkeypatch.setenv("GITHUB_COPILOT_API_BASE", api_base) + monkeypatch.setenv("GITHUB_COPILOT_USER_AGENT", "test-agent") + with github_http(lambda request: httpx.Response(204, request=request)) as requests: + await acheck_copilot_seat("stored-github-token", "copilot-cred") + assert len(requests) == 1 + assert str(requests[0].url) == f"{api_base.rstrip('/')}/models" + assert requests[0].headers["authorization"] == "Bearer stored-github-token" + assert requests[0].headers["user-agent"] == "test-agent" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "status,error_type,message", + [ + (401, CallerCredentialAuthenticationError, "GitHub rejected the GitHub Copilot connection"), + (403, CallerCredentialAuthenticationError, "GitHub rejected the GitHub Copilot connection"), + (404, CallerCredentialAuthenticationError, "GitHub rejected the GitHub Copilot connection"), + (429, CallerCredentialRateLimitError, "rate limited the seat check"), + (302, ServiceUnavailableError, "seat check failed with status 302"), + (500, ServiceUnavailableError, "seat check failed with status 500"), + ], +) +async def test_seat_check_maps_upstream_failures(status, error_type, message): + with github_http(lambda request: httpx.Response(status, json={"secret": "private-body"}, request=request)): + with pytest.raises(error_type, match=message) as exc: + await acheck_copilot_seat("stored-github-token", "copilot-cred") + assert "private-body" not in str(exc.value) + assert "stored-github-token" not in str(exc.value) + if error_type is CallerCredentialAuthenticationError: + assert "Reconnect GitHub Copilot for credential 'copilot-cred'" in str(exc.value) + + +@pytest.mark.asyncio +async def test_seat_check_transport_failure_raises_connection_error(): + def respond(request): + raise httpx.ReadError("private transport detail", request=request) + + with github_http(respond): + with pytest.raises(APIConnectionError, match="seat check request failed") as exc: + await acheck_copilot_seat("stored-github-token", "copilot-cred") + assert "private transport detail" not in str(exc.value) + + +@pytest.mark.asyncio +async def test_github_login_uses_configured_auth_headers(monkeypatch): + monkeypatch.setenv("GITHUB_COPILOT_USER_AGENT", "test-agent") + monkeypatch.setenv("GITHUB_COPILOT_INTEGRATION_ID", "") + with github_http( + lambda request: httpx.Response(200, json={"login": "connected-user"}, request=request) + ) as requests: + login = await afetch_github_login("stored-github-token") + assert login == "connected-user" + assert len(requests) == 1 + assert str(requests[0].url) == "https://api.github.com/user" + assert requests[0].headers["authorization"] == "token stored-github-token" + assert requests[0].headers["user-agent"] == "test-agent" + assert "copilot-integration-id" not in requests[0].headers def test_attach_per_user_without_connection_raises_connect_401(per_user_credential): @@ -268,14 +217,6 @@ def test_attach_shared_mode_does_nothing_without_connection(): assert GITHUB_COPILOT_USER_SESSION_KWARG_KEY not in kwargs -def test_evict_drops_cached_session(): - with github_http(_exchange_responder()) as requests: - exchange_github_token("user-a", "gh-token", "copilot-cred") - evict_copilot_user_session("user-a", "gh-token") - exchange_github_token("user-a", "gh-token", "copilot-cred") - assert len(requests) == 2 - - @pytest.mark.asyncio async def test_astart_device_flow_shape(): def respond(request: httpx.Request) -> httpx.Response: @@ -337,7 +278,7 @@ async def test_apoll_device_flow_connected(): def test_is_github_copilot_per_user_request(): from litellm.llms.github_copilot.per_user_auth import is_github_copilot_per_user_request - session = GithubCopilotUserSession(token="t", api_base="https://api.githubcopilot.com") + session = GithubCopilotUserSession(token="t") assert is_github_copilot_per_user_request({GITHUB_COPILOT_USER_SESSION_KWARG_KEY: session}) is True assert is_github_copilot_per_user_request({}) is False assert is_github_copilot_per_user_request({GITHUB_COPILOT_USER_SESSION_KWARG_KEY: "not-a-session"}) is False @@ -376,7 +317,7 @@ def test_two_per_user_calls_with_identical_bodies_both_reach_upstream( # test-q monkeypatch.setattr(litellm, "cache", Cache()) with ( - github_http(_exchange_responder()), + github_http(lambda request: pytest.fail(f"unexpected request to {request.url}")), patch( "litellm.main.openai_chat_completions.completion", return_value=_chat_completion_response(), @@ -401,7 +342,7 @@ async def test_two_per_user_async_calls_with_identical_bodies_both_reach_upstrea monkeypatch.setattr(litellm, "cache", Cache()) with ( - github_http(_exchange_responder()), + github_http(lambda request: pytest.fail(f"unexpected request to {request.url}")), patch( "litellm.main.openai_chat_completions.acompletion", new=AsyncMock(return_value=_chat_completion_response()), @@ -422,20 +363,9 @@ def test_shared_mode_second_identical_call_hits_response_cache( # test-quality- ): """Control: shared device-login github_copilot keeps caching exactly as before, so a second identical call does not touch the upstream.""" - import json - from litellm.caching import Cache - # a mounted shared login: api-key.json with a far-future token, no device flow needed - (tmp_path / "api-key.json").write_text( - json.dumps( - { - "token": "shared-copilot-token", - "expires_at": 4102444800, - "endpoints": {"api": "https://api.githubcopilot.com"}, - } - ) - ) + (tmp_path / "access-token").write_text("shared-copilot-token") monkeypatch.setenv("GITHUB_COPILOT_TOKEN_DIR", str(tmp_path)) monkeypatch.setattr(litellm, "cache", Cache()) @@ -496,9 +426,7 @@ def _anthropic_messages_payload(): def _copilot_surface_responder(model_payload): def respond(request: httpx.Request) -> httpx.Response: - if str(request.url) == EXCHANGE_URL: - return httpx.Response(200, json=_exchange_payload(), request=request) - if request.url.host.endswith("githubcopilot.com"): + if request.url.host in ("api.githubcopilot.com", "configured.example.com", "deployment.example.com"): return httpx.Response(200, json=model_payload, request=request) pytest.fail(f"unexpected request to {request.url}") @@ -513,19 +441,51 @@ def _no_authenticator(): ) -def _assert_exchange_then_model_call(requests, github_token, model_host="https://api.githubcopilot.com"): - exchange = [r for r in requests if str(r.url) == EXCHANGE_URL] - upstream = [r for r in requests if r.url.host.endswith("githubcopilot.com")] - assert exchange, "expected the GitHub token exchange request" - assert upstream, "expected the model request to the Copilot host" - assert exchange[0].headers["Authorization"] == f"token {github_token}" - assert upstream[0].headers["Authorization"].startswith("Bearer ") - assert upstream[0].url.host == httpx.URL(model_host).host +def _assert_direct_model_call(requests, github_token, model_host="https://api.githubcopilot.com"): + assert len(requests) == 1 + assert requests[0].headers["Authorization"] == f"Bearer {github_token}" + assert requests[0].url.host == httpx.URL(model_host).host + + +@pytest.mark.parametrize("surface", ["completion", "embedding", "responses"]) +@pytest.mark.parametrize("deployment_base", [None, "https://deployment.example.com/v1"]) +def test_per_user_request_sends_github_token_directly_to_configured_base( + per_user_credential, monkeypatch, surface, deployment_base +): + monkeypatch.setenv("GITHUB_COPILOT_API_BASE", "http://configured.example.com/v1") + payloads = { + "completion": _chat_completion_response().model_dump(), + "embedding": _embedding_response_payload(), + "responses": _responses_payload(), + } + common = { + "litellm_credential_name": "copilot-cred", + "secret_fields": _secret_fields("user-a", "stored-github-token"), + "extra_headers": dict(_EVIL_AUTH_HEADERS), + "api_base": deployment_base, + } + with github_http(_copilot_surface_responder(payloads[surface])) as requests, _no_authenticator(): + if surface == "completion": + result = litellm.completion( + model="github_copilot/gpt-4o", messages=[{"role": "user", "content": "hi"}], **common + ) + assert result.choices[0].message.content == "hello" + elif surface == "embedding": + result = litellm.embedding(model="github_copilot/text-embedding-3-small", input=["hi"], **common) + assert result.data[0]["embedding"] == [0.1, 0.2] + else: + result = litellm.responses(model="github_copilot/gpt-5.3-codex", input="hi", **common) + assert result.usage.total_tokens == 6 + expected_base = deployment_base or "http://configured.example.com/v1" + expected_path = {"completion": "chat/completions", "embedding": "embeddings", "responses": "responses"}[surface] + assert len(requests) == 1 + assert str(requests[0].url) == f"{expected_base}/{expected_path}" + assert requests[0].headers["authorization"] == "Bearer stored-github-token" def test_e2e_completion_per_user_credential(per_user_credential): with ( - github_http(_exchange_responder()) as requests, + github_http(lambda request: pytest.fail(f"unexpected request to {request.url}")) as requests, _no_authenticator(), patch( "litellm.main.openai_chat_completions.completion", @@ -538,13 +498,12 @@ def test_e2e_completion_per_user_credential(per_user_credential): litellm_credential_name="copilot-cred", secret_fields=_secret_fields("user-a", "gho_a"), ) - assert len(requests) == 1 and str(requests[0].url) == EXCHANGE_URL - assert requests[0].headers["Authorization"] == "token gho_a" + assert requests == [] call_kwargs = mock_completion.call_args.kwargs - assert call_kwargs["api_key"] == "copilot-token" + assert call_kwargs["api_key"] == "gho_a" assert call_kwargs["api_base"] == "https://api.githubcopilot.com" extra_headers = call_kwargs["optional_params"]["extra_headers"] - assert extra_headers["Authorization"] == "Bearer copilot-token" + assert extra_headers["Authorization"] == "Bearer gho_a" assert response.usage.total_tokens > 0 @@ -552,7 +511,7 @@ def test_e2e_completion_per_user_session_token_wins_over_caller_authorization(pe """The session token reaches the wire even when the caller passes their own Authorization in extra_headers (chat dispatches through _complete_custom_openai).""" with ( - github_http(_exchange_responder()), + github_http(lambda request: pytest.fail(f"unexpected request to {request.url}")), _no_authenticator(), patch( "litellm.main.openai_chat_completions.completion", @@ -567,7 +526,7 @@ def test_e2e_completion_per_user_session_token_wins_over_caller_authorization(pe extra_headers={"Authorization": "Bearer caller-token", "x-custom": "keep"}, ) extra_headers = mock_completion.call_args.kwargs["optional_params"]["extra_headers"] - assert extra_headers["Authorization"] == "Bearer copilot-token" + assert extra_headers["Authorization"] == "Bearer gho_a" assert extra_headers["x-custom"] == "keep" @@ -575,9 +534,8 @@ _EVIL_AUTH_HEADERS = {"authorization": "Bearer caller-evil", "x-keep": "1"} def _assert_session_token_on_the_wire(requests): - upstream = [r for r in requests if r.url.host.endswith("githubcopilot.com")] - assert upstream, "expected the model request to the Copilot host" - assert upstream[0].headers["authorization"] == "Bearer copilot-token" + assert len(requests) == 1 + assert requests[0].headers["authorization"] == "Bearer gho_a" def test_e2e_completion_per_user_caller_authorization_cannot_override_session(per_user_credential): @@ -586,7 +544,7 @@ def test_e2e_completion_per_user_caller_authorization_cannot_override_session(pe handed to openai_chat_completions.completion.""" caller_headers: dict[str, str] = dict(_EVIL_AUTH_HEADERS) with ( - github_http(_exchange_responder()), + github_http(lambda request: pytest.fail(f"unexpected request to {request.url}")), _no_authenticator(), patch( "litellm.main.openai_chat_completions.completion", @@ -601,7 +559,7 @@ def test_e2e_completion_per_user_caller_authorization_cannot_override_session(pe extra_headers=caller_headers, ) extra_headers = mock_completion.call_args.kwargs["optional_params"]["extra_headers"] - assert extra_headers["Authorization"] == "Bearer copilot-token" + assert extra_headers["Authorization"] == "Bearer gho_a" assert "authorization" not in extra_headers assert extra_headers["x-keep"] == "1" assert caller_headers == dict(_EVIL_AUTH_HEADERS), "the caller's dict must not be mutated" @@ -667,7 +625,7 @@ def test_e2e_embedding_per_user_credential(per_user_credential): litellm_credential_name="copilot-cred", secret_fields=_secret_fields("user-a", "gho_a"), ) - _assert_exchange_then_model_call(requests, "gho_a") + _assert_direct_model_call(requests, "gho_a") assert requests[-1].url.path == "/embeddings" assert response.usage.prompt_tokens == 4 @@ -683,7 +641,7 @@ def test_e2e_responses_per_user_credential(per_user_credential): litellm_credential_name="copilot-cred", secret_fields=_secret_fields("user-a", "gho_a"), ) - _assert_exchange_then_model_call(requests, "gho_a") + _assert_direct_model_call(requests, "gho_a") assert requests[-1].url.path == "/responses" assert response.usage.total_tokens == 6 @@ -701,7 +659,7 @@ async def test_e2e_anthropic_messages_per_user_credential(per_user_credential): litellm_credential_name="copilot-cred", secret_fields=_secret_fields("user-a", "gho_a"), ) - _assert_exchange_then_model_call(requests, "gho_a") + _assert_direct_model_call(requests, "gho_a") assert requests[-1].url.path == "/v1/messages" assert response["usage"]["input_tokens"] == 4 @@ -752,17 +710,7 @@ async def test_e2e_anthropic_messages_not_connected_raises_connect_401(per_user_ def _shared_login(tmp_path, monkeypatch): - import json - - (tmp_path / "api-key.json").write_text( - json.dumps( - { - "token": "shared-copilot-token", - "expires_at": 4102444800, - "endpoints": {"api": "https://api.githubcopilot.com"}, - } - ) - ) + (tmp_path / "access-token").write_text("shared-copilot-token") monkeypatch.setenv("GITHUB_COPILOT_TOKEN_DIR", str(tmp_path)) @@ -806,9 +754,6 @@ def test_e2e_responses_shared_mode_still_uses_authenticator(tmp_path, monkeypatc @pytest.mark.asyncio async def test_e2e_anthropic_messages_shared_mode_still_uses_authenticator(tmp_path, monkeypatch): - # The shared Authenticator's access-token refresh refuses to run inside an event - # loop, so on this async surface the mounted-login path can't reach it; patch the - # shared-login edge to emulate a mounted api-key.json. from litellm.llms.github_copilot.authenticator import Authenticator with ( @@ -816,9 +761,6 @@ async def test_e2e_anthropic_messages_shared_mode_still_uses_authenticator(tmp_p patch.object( # test-quality-ok: patching the shared-login edge is the fixture under test Authenticator, "get_api_key", return_value="shared-copilot-token" ) as mock_api_key, - patch.object( # test-quality-ok: same edge; api_key read is mocked so base must be too - Authenticator, "get_api_base", return_value="https://api.githubcopilot.com" - ), ): response = await litellm.anthropic.messages.acreate( model="github_copilot/claude-haiku-4.5", @@ -831,37 +773,6 @@ async def test_e2e_anthropic_messages_shared_mode_still_uses_authenticator(tmp_p assert response["usage"]["input_tokens"] == 4 -def test_sync_exchange_single_flight_one_http_call(): - import threading - - def slow_respond(request: httpx.Request) -> httpx.Response: - time.sleep(0.05) - return httpx.Response(200, json=_exchange_payload(), request=request) - - sessions: list[GithubCopilotUserSession] = [] - with github_http(slow_respond) as requests: - threads = [ - threading.Thread( - target=lambda: sessions.append(exchange_github_token("user-a", "gh-token", "copilot-cred")) - ) - for _ in range(6) - ] - for t in threads: - t.start() - for t in threads: - t.join() - assert len(requests) == 1 - assert len(sessions) == 6 and all(s.token == "copilot-token" for s in sessions) - - -def test_two_users_same_github_token_do_not_share_session(): - with github_http(_exchange_responder()): - s1 = exchange_github_token("user-a", "gh-same", "copilot-cred") - s2 = exchange_github_token("user-b", "gh-same", "copilot-cred") - assert _session_cache_key("user-a", "gh-same") != _session_cache_key("user-b", "gh-same") - assert s1.token == s2.token == "copilot-token" - - def test_router_constructs_with_per_user_credential_deployment(per_user_credential): from litellm.llms.github_copilot.authenticator import Authenticator @@ -907,68 +818,12 @@ def test_provider_info_shared_github_copilot_still_uses_authenticator(): params: Final = LiteLLM_Params(model="github_copilot/gpt-4.1") with ( patch.object(Authenticator, "get_api_key", return_value="shared-token") as get_key, - patch.object(Authenticator, "get_api_base", return_value="https://api.githubcopilot.com"), ): _, _, dynamic_api_key, _ = litellm.get_llm_provider(model="github_copilot/gpt-4.1", litellm_params=params) get_key.assert_called_once() assert dynamic_api_key == "shared-token" -@pytest.mark.asyncio -async def test_aexchange_cancelled_waiter_does_not_cancel_the_shared_exchange(per_user_credential): - """Two waiters share one in-flight exchange; cancelling one must not cancel the - underlying future the other is waiting on.""" - gate = asyncio.Event() - - class _GatedClient: - async def get(self, url, headers=None): - await gate.wait() - return httpx.Response(200, json=_exchange_payload(), request=httpx.Request("GET", url)) - - with patch( # test-quality-ok: the gate holds the HTTP edge open so two waiters overlap - "litellm.llms.custom_httpx.http_handler.get_async_httpx_client", return_value=_GatedClient() - ): - first = asyncio.ensure_future(aexchange_github_token("user-a", "gh-shared", "copilot-cred")) - await asyncio.sleep(0) - second = asyncio.ensure_future(aexchange_github_token("user-a", "gh-shared", "copilot-cred")) - await asyncio.sleep(0) - second.cancel() - gate.set() - session = await first - assert session.token == "copilot-token" and session.api_base == "https://api.githubcopilot.com" - with pytest.raises(asyncio.CancelledError): - await second - - -@pytest.mark.asyncio -async def test_aexchange_failure_with_a_single_caller_emits_no_unretrieved_warning(per_user_credential): - """The leader removes the failed future before anyone awaits it; the stored - exception must be consumed so the loop does not log 'never retrieved'.""" - import litellm.llms.github_copilot.per_user_auth as pua - - captured: list[tuple[object, object]] = [] - loop = asyncio.get_running_loop() - previous_handler = loop.get_exception_handler() - loop.set_exception_handler(lambda _loop, context: captured.append((_loop, context))) - try: - with ( - github_http(_exchange_responder(status=401)), - pytest.raises(CallerCredentialAuthenticationError), - ): - await aexchange_github_token("user-a", "gh-fail", "copilot-cred") - _IN_FLIGHT_KEYS = list(pua._IN_FLIGHT) - import gc - - gc.collect() - await asyncio.sleep(0) - finally: - loop.set_exception_handler(previous_handler) - assert _IN_FLIGHT_KEYS == [] - assert not any( - "never retrieved" in str(context.get("message", "")) for _, context in captured if isinstance(context, dict) - ) - - @pytest.mark.asyncio async def test_per_user_streaming_call_writes_nothing_to_the_response_cache(per_user_credential, monkeypatch): """Per-user streamed completions must never be written to the shared response @@ -986,9 +841,7 @@ async def test_per_user_streaming_call_writes_nothing_to_the_response_cache(per_ kwargs = { "model": "github_copilot/gpt-4o", "messages": [{"role": "user", "content": "hi"}], - GITHUB_COPILOT_USER_SESSION_KWARG_KEY: GithubCopilotUserSession( - token="copilot-token", api_base="https://api.githubcopilot.com" - ), + GITHUB_COPILOT_USER_SESSION_KWARG_KEY: GithubCopilotUserSession(token="copilot-token"), } await handler.async_get_cache( model="github_copilot/gpt-4o", diff --git a/tests/unit/proxy/credential_endpoints/test_endpoints.py b/tests/unit/proxy/credential_endpoints/test_endpoints.py index 4359ea6592b..762d61db251 100644 --- a/tests/unit/proxy/credential_endpoints/test_endpoints.py +++ b/tests/unit/proxy/credential_endpoints/test_endpoints.py @@ -1460,11 +1460,12 @@ from contextlib import contextmanager as _ctx from litellm.caching.dual_cache import DualCache from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler +from litellm.llms.github_copilot.common_utils import DEFAULT_GITHUB_COPILOT_API_BASE from litellm.proxy.credential_endpoints import user_provider_credentials as upc _DEVICE_CODE_URL = "https://github.com/login/device/code" _ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token" -_COPILOT_TOKEN_URL = "https://api.github.com/copilot_internal/v2/token" +_COPILOT_MODELS_URL = f"{DEFAULT_GITHUB_COPILOT_API_BASE}/models" _GITHUB_USER_URL = "https://api.github.com/user" @@ -1499,6 +1500,7 @@ def _connection_row(user_id="user-a", credential_name="copilot-cred", login="oct @pytest.fixture(autouse=True) def _connection_master_key(monkeypatch): monkeypatch.setattr("litellm.proxy.proxy_server.master_key", "sk-test-master") + monkeypatch.delenv("GITHUB_COPILOT_API_BASE", raising=False) @_ctx @@ -1518,6 +1520,15 @@ def _github_http(mapping): yield +def _copilot_models_response(github_token): + def respond(request): + assert request.method == "GET" + assert request.headers["Authorization"] == f"Bearer {github_token}" + return {"data": []} + + return respond + + _DEVICE_FLOW_START = { "device_code": "dc-1", "user_code": "UC-1", @@ -1606,13 +1617,17 @@ def test_user_connection_poll_rejects_an_undecryptable_flow_handle(monkeypatch): assert response.status_code == 400 -def test_user_connection_poll_connected_persists_and_reports_login(monkeypatch): +@pytest.mark.parametrize("api_base", [None, "http://copilot.test/gateway/"]) +def test_user_connection_poll_connected_persists_and_reports_login(monkeypatch, api_base): table = _patch_user_connection_env(monkeypatch, [_per_user_credential()]) + if api_base is not None: + monkeypatch.setenv("GITHUB_COPILOT_API_BASE", api_base) + models_url = f"{(api_base or DEFAULT_GITHUB_COPILOT_API_BASE).rstrip('/')}/models" with _github_http( { _DEVICE_CODE_URL: _DEVICE_FLOW_START, _ACCESS_TOKEN_URL: {"access_token": "gho_1"}, - _COPILOT_TOKEN_URL: {"token": "copilot-tok", "expires_at": 4102444800}, + models_url: _copilot_models_response("gho_1"), _GITHUB_USER_URL: {"login": "octo"}, } ): @@ -1627,6 +1642,10 @@ def test_user_connection_poll_connected_persists_and_reports_login(monkeypatch): assert poll.status_code == 200, poll.text assert poll.json() == {"status": "connected", "interval": None, "github_login": "octo"} table.upsert.assert_awaited_once() + written = table.upsert.await_args.kwargs["data"] + for operation in ("create", "update"): + stored_connection = upc.decode_user_provider_credential(written[operation]["credential_b64"]) + assert stored_connection == upc.GithubCopilotUserConnectionPayload(access_token="gho_1", github_login="octo") def test_user_connection_poll_pending(monkeypatch): @@ -1718,7 +1737,7 @@ def test_user_connections_isolated_per_user(monkeypatch): table.delete_many.assert_awaited_once_with(where={"user_id": "user-a", "credential_name": "copilot-cred"}) -@pytest.mark.parametrize("seat_status", [403, 404]) +@pytest.mark.parametrize("seat_status", [401, 403, 404]) def test_user_connection_poll_reports_no_copilot_seat(monkeypatch, seat_status): """A GitHub seat check that rejects the user's token maps to no_copilot_seat, stores nothing, and clears the in-flight device code.""" @@ -1730,7 +1749,9 @@ def test_user_connection_poll_reports_no_copilot_seat(monkeypatch, seat_status): return httpx.Response(200, json=_DEVICE_FLOW_START, request=request) if url == _ACCESS_TOKEN_URL: return httpx.Response(200, json={"access_token": "gho_seat"}, request=request) - if url == _COPILOT_TOKEN_URL: + if url == _COPILOT_MODELS_URL: + assert request.method == "GET" + assert request.headers["Authorization"] == "Bearer gho_seat" return httpx.Response(seat_status, json={}, request=request) pytest.fail(f"unexpected request to {url}") @@ -1758,7 +1779,9 @@ def test_user_connection_poll_rate_limit_returns_429(monkeypatch): return httpx.Response(200, json=_DEVICE_FLOW_START, request=request) if url == _ACCESS_TOKEN_URL: return httpx.Response(200, json={"access_token": "gho_limited"}, request=request) - if url == _COPILOT_TOKEN_URL: + if url == _COPILOT_MODELS_URL: + assert request.method == "GET" + assert request.headers["Authorization"] == "Bearer gho_limited" return httpx.Response(429, json={}, request=request) pytest.fail(f"unexpected request to {url}") @@ -2117,7 +2140,7 @@ def test_user_connection_poll_valid_handle_works_with_a_fresh_cache(monkeypatch) { _DEVICE_CODE_URL: _DEVICE_FLOW_START, _ACCESS_TOKEN_URL: {"access_token": "gho_1"}, - _COPILOT_TOKEN_URL: {"token": "copilot-tok", "expires_at": 4102444800}, + _COPILOT_MODELS_URL: _copilot_models_response("gho_1"), _GITHUB_USER_URL: {"login": "octo"}, } ): @@ -2201,7 +2224,7 @@ def test_user_connection_poll_reports_503_when_the_cache_overwrite_cannot_land(m { _DEVICE_CODE_URL: _DEVICE_FLOW_START, _ACCESS_TOKEN_URL: {"access_token": "gho_1"}, - _COPILOT_TOKEN_URL: {"token": "copilot-tok", "expires_at": 4102444800}, + _COPILOT_MODELS_URL: _copilot_models_response("gho_1"), _GITHUB_USER_URL: {"login": "octo"}, } ): diff --git a/tests/unit/proxy/proxy_server/test_routes_utils.py b/tests/unit/proxy/proxy_server/test_routes_utils.py index 295b9e5ae8b..e9c3adc94b9 100644 --- a/tests/unit/proxy/proxy_server/test_routes_utils.py +++ b/tests/unit/proxy/proxy_server/test_routes_utils.py @@ -10,7 +10,6 @@ Pins (PR2): from __future__ import annotations import asyncio -import json import httpx import pytest @@ -176,15 +175,6 @@ def test_supported_openai_params_never_runs_oauth_for_authenticating_providers(c through ``get_llm_provider`` would run the provider's OAuth device flow and block the event loop.""" monkeypatch.setenv("GITHUB_COPILOT_TOKEN_DIR", str(tmp_path)) (tmp_path / "access-token").write_text("fake-access-token") - (tmp_path / "api-key.json").write_text( - json.dumps( - { - "token": "fake-api-key", - "expires_at": 4102444800, - "endpoints": {"api": "https://api.githubcopilot.com"}, - } - ) - ) router = litellm.Router( model_list=[ { diff --git a/tests/unit/router_utils/test_cooldown_handlers.py b/tests/unit/router_utils/test_cooldown_handlers.py index 2376e0f4d0c..30d3862b414 100644 --- a/tests/unit/router_utils/test_cooldown_handlers.py +++ b/tests/unit/router_utils/test_cooldown_handlers.py @@ -3,6 +3,7 @@ import importlib import random import time from collections.abc import Mapping +from pathlib import Path from typing import Final from unittest.mock import MagicMock, patch @@ -1517,20 +1518,18 @@ class TestPerUserCopilotAuthFailureCooldown: assert get_cooldown_deployments(router, parent_otel_span=None) == [] @pytest.mark.asyncio - async def test_shared_mode_copilot_auth_failure_still_cools_down(self) -> None: - from litellm.llms.github_copilot.authenticator import Authenticator - + async def test_shared_mode_copilot_auth_failure_still_cools_down( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + monkeypatch.setenv("GITHUB_COPILOT_TOKEN_DIR", str(tmp_path)) + (tmp_path / "access-token").write_text("gho_shared") model_id: Final = "shared-mode" - with ( - patch.object(Authenticator, "get_api_key", return_value="shared-copilot-token"), - patch.object(Authenticator, "get_api_base", return_value="https://api.githubcopilot.com"), - ): - router: Final = self._router(model_id, {"api_key": "sk-shared"}) - self._callback( - router, - model_id, - litellm.AuthenticationError("upstream rejected the shared token", "github_copilot", "gpt-4o"), - ) + router: Final = self._router(model_id, {"api_key": "sk-shared"}) + self._callback( + router, + model_id, + litellm.AuthenticationError("upstream rejected the shared token", "github_copilot", "gpt-4o"), + ) assert get_deployment_failures_for_current_minute(router, model_id) == 1 assert get_cooldown_deployments(router, parent_otel_span=None) == [model_id] @@ -1564,21 +1563,17 @@ class TestPerUserCopilotAuthFailureCooldown: assert get_cooldown_deployments(router, parent_otel_span=None) == [] @pytest.mark.asyncio - async def test_fallback_shared_mode_auth_failure_still_cools_down(self) -> None: - from litellm.llms.github_copilot.authenticator import Authenticator - + async def test_fallback_shared_mode_auth_failure_still_cools_down( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + monkeypatch.setenv("GITHUB_COPILOT_TOKEN_DIR", str(tmp_path)) + (tmp_path / "access-token").write_text("gho_shared") model_id: Final = "fallback-shared" - with ( - patch.object(Authenticator, "get_api_key", return_value="shared-copilot-token"), - patch.object(Authenticator, "get_api_base", return_value="https://api.githubcopilot.com"), - ): - router: Final = self._router(model_id, {"api_key": "sk-shared"}) - exception: Final = litellm.AuthenticationError( - "upstream rejected the shared token", "github_copilot", "gpt-4o" - ) - exception.failed_deployment_id = model_id + router: Final = self._router(model_id, {"api_key": "sk-shared"}) + exception: Final = litellm.AuthenticationError("upstream rejected the shared token", "github_copilot", "gpt-4o") + exception.failed_deployment_id = model_id - _trigger_cooldown_for_failed_deployment(litellm_router=router, kwargs={}, exception=exception) + _trigger_cooldown_for_failed_deployment(litellm_router=router, kwargs={}, exception=exception) assert get_cooldown_deployments(router, parent_otel_span=None) == [model_id] @@ -1614,23 +1609,21 @@ class TestPerUserCopilotAuthFailureCooldown: assert get_cooldown_deployments(router, parent_otel_span=None) == [] @pytest.mark.asyncio - async def test_fallback_rate_limit_on_shared_deployment_still_cools_down(self) -> None: - from litellm.llms.github_copilot.authenticator import Authenticator - + async def test_fallback_rate_limit_on_shared_deployment_still_cools_down( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + monkeypatch.setenv("GITHUB_COPILOT_TOKEN_DIR", str(tmp_path)) + (tmp_path / "access-token").write_text("gho_shared") model_id: Final = "fallback-shared-429" - with ( - patch.object(Authenticator, "get_api_key", return_value="shared-copilot-token"), - patch.object(Authenticator, "get_api_base", return_value="https://api.githubcopilot.com"), - ): - router: Final = self._router( - model_id, - {"api_key": "sk-shared"}, - allowed_fails_policy={"RateLimitErrorAllowedFails": 0}, - ) - exception: Final = litellm.RateLimitError("copilot 429", "github_copilot", "gpt-4o") - exception.failed_deployment_id = model_id + router: Final = self._router( + model_id, + {"api_key": "sk-shared"}, + allowed_fails_policy={"RateLimitErrorAllowedFails": 0}, + ) + exception: Final = litellm.RateLimitError("copilot 429", "github_copilot", "gpt-4o") + exception.failed_deployment_id = model_id - _trigger_cooldown_for_failed_deployment(litellm_router=router, kwargs={}, exception=exception) + _trigger_cooldown_for_failed_deployment(litellm_router=router, kwargs={}, exception=exception) assert get_cooldown_deployments(router, parent_otel_span=None) == [model_id] @@ -2939,9 +2932,7 @@ async def test_allowed_fails_cools_a_single_deployment( side_effect=httpx.ReadTimeout("upstream timeout") ) policy_kwargs: Final = ( - {"allowed_fails_policy": AllowedFailsPolicy(TimeoutErrorAllowedFails=1)} - if use_policy - else {"allowed_fails": 1} + {"allowed_fails_policy": AllowedFailsPolicy(TimeoutErrorAllowedFails=1)} if use_policy else {"allowed_fails": 1} ) router: Final = Router( model_list=[ @@ -2977,11 +2968,7 @@ def test_per_user_session_upstream_errors_never_cool_down_the_shared_deployment( not cool down the shared deployment for every other caller.""" from litellm.llms.github_copilot.per_user_auth import GithubCopilotUserSession - session_kwargs = { - "github_copilot_user_session": GithubCopilotUserSession( - token="copilot-token", api_base="https://api.githubcopilot.com" - ) - } + session_kwargs = {"github_copilot_user_session": GithubCopilotUserSession(token="gho_user")} for exc, status in ( (litellm.RateLimitError("copilot 429", "github_copilot", "gpt-4o"), 429), (litellm.AuthenticationError("copilot 401", "github_copilot", "gpt-4o"), 401), From 8ef7dc491ecc177a51c03559e7ea0345536079a9 Mon Sep 17 00:00:00 2001 From: codgician <15964984+codgician@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:39:47 +0800 Subject: [PATCH 17/32] test(internal-context): drop per_user_auth from in-memory cache exemptions --- tests/unit/test_internal_context.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit/test_internal_context.py b/tests/unit/test_internal_context.py index 200d9a0000c..32eb32a5ce2 100644 --- a/tests/unit/test_internal_context.py +++ b/tests/unit/test_internal_context.py @@ -55,7 +55,6 @@ _IN_MEMORY_ONLY_CALLERS: Final = frozenset( "litellm/llms/bedrock/base_aws_llm.py", "litellm/llms/custom_httpx/http_handler.py", "litellm/llms/gigachat/authenticator.py", - "litellm/llms/github_copilot/per_user_auth.py", "litellm/llms/litellm_proxy/skills/handler.py", "litellm/llms/openai/common_utils.py", "litellm/llms/openai_like/model_info.py", From 033e3ecc1e436188f08f00fdfe7c7423444b1ef8 Mon Sep 17 00:00:00 2001 From: codgician <15964984+codgician@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:38:17 +0800 Subject: [PATCH 18/32] fix(github-copilot): never send per-user tokens to a request api_base Per-user requests now resolve the Copilot base only from operator configuration (GITHUB_COPILOT_API_BASE, then the default), ignoring any api_base on the request or deployment. This keeps a caller with client-side credential overrides from redirecting a connected user's stored GitHub OAuth token to their own host, across chat, embedding, and responses. --- .../get_llm_provider_logic.py | 4 ++-- .../embedding/transformation.py | 6 ++++- .../responses/transformation.py | 6 ++++- .../clientside_credential_handler.py | 4 ++-- ...github_copilot_embedding_transformation.py | 10 ++++---- ...github_copilot_responses_transformation.py | 24 ++++++++++++++----- .../llms/github_copilot/test_per_user_auth.py | 12 +++++----- 7 files changed, 43 insertions(+), 23 deletions(-) diff --git a/litellm/litellm_core_utils/get_llm_provider_logic.py b/litellm/litellm_core_utils/get_llm_provider_logic.py index 128a396748c..4d6927d51a9 100644 --- a/litellm/litellm_core_utils/get_llm_provider_logic.py +++ b/litellm/litellm_core_utils/get_llm_provider_logic.py @@ -805,7 +805,7 @@ def _get_openai_compatible_provider_info( cast("dict[str, object]", litellm_params) # cast-ok: litellm_params arrives as an untyped request dict ) if user_session is not None: - api_base = get_copilot_api_base(api_base) # rebind-ok: resolves provider args in place + api_base = get_copilot_api_base() # rebind-ok: per-user tokens only go to the operator-configured base dynamic_api_key = user_session.token # rebind-ok: resolves provider args in place elif ( github_copilot_per_user_credential_name( @@ -815,7 +815,7 @@ def _get_openai_compatible_provider_info( ): # per-user deployments resolve no token at provider-info time; the # caller's session supplies the key per request - api_base = get_copilot_api_base(api_base) # rebind-ok: resolves provider args in place + api_base = get_copilot_api_base() # rebind-ok: per-user tokens only go to the operator-configured base dynamic_api_key = None # rebind-ok: resolves provider args in place else: ( diff --git a/litellm/llms/github_copilot/embedding/transformation.py b/litellm/llms/github_copilot/embedding/transformation.py index a6e22f3a26e..d08246cf1fe 100644 --- a/litellm/llms/github_copilot/embedding/transformation.py +++ b/litellm/llms/github_copilot/embedding/transformation.py @@ -116,7 +116,11 @@ class GithubCopilotEmbeddingConfig(BaseEmbeddingConfig): """ Get the complete URL for GitHub Copilot Embedding API endpoint. """ - return f"{get_copilot_api_base(api_base).rstrip('/')}/embeddings" + user_session: Final = require_github_copilot_user_session( + cast("dict[str, object]", litellm_params) # cast-ok: litellm_params arrives as an untyped request dict + ) + operator_api_base: Final = get_copilot_api_base(api_base if user_session is None else None) + return f"{operator_api_base.rstrip('/')}/embeddings" def transform_embedding_request( self, diff --git a/litellm/llms/github_copilot/responses/transformation.py b/litellm/llms/github_copilot/responses/transformation.py index dcd2362ac0a..e0100c19911 100644 --- a/litellm/llms/github_copilot/responses/transformation.py +++ b/litellm/llms/github_copilot/responses/transformation.py @@ -262,7 +262,11 @@ class GithubCopilotResponsesAPIConfig(OpenAIResponsesAPIConfig): """ Get the complete URL for GitHub Copilot Responses API endpoint. """ - return f"{get_copilot_api_base(api_base).rstrip('/')}/responses" + user_session: Final = require_github_copilot_user_session( + cast("dict[str, object]", litellm_params) # cast-ok: litellm_params arrives as an untyped request dict + ) + operator_api_base: Final = get_copilot_api_base(api_base if user_session is None else None) + return f"{operator_api_base.rstrip('/')}/responses" def _handle_reasoning_item(self, item: dict[str, object]) -> dict[str, object]: """ diff --git a/litellm/router_utils/clientside_credential_handler.py b/litellm/router_utils/clientside_credential_handler.py index 65980449937..12540b12ddc 100644 --- a/litellm/router_utils/clientside_credential_handler.py +++ b/litellm/router_utils/clientside_credential_handler.py @@ -118,8 +118,8 @@ def get_dynamic_litellm_params(litellm_params: dict, request_kwargs: dict) -> di # A per-user OAuth credential must survive the clear: dropping # litellm_credential_name / github_copilot_auth_type would flip the call to # shared mode and send the admin's Copilot token to the redirected host. - # Per-user mode ignores the caller's api_base anyway (the session's - # validated host wins), so keeping the mode is fail-closed. + # Per-user mode ignores the caller's api_base anyway (only the + # operator-configured Copilot base is used), so keeping the mode is fail-closed. credential_name_value: Final[object] = litellm_params.get( # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] # litellm_params is the untyped request dict "litellm_credential_name" ) diff --git a/tests/unit/llms/github_copilot/embedding/test_github_copilot_embedding_transformation.py b/tests/unit/llms/github_copilot/embedding/test_github_copilot_embedding_transformation.py index 65a5ae612a9..32f29b2909a 100644 --- a/tests/unit/llms/github_copilot/embedding/test_github_copilot_embedding_transformation.py +++ b/tests/unit/llms/github_copilot/embedding/test_github_copilot_embedding_transformation.py @@ -300,11 +300,11 @@ def test_per_user_session_token_wins_over_caller_authorization(authorization_hea @pytest.mark.parametrize( - ("deployment_base", "expected_base"), - [(None, "http://localhost:8080"), ("https://deployment.example.test/", "https://deployment.example.test")], + ("request_base", "expected_base"), + [(None, "http://localhost:8080"), ("https://attacker.example.test/", "http://localhost:8080")], ) -def test_get_complete_url_uses_configured_base_with_per_user_session( - monkeypatch: pytest.MonkeyPatch, deployment_base: str | None, expected_base: str +def test_get_complete_url_ignores_request_base_with_per_user_session( + monkeypatch: pytest.MonkeyPatch, request_base: str | None, expected_base: str ) -> None: from litellm.llms.github_copilot.per_user_auth import GithubCopilotUserSession @@ -313,7 +313,7 @@ def test_get_complete_url_uses_configured_base_with_per_user_session( config.authenticator = MagicMock() session: Final = GithubCopilotUserSession(token="user-oauth-token") url: Final = config.get_complete_url( - api_base=deployment_base, + api_base=request_base, api_key=None, model="github_copilot/text-embedding-3-small", optional_params={}, diff --git a/tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py b/tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py index 0929b3f3451..1e8fd8204ec 100644 --- a/tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py +++ b/tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py @@ -53,12 +53,22 @@ class TestGithubCopilotResponsesAPITransformation: assert config.custom_llm_provider == LlmProviders.GITHUB_COPILOT, "custom_llm_provider should be GITHUB_COPILOT" @pytest.mark.parametrize( - ("api_base", "environment_base", "expected_base"), + ("api_base", "environment_base", "shared_expected_base", "per_user_expected_base"), ( - (None, "https://copilot-env.example", "https://copilot-env.example"), - ("http://localhost:8080", "https://copilot-env.example", "http://localhost:8080"), - ("https://copilot-deployment.example/", None, "https://copilot-deployment.example"), - (None, None, DEFAULT_GITHUB_COPILOT_API_BASE), + (None, "https://copilot-env.example", "https://copilot-env.example", "https://copilot-env.example"), + ( + "http://localhost:8080", + "https://copilot-env.example", + "http://localhost:8080", + "https://copilot-env.example", + ), + ( + "https://copilot-deployment.example/", + None, + "https://copilot-deployment.example", + DEFAULT_GITHUB_COPILOT_API_BASE, + ), + (None, None, DEFAULT_GITHUB_COPILOT_API_BASE, DEFAULT_GITHUB_COPILOT_API_BASE), ), ) @pytest.mark.parametrize("per_user", (False, True)) @@ -66,7 +76,8 @@ class TestGithubCopilotResponsesAPITransformation: self, api_base: str | None, environment_base: str | None, - expected_base: str, + shared_expected_base: str, + per_user_expected_base: str, per_user: bool, monkeypatch: pytest.MonkeyPatch, ): @@ -84,6 +95,7 @@ class TestGithubCopilotResponsesAPITransformation: else {} ), ) + expected_base = per_user_expected_base if per_user else shared_expected_base assert url == f"{expected_base}/responses" config.authenticator.get_api_key.assert_not_called() diff --git a/tests/unit/llms/github_copilot/test_per_user_auth.py b/tests/unit/llms/github_copilot/test_per_user_auth.py index 19d6399e354..d95fab2fda4 100644 --- a/tests/unit/llms/github_copilot/test_per_user_auth.py +++ b/tests/unit/llms/github_copilot/test_per_user_auth.py @@ -426,7 +426,7 @@ def _anthropic_messages_payload(): def _copilot_surface_responder(model_payload): def respond(request: httpx.Request) -> httpx.Response: - if request.url.host in ("api.githubcopilot.com", "configured.example.com", "deployment.example.com"): + if request.url.host in ("api.githubcopilot.com", "configured.example.com"): return httpx.Response(200, json=model_payload, request=request) pytest.fail(f"unexpected request to {request.url}") @@ -448,9 +448,9 @@ def _assert_direct_model_call(requests, github_token, model_host="https://api.gi @pytest.mark.parametrize("surface", ["completion", "embedding", "responses"]) -@pytest.mark.parametrize("deployment_base", [None, "https://deployment.example.com/v1"]) -def test_per_user_request_sends_github_token_directly_to_configured_base( - per_user_credential, monkeypatch, surface, deployment_base +@pytest.mark.parametrize("caller_base", [None, "https://attacker.example.com/v1"]) +def test_per_user_request_sends_github_token_only_to_operator_configured_base( + per_user_credential, monkeypatch, surface, caller_base ): monkeypatch.setenv("GITHUB_COPILOT_API_BASE", "http://configured.example.com/v1") payloads = { @@ -462,7 +462,7 @@ def test_per_user_request_sends_github_token_directly_to_configured_base( "litellm_credential_name": "copilot-cred", "secret_fields": _secret_fields("user-a", "stored-github-token"), "extra_headers": dict(_EVIL_AUTH_HEADERS), - "api_base": deployment_base, + "api_base": caller_base, } with github_http(_copilot_surface_responder(payloads[surface])) as requests, _no_authenticator(): if surface == "completion": @@ -476,7 +476,7 @@ def test_per_user_request_sends_github_token_directly_to_configured_base( else: result = litellm.responses(model="github_copilot/gpt-5.3-codex", input="hi", **common) assert result.usage.total_tokens == 6 - expected_base = deployment_base or "http://configured.example.com/v1" + expected_base = "http://configured.example.com/v1" expected_path = {"completion": "chat/completions", "embedding": "embeddings", "responses": "responses"}[surface] assert len(requests) == 1 assert str(requests[0].url) == f"{expected_base}/{expected_path}" From 399449788b757ebc0f996bde27639e750653606b Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:54:58 -0700 Subject: [PATCH 19/32] fix(github-copilot): keep the request headers main sends by default Restore the openai-intent, x-github-api-version, x-vscode-user-agent-library-version and per-request x-request-id defaults so existing deployments send the same wire headers after the token exchange removal, and move tests merged from main off the removed api-key.json and session api_base --- litellm/llms/github_copilot/common_utils.py | 8 +++++--- .../anthropic/test_anthropic_safeguards_beta_wire.py | 8 ++++---- .../test_github_copilot_embedding_transformation.py | 3 ++- .../test_github_copilot_messages_transformation.py | 2 +- .../test_github_copilot_responses_transformation.py | 11 +++++++---- .../test_github_copilot_authenticator.py | 5 ++++- tests/unit/test_router/test_router.py | 2 +- tests/unit/test_router_model_cost_isolation.py | 8 ++------ 8 files changed, 26 insertions(+), 21 deletions(-) diff --git a/litellm/llms/github_copilot/common_utils.py b/litellm/llms/github_copilot/common_utils.py index 9efe96e49a2..dd16baab641 100644 --- a/litellm/llms/github_copilot/common_utils.py +++ b/litellm/llms/github_copilot/common_utils.py @@ -6,6 +6,7 @@ import os from collections.abc import MutableMapping from typing import Final from urllib.parse import urlsplit +from uuid import uuid4 import httpx @@ -31,12 +32,12 @@ _COPILOT_AUTH_HEADER_CONFIG = ( ("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), + ("openai-intent", "GITHUB_COPILOT_OPENAI_INTENT", "conversation-panel"), + ("x-github-api-version", "GITHUB_COPILOT_API_VERSION", API_VERSION), ( "x-vscode-user-agent-library-version", "GITHUB_COPILOT_USER_AGENT_LIBRARY_VERSION", - None, + "electron-fetch", ), ) @@ -103,6 +104,7 @@ def get_copilot_default_headers( return { **_get_configured_copilot_headers(_COPILOT_REQUEST_HEADER_CONFIG), "Authorization": f"Bearer {api_key}", + "x-request-id": str(uuid4()), } diff --git a/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_safeguards_beta_wire.py b/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_safeguards_beta_wire.py index cd11644eec0..6322fb45ad0 100644 --- a/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_safeguards_beta_wire.py +++ b/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_safeguards_beta_wire.py @@ -638,11 +638,11 @@ def test_github_copilot_messages_forward_safeguards_without_deriving_the_beta( token_dir: Final = tmp_path / "copilot" token_dir.mkdir() with wire_server(anthropic_peer) as wire: - (token_dir / "api-key.json").write_text( - json.dumps({"token": "synthetic-copilot-token", "expires_at": 4102444800, "endpoints": {"api": wire.url}}) - ) + (token_dir / "access-token").write_text("synthetic-copilot-token") with ( - owned_proxy(gateway, tmp_path, {"GITHUB_COPILOT_TOKEN_DIR": str(token_dir)}) as owned, + owned_proxy( + gateway, tmp_path, {"GITHUB_COPILOT_TOKEN_DIR": str(token_dir), "GITHUB_COPILOT_API_BASE": wire.url} + ) as owned, owned.scenario() as scenario, ): model: Final = scenario.model(model="github_copilot/claude-sonnet-4-5", api_base=wire.url) diff --git a/tests/unit/llms/github_copilot/embedding/test_github_copilot_embedding_transformation.py b/tests/unit/llms/github_copilot/embedding/test_github_copilot_embedding_transformation.py index 32f29b2909a..f31ddb91894 100644 --- a/tests/unit/llms/github_copilot/embedding/test_github_copilot_embedding_transformation.py +++ b/tests/unit/llms/github_copilot/embedding/test_github_copilot_embedding_transformation.py @@ -1,3 +1,4 @@ +import uuid from typing import Final from unittest.mock import MagicMock @@ -38,7 +39,7 @@ 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.115.0" - assert "x-request-id" not in validated_headers + assert str(uuid.UUID(validated_headers["x-request-id"])) == validated_headers["x-request-id"] # Test with authentication failure config.authenticator.get_api_key.side_effect = GetAPIKeyError( diff --git a/tests/unit/llms/github_copilot/messages/test_github_copilot_messages_transformation.py b/tests/unit/llms/github_copilot/messages/test_github_copilot_messages_transformation.py index 8e6f82014d8..4d0cb16932c 100644 --- a/tests/unit/llms/github_copilot/messages/test_github_copilot_messages_transformation.py +++ b/tests/unit/llms/github_copilot/messages/test_github_copilot_messages_transformation.py @@ -151,7 +151,7 @@ def test_github_copilot_anthropic_messages_validate_environment_injects_context_ @pytest.mark.usefixtures("local_beta_headers_config") def test_github_copilot_safeguards_do_not_derive_the_dangerous_tool_use_beta() -> None: - session: Final = GithubCopilotUserSession(token="gh.test-key", api_base="https://api.githubcopilot.com") + session: Final = GithubCopilotUserSession(token="gh.test-key") validated_headers, _ = GithubCopilotAnthropicMessagesConfig().validate_anthropic_messages_environment( headers={}, diff --git a/tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py b/tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py index 1e8fd8204ec..d07fb83918d 100644 --- a/tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py +++ b/tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py @@ -8,6 +8,7 @@ Source: litellm/llms/github_copilot/responses/transformation.py """ import os +import uuid from unittest.mock import MagicMock, patch import httpx @@ -114,6 +115,7 @@ class TestGithubCopilotResponsesAPITransformation: config = GithubCopilotResponsesAPIConfig() headers = config.validate_environment(headers={}, model="gpt-5.1-codex", litellm_params={}) + next_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" @@ -122,10 +124,11 @@ class TestGithubCopilotResponsesAPITransformation: assert headers["editor-version"] == "vscode/1.115.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 - assert "x-vscode-user-agent-library-version" not in headers + assert headers["openai-intent"] == "conversation-panel" + assert headers["x-github-api-version"] == "2025-04-01" + assert headers["x-vscode-user-agent-library-version"] == "electron-fetch" + assert str(uuid.UUID(headers["x-request-id"])) == headers["x-request-id"] + assert next_headers["x-request-id"] != headers["x-request-id"] @patch( # test-quality-ok: matches the Authenticator-mocking convention already used throughout this file "litellm.llms.github_copilot.responses.transformation.Authenticator" diff --git a/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py b/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py index 5bc96048ccb..ad67261e63d 100644 --- a/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py +++ b/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py @@ -1,5 +1,6 @@ import logging import os +import uuid from concurrent.futures import ThreadPoolExecutor from unittest.mock import MagicMock, mock_open, patch @@ -170,6 +171,7 @@ class TestGitHubCopilotAuthenticator: "GITHUB_COPILOT_EDITOR_PLUGIN_VERSION": "", "GITHUB_COPILOT_API_VERSION": "2026-06-01", "GITHUB_COPILOT_OPENAI_INTENT": "conversation-edits", + "GITHUB_COPILOT_USER_AGENT_LIBRARY_VERSION": "", "GITHUB_COPILOT_API_BASE": "https://api.githubcopilot.com", } @@ -206,12 +208,13 @@ class TestGitHubCopilotAuthenticator: "grant_type": "urn:ietf:params:oauth:grant-type:device_code", }, } - assert request_headers == { + assert {header: value for header, value in request_headers.items() if header != "x-request-id"} == { **expected_auth_headers, "openai-intent": "conversation-edits", "x-github-api-version": "2026-06-01", "Authorization": "Bearer " + "opencode-oauth-token", } + assert str(uuid.UUID(request_headers["x-request-id"])) == request_headers["x-request-id"] mock_client.get.assert_not_called() def test_get_access_token_from_file(self, authenticator): diff --git a/tests/unit/test_router/test_router.py b/tests/unit/test_router/test_router.py index 6403f00eb50..2dc34a46823 100644 --- a/tests/unit/test_router/test_router.py +++ b/tests/unit/test_router/test_router.py @@ -28621,7 +28621,7 @@ def test_pre_call_checks_filters_an_authenticating_provider_deployment_that_lack ): monkeypatch.setattr(litellm, "drop_params", False) monkeypatch.setenv("GITHUB_COPILOT_TOKEN_DIR", str(tmp_path)) - (tmp_path / "api-key.json").write_text(json.dumps({"token": "tid=test", "expires_at": 4102444800})) + (tmp_path / "access-token").write_text("gho_test") model_list: Final = [ { "model_name": "mixed-json-mode", diff --git a/tests/unit/test_router_model_cost_isolation.py b/tests/unit/test_router_model_cost_isolation.py index 094a13c37b2..1af55fd20e1 100644 --- a/tests/unit/test_router_model_cost_isolation.py +++ b/tests/unit/test_router_model_cost_isolation.py @@ -10,7 +10,6 @@ should still use the built-in pricing. import asyncio import copy import itertools -import json import logging import os import pathlib @@ -47,9 +46,6 @@ from litellm.utils import ( ) -_COPILOT_TOKEN_EXPIRES_AT: Final = 4_102_444_800 - - def _simulate_price_data_reload(fetched_catalog): """Drive what a price data reload does to this process's litellm state. @@ -2498,7 +2494,7 @@ def test_a_github_copilot_chat_pin_opts_a_dual_endpoint_model_out_of_the_respons from litellm.llms.github_copilot.responses.transformation import github_copilot_supports_responses_api monkeypatch.setenv("GITHUB_COPILOT_TOKEN_DIR", str(tmp_path)) - (tmp_path / "api-key.json").write_text(json.dumps({"token": "tid=test", "expires_at": _COPILOT_TOKEN_EXPIRES_AT})) + (tmp_path / "access-token").write_text("gho_test") monkeypatch.setattr(litellm, "model_cost", copy.deepcopy(litellm.model_cost)) litellm.model_cost["github_copilot/routing-test-model"] = { "litellm_provider": "github_copilot", @@ -2538,7 +2534,7 @@ def test_a_github_copilot_responses_pin_still_opts_the_model_into_the_responses_ from litellm.llms.github_copilot.responses.transformation import github_copilot_supports_responses_api monkeypatch.setenv("GITHUB_COPILOT_TOKEN_DIR", str(tmp_path)) - (tmp_path / "api-key.json").write_text(json.dumps({"token": "tid=test", "expires_at": _COPILOT_TOKEN_EXPIRES_AT})) + (tmp_path / "access-token").write_text("gho_test") monkeypatch.setattr(litellm, "model_cost", copy.deepcopy(litellm.model_cost)) litellm.model_cost["github_copilot/routing-test-model"] = { "litellm_provider": "github_copilot", From 96cc4231807134a4a1b8776d64d8d3103dcb8e01 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:49:50 -0700 Subject: [PATCH 20/32] fix(github-copilot): use the plan's Copilot host, honor deployment api_base on every surface, map per-user 401 to reconnect --- .../exception_mapping_utils.py | 9 + .../get_llm_provider_logic.py | 4 +- litellm/llms/github_copilot/authenticator.py | 18 +- .../github_copilot/chat/transformation.py | 3 +- litellm/llms/github_copilot/common_utils.py | 220 +++++++++++- .../embedding/transformation.py | 7 +- .../github_copilot/messages/transformation.py | 24 +- litellm/llms/github_copilot/per_user_auth.py | 55 ++- .../responses/transformation.py | 7 +- litellm/router.py | 10 +- .../clientside_credential_handler.py | 10 +- litellm/utils.py | 8 +- tests/unit/conftest.py | 3 + .../llm_response_utils/test_get_api_base.py | 4 +- ...github_copilot_embedding_transformation.py | 32 +- ..._github_copilot_messages_transformation.py | 51 ++- ...github_copilot_responses_transformation.py | 33 +- .../test_github_copilot_authenticator.py | 81 +++-- .../llms/github_copilot/test_per_user_auth.py | 324 +++++++++++++++--- .../credential_endpoints/test_endpoints.py | 29 +- tests/unit/test_internal_context.py | 1 + 21 files changed, 755 insertions(+), 178 deletions(-) diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index 1f49cc2c4bb..33a9ca8f233 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -2469,6 +2469,15 @@ def exception_type( return original_exception if _is_guardrail_block(original_exception): return original_exception + if isinstance(original_exception, Exception) and getattr(original_exception, "status_code", None) == 401: + from litellm.llms.github_copilot.per_user_auth import github_copilot_caller_rejection + + github_copilot_rejection: Final = github_copilot_caller_rejection( + original_exception, + cast(object, extra_kwargs), # cast-ok: extra_kwargs arrives as an untyped request dict + ) + if github_copilot_rejection is not None: + raise github_copilot_rejection from original_exception if isinstance(original_exception, ImportError) and ( original_exception.name in ("boto3", "botocore") or custom_llm_provider in ("bedrock", "bedrock_mantle") ): diff --git a/litellm/litellm_core_utils/get_llm_provider_logic.py b/litellm/litellm_core_utils/get_llm_provider_logic.py index 59d27bffbd1..d8438170cfd 100644 --- a/litellm/litellm_core_utils/get_llm_provider_logic.py +++ b/litellm/litellm_core_utils/get_llm_provider_logic.py @@ -817,7 +817,7 @@ def _get_openai_compatible_provider_info( cast("dict[str, object]", litellm_params) # cast-ok: litellm_params arrives as an untyped request dict ) if user_session is not None: - api_base = get_copilot_api_base() # rebind-ok: per-user tokens only go to the operator-configured base + api_base = get_copilot_api_base(api_base, lambda: user_session.token) # rebind-ok: provider args dynamic_api_key = user_session.token # rebind-ok: resolves provider args in place elif ( github_copilot_per_user_credential_name( @@ -827,7 +827,7 @@ def _get_openai_compatible_provider_info( ): # per-user deployments resolve no token at provider-info time; the # caller's session supplies the key per request - api_base = get_copilot_api_base() # rebind-ok: per-user tokens only go to the operator-configured base + api_base = get_copilot_api_base(api_base) # rebind-ok: resolves provider args in place dynamic_api_key = None # rebind-ok: resolves provider args in place else: ( diff --git a/litellm/llms/github_copilot/authenticator.py b/litellm/llms/github_copilot/authenticator.py index 2a7d927dd27..0615020c3e8 100644 --- a/litellm/llms/github_copilot/authenticator.py +++ b/litellm/llms/github_copilot/authenticator.py @@ -56,6 +56,13 @@ class Authenticator: ) self._ensure_token_dir() + def stored_access_token(self) -> str | None: + try: + with open(self.access_token_file, "r") as f: + return f.read().strip() or None + except OSError: + return None + def get_access_token(self) -> str: """ Login to Copilot with retry 3 times. @@ -66,13 +73,10 @@ class Authenticator: Raises: GetAccessTokenError: If unable to obtain an access token after retries. """ - try: - with open(self.access_token_file, "r") as f: - access_token = f.read().strip() - if access_token: - return access_token - except OSError: - verbose_logger.warning("No existing access token found or error reading file") + stored: Final = self.stored_access_token() + if stored is not None: + return stored + verbose_logger.warning("No existing access token found or error reading file") if not can_block_current_thread(): raise GetAccessTokenError( diff --git a/litellm/llms/github_copilot/chat/transformation.py b/litellm/llms/github_copilot/chat/transformation.py index 1eed0470562..87d4e956aa1 100644 --- a/litellm/llms/github_copilot/chat/transformation.py +++ b/litellm/llms/github_copilot/chat/transformation.py @@ -43,7 +43,6 @@ class GithubCopilotConfig(OpenAIConfig): api_key: str | None, custom_llm_provider: str, ) -> tuple[str | None, str | None, str]: - dynamic_api_base: Final = self.api_base_without_login(api_base) try: dynamic_api_key: Final = self.authenticator.get_api_key() except GetAPIKeyError as e: @@ -52,7 +51,7 @@ class GithubCopilotConfig(OpenAIConfig): llm_provider=custom_llm_provider, message=str(e), ) - return dynamic_api_base, dynamic_api_key, custom_llm_provider + return get_copilot_api_base(api_base, lambda: dynamic_api_key), dynamic_api_key, custom_llm_provider def get_openai_compatible_provider_info( self, diff --git a/litellm/llms/github_copilot/common_utils.py b/litellm/llms/github_copilot/common_utils.py index dd16baab641..eae5b020163 100644 --- a/litellm/llms/github_copilot/common_utils.py +++ b/litellm/llms/github_copilot/common_utils.py @@ -2,15 +2,24 @@ Constants for Copilot integration """ +import hashlib +import ipaddress import os -from collections.abc import MutableMapping -from typing import Final +from collections.abc import Callable, Mapping, MutableMapping +from typing import ( + Final, + Protocol, + cast, # noqa: TID251 # casts pin the untyped litellm clients and cache to the local Protocols +) from urllib.parse import urlsplit from uuid import uuid4 import httpx +from pydantic import ConfigDict, TypeAdapter, with_config +from typing_extensions import NotRequired, ReadOnly, TypedDict from litellm._logging import verbose_logger +from litellm.caching.in_memory_cache import InMemoryCache from litellm.llms.base_llm.chat.transformation import BaseLLMException COPILOT_VERSION: Final = "0.26.7" @@ -18,6 +27,11 @@ 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" +_GITHUB_API_HOST: Final = "api.github.com" +_COPILOT_USER_URL: Final = f"https://{_GITHUB_API_HOST}/copilot_internal/user" +_PLAN_API_BASE_TTL_SECONDS: Final = 3600 +_PLAN_API_BASE_RETRY_SECONDS: Final = 60 +_PLAN_API_BASE_TIMEOUT_SECONDS: Final = 10.0 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 @@ -42,6 +56,43 @@ _COPILOT_REQUEST_HEADER_CONFIG = _COPILOT_AUTH_HEADER_CONFIG + ( ) +class _PlanApiBaseCache(Protocol): + def get_cache(self, key: str) -> object: ... + + def set_cache(self, key: str, value: object, ttl: float | None = None) -> object: ... + + def flush_cache(self) -> None: ... + + +class _SyncGetClient(Protocol): + def get( + self, url: str, headers: Mapping[str, str] | None = None, timeout: float | None = None + ) -> httpx.Response: ... + + +class _AsyncGetClient(Protocol): + async def get( + self, url: str, headers: Mapping[str, str] | None = None, timeout: float | None = None + ) -> httpx.Response: ... + + +@with_config(ConfigDict(extra="allow")) +class _CopilotUserEndpoints(TypedDict): + api: ReadOnly[NotRequired[object]] + + +@with_config(ConfigDict(extra="allow")) +class _CopilotUserPayload(TypedDict): + endpoints: ReadOnly[NotRequired[_CopilotUserEndpoints]] + + +_COPILOT_USER: Final = TypeAdapter(_CopilotUserPayload) +_PLAN_API_BASES: Final = cast( # cast-ok: pins the untyped module cache to _PlanApiBaseCache + _PlanApiBaseCache, + InMemoryCache(max_size_in_memory=1000, default_ttl=_PLAN_API_BASE_TTL_SECONDS), +) + + class GithubCopilotError(BaseLLMException): def __init__( self, @@ -74,6 +125,10 @@ class GetAPIKeyError(GithubCopilotError): pass +class InvalidAPIBaseError(GithubCopilotError): + pass + + def _get_copilot_header_value(environment_variable: str, default: str | None) -> str | None: value = os.getenv(environment_variable) if value is None: @@ -108,11 +163,32 @@ def get_copilot_default_headers( } +def _hostname(url: str) -> str | None: + try: + return urlsplit(url).hostname + except ValueError: + return None + + +def _is_loopback(hostname: str) -> bool: + if hostname == "localhost": + return True + try: + return ipaddress.ip_address(hostname).is_loopback + except ValueError: + return False + + def _is_valid_api_base(api_base: str) -> bool: - parsed_api_base: Final = urlsplit(api_base) + try: + parsed_api_base: Final = urlsplit(api_base) + except ValueError: + return False + scheme: Final = parsed_api_base.scheme.lower() + hostname: Final = parsed_api_base.hostname return ( - parsed_api_base.scheme.lower() in ("http", "https") - and parsed_api_base.hostname is not None + hostname is not None + and (scheme == "https" or (scheme == "http" and _is_loopback(hostname))) and parsed_api_base.username is None and parsed_api_base.password is None and not parsed_api_base.query @@ -120,21 +196,129 @@ def _is_valid_api_base(api_base: str) -> bool: ) -def get_copilot_api_base(api_base: str | None = None) -> str: - candidates: Final = ( - ("deployment api_base", api_base), - ("GITHUB_COPILOT_API_BASE", os.getenv("GITHUB_COPILOT_API_BASE")), - ) - for source, candidate in candidates: - if not candidate: - continue - if _is_valid_api_base(candidate): - return candidate +def _warn_if_token_exchange_url_is_set() -> None: + api_key_url: Final = os.getenv("GITHUB_COPILOT_API_KEY_URL") + if api_key_url and _hostname(api_key_url) != _GITHUB_API_HOST: verbose_logger.warning( - "Ignoring %s because it must be an HTTP(S) URL without credentials, query, or fragment", - source, + "GITHUB_COPILOT_API_KEY_URL is no longer used, so GitHub Copilot requests go to %s. " + "Set GITHUB_COPILOT_API_BASE to your Copilot API host, such as https://copilot-api..ghe.com", + DEFAULT_GITHUB_COPILOT_API_BASE, ) - return DEFAULT_GITHUB_COPILOT_API_BASE + + +def _configured_copilot_api_base_source(api_base: str | None) -> tuple[str, str | None]: + return ("api_base", api_base) if api_base else ("GITHUB_COPILOT_API_BASE", os.getenv("GITHUB_COPILOT_API_BASE")) + + +def _configured_copilot_api_base(api_base: str | None) -> str | None: + source, configured_api_base = _configured_copilot_api_base_source(api_base) + if not configured_api_base: + _warn_if_token_exchange_url_is_set() + return None + if not _is_valid_api_base(configured_api_base): + raise InvalidAPIBaseError( + status_code=500, + message=( + f"GitHub Copilot {source} must be an HTTPS URL, or HTTP on a loopback host, " + "without credentials, query, or fragment" + ), + ) + return configured_api_base + + +def _trusted_plan_api_base(endpoints_api: object) -> str | None: + if not isinstance(endpoints_api, str) or not _is_valid_api_base(endpoints_api): + return None + try: + parsed: Final = urlsplit(endpoints_api) + port: Final = parsed.port + except ValueError: + return None + hostname: Final = (parsed.hostname or "").lower() + is_copilot_host: Final = parsed.scheme.lower() == "https" and hostname.endswith(".githubcopilot.com") + return endpoints_api.rstrip("/") if is_copilot_host and port in (None, 443) else None + + +def _plan_api_base_from(response: httpx.Response) -> tuple[str, int]: + if not response.is_success: + return DEFAULT_GITHUB_COPILOT_API_BASE, _PLAN_API_BASE_RETRY_SECONDS + try: + endpoints: Final = _COPILOT_USER.validate_python(response.json()).get("endpoints") + except ValueError: + return DEFAULT_GITHUB_COPILOT_API_BASE, _PLAN_API_BASE_TTL_SECONDS + endpoints_api: Final = endpoints.get("api") if endpoints is not None else None + return _trusted_plan_api_base(endpoints_api) or DEFAULT_GITHUB_COPILOT_API_BASE, _PLAN_API_BASE_TTL_SECONDS + + +def _plan_api_base_key(github_token: str) -> str: + return hashlib.sha256(github_token.encode()).hexdigest() + + +def _cached_plan_api_base(key: str) -> str | None: + cached: Final = _PLAN_API_BASES.get_cache(key) + return cached if isinstance(cached, str) else None + + +def _remember_plan_api_base(key: str, resolved: tuple[str, int]) -> None: + api_base, ttl = resolved + _PLAN_API_BASES.set_cache(key, api_base, ttl=ttl) + + +def _plan_api_base_is_needed(github_token: str, api_base: str | None) -> bool: + _, configured_api_base = _configured_copilot_api_base_source(api_base) + return not configured_api_base and _cached_plan_api_base(_plan_api_base_key(github_token)) is None + + +def _lookup_plan_api_base(github_token: str) -> tuple[str, int]: + import litellm + + client: Final = cast(_SyncGetClient, litellm.module_level_client) # cast-ok: pins the untyped client + try: + response: Final = client.get( + _COPILOT_USER_URL, headers=get_copilot_auth_headers(github_token), timeout=_PLAN_API_BASE_TIMEOUT_SECONDS + ) + except httpx.HTTPError: + return DEFAULT_GITHUB_COPILOT_API_BASE, _PLAN_API_BASE_RETRY_SECONDS + return _plan_api_base_from(response) + + +async def _alookup_plan_api_base(github_token: str) -> tuple[str, int]: + from litellm.llms.custom_httpx import http_handler + + client: Final = cast( # cast-ok: pins the untyped client factory to the local Protocol + Callable[[str], _AsyncGetClient], http_handler.get_async_httpx_client + )("github_copilot") + try: + response: Final = await client.get( + _COPILOT_USER_URL, headers=get_copilot_auth_headers(github_token), timeout=_PLAN_API_BASE_TIMEOUT_SECONDS + ) + except httpx.HTTPError: + return DEFAULT_GITHUB_COPILOT_API_BASE, _PLAN_API_BASE_RETRY_SECONDS + return _plan_api_base_from(response) + + +def prefetch_copilot_plan_api_base(github_token: str, api_base: str | None = None) -> None: + if _plan_api_base_is_needed(github_token, api_base): + _remember_plan_api_base(_plan_api_base_key(github_token), _lookup_plan_api_base(github_token)) + + +async def aprefetch_copilot_plan_api_base(github_token: str, api_base: str | None = None) -> None: + if _plan_api_base_is_needed(github_token, api_base): + _remember_plan_api_base(_plan_api_base_key(github_token), await _alookup_plan_api_base(github_token)) + + +def get_copilot_api_base(api_base: str | None = None, github_token: Callable[[], str | None] | None = None) -> str: + configured_api_base: Final = _configured_copilot_api_base(api_base) + if configured_api_base is not None: + return configured_api_base + token: Final = github_token() if github_token is not None else None + plan_api_base: Final = _cached_plan_api_base(_plan_api_base_key(token)) if token else None + return plan_api_base or DEFAULT_GITHUB_COPILOT_API_BASE + + +async def aget_copilot_api_base(github_token: str, api_base: str | None = None) -> str: + await aprefetch_copilot_plan_api_base(github_token, api_base) + return get_copilot_api_base(api_base, lambda: github_token) def pin_session_authorization( diff --git a/litellm/llms/github_copilot/embedding/transformation.py b/litellm/llms/github_copilot/embedding/transformation.py index d0e13c232f6..762e613e1ba 100644 --- a/litellm/llms/github_copilot/embedding/transformation.py +++ b/litellm/llms/github_copilot/embedding/transformation.py @@ -120,8 +120,11 @@ class GithubCopilotEmbeddingConfig(BaseEmbeddingConfig): user_session: Final = require_github_copilot_user_session( cast("dict[str, object]", litellm_params) # cast-ok: litellm_params arrives as an untyped request dict ) - operator_api_base: Final = get_copilot_api_base(api_base if user_session is None else None) - return f"{operator_api_base.rstrip('/')}/embeddings" + resolved: Final = get_copilot_api_base( + api_base, + lambda: user_session.token if user_session is not None else self.authenticator.get_api_key(), + ) + return f"{resolved.rstrip('/')}/embeddings" def transform_embedding_request( self, diff --git a/litellm/llms/github_copilot/messages/transformation.py b/litellm/llms/github_copilot/messages/transformation.py index 14cb392d4f0..86f4ab5f72f 100644 --- a/litellm/llms/github_copilot/messages/transformation.py +++ b/litellm/llms/github_copilot/messages/transformation.py @@ -61,18 +61,10 @@ class GithubCopilotAnthropicMessagesConfig(AnthropicMessagesConfig): ) -> tuple[dict, str | None]: """ Validate environment for GitHub Copilot and add Copilot-specific headers. - - The caller-supplied ``api_base`` is intentionally ignored. Routing this - request anywhere other than the authenticated Copilot endpoint would - leak the Copilot bearer token to a caller-controlled URL. """ - # Always use the configured Copilot endpoint, never the caller-supplied - # api_base. rstrip so a base with a trailing slash does not yield a - # double-slash URL once "/v1/messages" is appended downstream. user_session: Final = require_github_copilot_user_session( cast("dict[str, object]", litellm_params) # cast-ok: litellm_params arrives as an untyped request dict ) - dynamic_api_base: Final = get_copilot_api_base().rstrip("/") try: dynamic_api_key: Final = ( user_session.token if user_session is not None else self.authenticator.get_api_key() @@ -83,6 +75,7 @@ class GithubCopilotAnthropicMessagesConfig(AnthropicMessagesConfig): llm_provider="github_copilot", message=str(e), ) + dynamic_api_base: Final = get_copilot_api_base(api_base, lambda: dynamic_api_key).rstrip("/") # Merge Copilot headers with provided headers copilot_headers: Final = get_copilot_default_headers(dynamic_api_key) @@ -119,13 +112,12 @@ class GithubCopilotAnthropicMessagesConfig(AnthropicMessagesConfig): ) -> str: """ Return the complete URL for GitHub Copilot /v1/messages endpoint. - - ``api_base`` here is the value already resolved by - ``validate_anthropic_messages_environment`` (the authenticated Copilot - host), not the raw caller-supplied base — that one is discarded there to - avoid leaking the Copilot bearer token to a caller-controlled URL. We - reuse it to avoid a second authenticator read, falling back to a fresh - resolution only if it was not provided. """ - resolved: Final = (api_base or get_copilot_api_base()).rstrip("/") + user_session: Final = require_github_copilot_user_session( + cast("dict[str, object]", litellm_params) # cast-ok: litellm_params arrives as an untyped request dict + ) + resolved: Final = get_copilot_api_base( + api_base, + lambda: user_session.token if user_session is not None else self.authenticator.get_api_key(), + ).rstrip("/") return resolved if resolved.endswith("/v1/messages") else f"{resolved}/v1/messages" diff --git a/litellm/llms/github_copilot/per_user_auth.py b/litellm/llms/github_copilot/per_user_auth.py index 8bad751a360..d9fa9675380 100644 --- a/litellm/llms/github_copilot/per_user_auth.py +++ b/litellm/llms/github_copilot/per_user_auth.py @@ -35,8 +35,15 @@ from .authenticator import ( DEFAULT_GITHUB_ACCESS_TOKEN_URL, DEFAULT_GITHUB_CLIENT_ID, DEFAULT_GITHUB_DEVICE_CODE_URL, + Authenticator, +) +from .common_utils import ( + aget_copilot_api_base, + aprefetch_copilot_plan_api_base, + get_copilot_auth_headers, + get_copilot_default_headers, + prefetch_copilot_plan_api_base, ) -from .common_utils import get_copilot_api_base, get_copilot_auth_headers, get_copilot_default_headers GITHUB_COPILOT_USER_SESSION_KWARG_KEY: Final = "github_copilot_user_session" _LLM_PROVIDER: Final = "github_copilot" @@ -294,7 +301,7 @@ async def acheck_copilot_seat(github_token: str, credential_name: str) -> None: client: Final = _new_async_github_client() try: response: Final = await client.get( - f"{get_copilot_api_base().rstrip('/')}/models", + f"{(await aget_copilot_api_base(github_token)).rstrip('/')}/models", headers=get_copilot_default_headers(github_token), ) except Exception as e: @@ -413,6 +420,17 @@ def require_github_copilot_user_session(litellm_params: object) -> GithubCopilot return None +def github_copilot_caller_rejection( + original_exception: object, request_kwargs: object +) -> CallerCredentialAuthenticationError | None: + if ( + getattr(original_exception, "status_code", None) != 401 + or github_copilot_user_session_from(request_kwargs) is None + ): + return None + return _reject_connection(github_copilot_per_user_credential_name(request_kwargs) or "") + + def is_github_copilot_per_user_request(kwargs: Mapping[str, object]) -> bool: """True when this call carries a resolved per-user Copilot session, meaning it must bypass the shared response cache (cache keys ignore caller identity). @@ -490,3 +508,36 @@ def attach_github_copilot_user_session( session: Final = GithubCopilotUserSession(token=github_token) kwargs[GITHUB_COPILOT_USER_SESSION_KWARG_KEY] = session # rebind-ok: kwargs is the request mutation channel _strip_caller_authorization(kwargs) + + +def _plan_api_base_request(kwargs: Mapping[str, object], model: object) -> tuple[str, str | None] | None: + api_base_obj: Final = kwargs.get("api_base") + api_base: Final = api_base_obj if isinstance(api_base_obj, str) else None + session: Final = kwargs.get(GITHUB_COPILOT_USER_SESSION_KWARG_KEY) + if isinstance(session, GithubCopilotUserSession): + return session.token, api_base + targets_github_copilot: Final = kwargs.get("custom_llm_provider") == _LLM_PROVIDER or ( + isinstance(model, str) and model.startswith(f"{_LLM_PROVIDER}/") + ) + shared_token: Final = Authenticator().stored_access_token() if targets_github_copilot else None + return (shared_token, api_base) if shared_token is not None else None + + +def prepare_github_copilot_request( + kwargs: dict[str, object], # mutable-ok: kwargs is the request mutation channel + model: object, +) -> None: + attach_github_copilot_user_session(kwargs) + plan_request: Final = _plan_api_base_request(kwargs, model) + if plan_request is not None: + prefetch_copilot_plan_api_base(*plan_request) + + +async def aprepare_github_copilot_request( + kwargs: dict[str, object], # mutable-ok: kwargs is the request mutation channel + model: object, +) -> None: + attach_github_copilot_user_session(kwargs) + plan_request: Final = _plan_api_base_request(kwargs, model) + if plan_request is not None: + await aprefetch_copilot_plan_api_base(*plan_request) diff --git a/litellm/llms/github_copilot/responses/transformation.py b/litellm/llms/github_copilot/responses/transformation.py index e0100c19911..f671e5ee346 100644 --- a/litellm/llms/github_copilot/responses/transformation.py +++ b/litellm/llms/github_copilot/responses/transformation.py @@ -265,8 +265,11 @@ class GithubCopilotResponsesAPIConfig(OpenAIResponsesAPIConfig): user_session: Final = require_github_copilot_user_session( cast("dict[str, object]", litellm_params) # cast-ok: litellm_params arrives as an untyped request dict ) - operator_api_base: Final = get_copilot_api_base(api_base if user_session is None else None) - return f"{operator_api_base.rstrip('/')}/responses" + resolved: Final = get_copilot_api_base( + api_base, + lambda: user_session.token if user_session is not None else self.authenticator.get_api_key(), + ) + return f"{resolved.rstrip('/')}/responses" def _handle_reasoning_item(self, item: dict[str, object]) -> dict[str, object]: """ diff --git a/litellm/router.py b/litellm/router.py index 5bf46c68b20..de9e2e3f541 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -196,6 +196,7 @@ from litellm.router_utils.batch_utils import ( ) from litellm.router_utils.client_initalization_utils import InitalizeCachedClient, MaxParallelRequestsLimit from litellm.router_utils.clientside_credential_handler import ( + caller_endpoint_keys_to_drop, get_dynamic_litellm_params, is_clientside_credential, ) @@ -4585,8 +4586,13 @@ class Router: - Merges tools from deployment with request (proxy-configured tools + request tools). - On a fallback hop, drops the encrypted reasoning this deployment cannot decrypt, keeping its summary. """ - for key in self._forwarded_alias_marker_keys_the_deployment_sets( - deployment=deployment, forwarded_keys=kwargs.pop(_ALIAS_MARKER_FORWARDED_PARAMS_KWARG, ()) + for key in ( + *self._forwarded_alias_marker_keys_the_deployment_sets( + deployment=deployment, forwarded_keys=kwargs.pop(_ALIAS_MARKER_FORWARDED_PARAMS_KWARG, ()) + ), + *caller_endpoint_keys_to_drop( + cast("Mapping[str, object]", deployment["litellm_params"]) # cast-ok: untyped deployment dict + ), ): kwargs.pop(key, None) self._merge_tools_from_deployment(deployment=deployment, kwargs=kwargs, defaults=self.default_litellm_params) diff --git a/litellm/router_utils/clientside_credential_handler.py b/litellm/router_utils/clientside_credential_handler.py index 12540b12ddc..ccd122e3e67 100644 --- a/litellm/router_utils/clientside_credential_handler.py +++ b/litellm/router_utils/clientside_credential_handler.py @@ -11,7 +11,7 @@ If given, generate a unique model_id for the deployment. Ensures cooldowns are applied correctly. """ -from collections.abc import MutableMapping +from collections.abc import Mapping, MutableMapping from typing import Final, cast # noqa: TID251 # narrows the untyped request dict for mutation from litellm.types.utils import server_owned_wif_litellm_params @@ -82,6 +82,14 @@ def _admin_config_fields_to_clear_on_base_override() -> list[str]: _ADMIN_CONFIG_FIELDS_TO_CLEAR_ON_BASE_OVERRIDE: Final = _admin_config_fields_to_clear_on_base_override() +def caller_endpoint_keys_to_drop(deployment_litellm_params: Mapping[str, object]) -> tuple[str, ...]: + model: Final = deployment_litellm_params.get("model") + is_github_copilot: Final = deployment_litellm_params.get("custom_llm_provider") == "github_copilot" or ( + isinstance(model, str) and model.startswith("github_copilot/") + ) + return ("api_base", "base_url") if is_github_copilot else () + + def is_clientside_credential(request_kwargs: dict) -> bool: """ Check if the credential is a clientside credential. diff --git a/litellm/utils.py b/litellm/utils.py index d5fc0253a11..0b8112adfbe 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -1789,9 +1789,9 @@ def client(original_function): ## LOAD CREDENTIALS load_credentials_from_list(kwargs) - from litellm.llms.github_copilot.per_user_auth import attach_github_copilot_user_session + from litellm.llms.github_copilot.per_user_auth import prepare_github_copilot_request - attach_github_copilot_user_session(kwargs) # pyright: ignore[reportUnknownArgumentType] # kwargs is the untyped request dict + prepare_github_copilot_request(kwargs, model) # pyright: ignore[reportUnknownArgumentType] # kwargs is the untyped request dict kwargs["litellm_logging_obj"] = logging_obj LLMCachingHandler: Final = _get_cached_llm_caching_handler() _llm_caching_handler: Final[LLMCachingHandler] = LLMCachingHandler( @@ -2080,9 +2080,9 @@ def client(original_function): kwargs["litellm_logging_obj"] = logging_obj ## LOAD CREDENTIALS load_credentials_from_list(kwargs) - from litellm.llms.github_copilot.per_user_auth import attach_github_copilot_user_session + from litellm.llms.github_copilot.per_user_auth import aprepare_github_copilot_request - attach_github_copilot_user_session(kwargs) # pyright: ignore[reportUnknownArgumentType] # kwargs is the untyped request dict + await aprepare_github_copilot_request(kwargs, model) # pyright: ignore[reportUnknownArgumentType] # kwargs is the untyped request dict logging_obj.llm_caching_handler = _llm_caching_handler # [OPTIONAL] CHECK BUDGET if litellm.max_budget: diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 8331fb1937b..e32e83f64fd 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -52,6 +52,7 @@ from litellm.litellm_core_utils.prompt_templates import ( # noqa: E402 # same image_handling as image_handling_module, ) from litellm.llms.gemini.chat import transformation as gemini_chat_transformation_module # noqa: E402 # same import-time dependency +from litellm.llms.github_copilot import common_utils as github_copilot_common_utils_module # noqa: E402 # same import-time dependency from litellm.llms.custom_httpx.async_client_cleanup import ( # noqa: E402 # same import-time dependency close_litellm_async_clients, ) @@ -206,6 +207,8 @@ def _reset_aws_auth_caches() -> None: def _flush_client_caches() -> None: litellm.in_memory_llm_clients_cache.flush_cache() image_handling_module.in_memory_cache.flush_cache() + github_copilot_common_utils_module._PLAN_API_BASES.flush_cache() + litellm_utils_module.ProviderConfigManager._get_provider_anthropic_messages_config_cached.cache_clear() _reset_aws_auth_caches() diff --git a/tests/unit/litellm_core_utils/llm_response_utils/test_get_api_base.py b/tests/unit/litellm_core_utils/llm_response_utils/test_get_api_base.py index 517a5ac43bc..f0cf95e1285 100644 --- a/tests/unit/litellm_core_utils/llm_response_utils/test_get_api_base.py +++ b/tests/unit/litellm_core_utils/llm_response_utils/test_get_api_base.py @@ -52,12 +52,12 @@ class TestDeclaredAuthenticatingProvider: assert api_base == expected def test_copilot_reports_configured_environment_base(self, isolated_token_dirs, resolution_lookups, monkeypatch): - monkeypatch.setenv("GITHUB_COPILOT_API_BASE", "http://configured.example.com/v1") + monkeypatch.setenv("GITHUB_COPILOT_API_BASE", "https://configured.example.com/v1") api_base = litellm.get_api_base(model="github_copilot/gpt-4o", optional_params={}) assert resolution_lookups == [] - assert api_base == "http://configured.example.com/v1" + assert api_base == "https://configured.example.com/v1" def test_explicit_api_base_still_wins(self, isolated_token_dirs, resolution_lookups): api_base = litellm.get_api_base( diff --git a/tests/unit/llms/github_copilot/embedding/test_github_copilot_embedding_transformation.py b/tests/unit/llms/github_copilot/embedding/test_github_copilot_embedding_transformation.py index f31ddb91894..99cae29be13 100644 --- a/tests/unit/llms/github_copilot/embedding/test_github_copilot_embedding_transformation.py +++ b/tests/unit/llms/github_copilot/embedding/test_github_copilot_embedding_transformation.py @@ -63,13 +63,10 @@ def test_github_copilot_embedding_config_validate_environment(): @pytest.mark.parametrize( ("deployment_base", "environment_base", "expected_base"), [ - (None, None, DEFAULT_GITHUB_COPILOT_API_BASE), (None, "https://copilot.example.test/", "https://copilot.example.test"), (None, "http://localhost:8080/", "http://localhost:8080"), ("https://deployment.example.test/", "https://env.example.test", "https://deployment.example.test"), ("http://localhost:9090/", "https://env.example.test", "http://localhost:9090"), - ("not-a-url", "https://env.example.test", "https://env.example.test"), - (None, "not-a-url", DEFAULT_GITHUB_COPILOT_API_BASE), ], ) def test_github_copilot_embedding_config_get_complete_url( @@ -97,6 +94,25 @@ def test_github_copilot_embedding_config_get_complete_url( assert config.authenticator.mock_calls == [] +def test_github_copilot_embedding_url_falls_back_to_the_default_base_without_a_known_plan_host( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("GITHUB_COPILOT_API_BASE", raising=False) + config: Final = GithubCopilotEmbeddingConfig() + config.authenticator = MagicMock() + config.authenticator.get_api_key.return_value = "shared-github-token" + + url: Final = config.get_complete_url( + api_base=None, + api_key=None, + model="github_copilot/text-embedding-3-small", + optional_params={}, + litellm_params={}, + ) + + assert url == f"{DEFAULT_GITHUB_COPILOT_API_BASE}/embeddings" + + def test_github_copilot_embedding_config_transform_request(): """Test the GitHub Copilot embedding request transformation.""" config = GithubCopilotEmbeddingConfig() @@ -301,11 +317,11 @@ def test_per_user_session_token_wins_over_caller_authorization(authorization_hea @pytest.mark.parametrize( - ("request_base", "expected_base"), - [(None, "http://localhost:8080"), ("https://attacker.example.test/", "http://localhost:8080")], + ("deployment_base", "expected_base"), + [(None, "http://localhost:8080"), ("https://deployment.example.test/", "https://deployment.example.test")], ) -def test_get_complete_url_ignores_request_base_with_per_user_session( - monkeypatch: pytest.MonkeyPatch, request_base: str | None, expected_base: str +def test_get_complete_url_uses_the_deployment_base_with_per_user_session( + monkeypatch: pytest.MonkeyPatch, deployment_base: str | None, expected_base: str ) -> None: from litellm.llms.github_copilot.per_user_auth import GithubCopilotUserSession @@ -314,7 +330,7 @@ def test_get_complete_url_ignores_request_base_with_per_user_session( config.authenticator = MagicMock() session: Final = GithubCopilotUserSession(token="user-oauth-token") url: Final = config.get_complete_url( - api_base=request_base, + api_base=deployment_base, api_key=None, model="github_copilot/text-embedding-3-small", optional_params={}, diff --git a/tests/unit/llms/github_copilot/messages/test_github_copilot_messages_transformation.py b/tests/unit/llms/github_copilot/messages/test_github_copilot_messages_transformation.py index 4d0cb16932c..1d147bfc8c3 100644 --- a/tests/unit/llms/github_copilot/messages/test_github_copilot_messages_transformation.py +++ b/tests/unit/llms/github_copilot/messages/test_github_copilot_messages_transformation.py @@ -18,7 +18,6 @@ from litellm.llms.github_copilot.per_user_auth import ( @pytest.mark.parametrize( ("resolved_base", "environment_base", "expected_base"), [ - (None, None, DEFAULT_GITHUB_COPILOT_API_BASE), (None, "https://copilot.example.test/", "https://copilot.example.test"), (None, "http://localhost:8080/", "http://localhost:8080"), ("https://resolved.example.test", "https://env.example.test", "https://resolved.example.test"), @@ -51,6 +50,25 @@ def test_github_copilot_anthropic_messages_get_complete_url( assert config.authenticator.mock_calls == [] +def test_github_copilot_anthropic_messages_url_falls_back_to_the_default_base_without_a_known_plan_host( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("GITHUB_COPILOT_API_BASE", raising=False) + config: Final = GithubCopilotAnthropicMessagesConfig() + config.authenticator = MagicMock() + config.authenticator.get_api_key.return_value = "shared-github-token" + + url: Final = config.get_complete_url( + api_base=None, + api_key=None, + model="github_copilot/claude-haiku-4.5", + optional_params={}, + litellm_params={}, + ) + + assert url == f"{DEFAULT_GITHUB_COPILOT_API_BASE}/v1/messages" + + def test_github_copilot_anthropic_messages_validate_environment(monkeypatch: pytest.MonkeyPatch): """Test environment validation and header injection.""" monkeypatch.delenv("GITHUB_COPILOT_API_BASE", raising=False) @@ -61,7 +79,6 @@ def test_github_copilot_anthropic_messages_validate_environment(monkeypatch: pyt config.authenticator.get_api_key.return_value = "gh.test-key-123" headers = {} - # Pass a hostile api_base to confirm it is ignored. validated_headers, api_base = config.validate_anthropic_messages_environment( headers=headers, model="github_copilot/claude-haiku-4.5", @@ -69,7 +86,7 @@ def test_github_copilot_anthropic_messages_validate_environment(monkeypatch: pyt optional_params={}, litellm_params={}, api_key=None, - api_base="https://attacker.example.com", + api_base=None, ) assert "copilot-integration-id" in validated_headers @@ -230,16 +247,16 @@ def test_provider_config_manager_skips_non_claude_copilot_models(): @pytest.mark.parametrize( - ("environment_base", "expected_base"), + ("deployment_base", "environment_base", "expected_base"), [ - (None, DEFAULT_GITHUB_COPILOT_API_BASE), - ("https://copilot.example.test/", "https://copilot.example.test"), - ("http://localhost:8080/", "http://localhost:8080"), - ("https://user:password@copilot.example.test", DEFAULT_GITHUB_COPILOT_API_BASE), + (None, None, DEFAULT_GITHUB_COPILOT_API_BASE), + (None, "https://copilot.example.test/", "https://copilot.example.test"), + (None, "http://localhost:8080/", "http://localhost:8080"), + ("https://deployment.example.test/", "https://copilot.example.test/", "https://deployment.example.test"), ], ) -def test_validate_environment_ignores_deployment_base_and_resolves_configured_endpoint( - monkeypatch: pytest.MonkeyPatch, environment_base: str | None, expected_base: str +def test_validate_environment_uses_the_deployment_base_else_the_configured_endpoint( + monkeypatch: pytest.MonkeyPatch, deployment_base: str | None, environment_base: str | None, expected_base: str ) -> None: if environment_base is None: monkeypatch.delenv("GITHUB_COPILOT_API_BASE", raising=False) @@ -256,7 +273,7 @@ def test_validate_environment_ignores_deployment_base_and_resolves_configured_en optional_params={}, litellm_params={}, api_key=None, - api_base="https://attacker.example.com", + api_base=deployment_base, ) assert api_base == expected_base @@ -332,11 +349,15 @@ def test_github_copilot_messages_config_probes_capabilities_under_copilot_namesp @pytest.mark.parametrize( - ("environment_base", "expected_base"), - [(None, DEFAULT_GITHUB_COPILOT_API_BASE), ("http://localhost:8080/", "http://localhost:8080")], + ("deployment_base", "environment_base", "expected_base"), + [ + (None, None, DEFAULT_GITHUB_COPILOT_API_BASE), + (None, "http://localhost:8080/", "http://localhost:8080"), + ("https://deployment.example.test", "http://localhost:8080/", "https://deployment.example.test"), + ], ) def test_validate_environment_uses_per_user_session_and_skips_authenticator( - monkeypatch: pytest.MonkeyPatch, environment_base: str | None, expected_base: str + monkeypatch: pytest.MonkeyPatch, deployment_base: str | None, environment_base: str | None, expected_base: str ) -> None: from litellm.llms.github_copilot.per_user_auth import GithubCopilotUserSession @@ -356,7 +377,7 @@ def test_validate_environment_uses_per_user_session_and_skips_authenticator( messages=[], optional_params={}, litellm_params={"github_copilot_user_session": session}, - api_base="https://attacker.example.test", + api_base=deployment_base, ) assert headers["Authorization"] == "Bearer user-copilot-token" assert api_base == expected_base diff --git a/tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py b/tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py index d07fb83918d..13cc778e294 100644 --- a/tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py +++ b/tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py @@ -54,22 +54,11 @@ class TestGithubCopilotResponsesAPITransformation: assert config.custom_llm_provider == LlmProviders.GITHUB_COPILOT, "custom_llm_provider should be GITHUB_COPILOT" @pytest.mark.parametrize( - ("api_base", "environment_base", "shared_expected_base", "per_user_expected_base"), + ("api_base", "environment_base", "expected_base"), ( - (None, "https://copilot-env.example", "https://copilot-env.example", "https://copilot-env.example"), - ( - "http://localhost:8080", - "https://copilot-env.example", - "http://localhost:8080", - "https://copilot-env.example", - ), - ( - "https://copilot-deployment.example/", - None, - "https://copilot-deployment.example", - DEFAULT_GITHUB_COPILOT_API_BASE, - ), - (None, None, DEFAULT_GITHUB_COPILOT_API_BASE, DEFAULT_GITHUB_COPILOT_API_BASE), + (None, "https://copilot-env.example", "https://copilot-env.example"), + ("http://localhost:8080", "https://copilot-env.example", "http://localhost:8080"), + ("https://copilot-deployment.example/", None, "https://copilot-deployment.example"), ), ) @pytest.mark.parametrize("per_user", (False, True)) @@ -77,8 +66,7 @@ class TestGithubCopilotResponsesAPITransformation: self, api_base: str | None, environment_base: str | None, - shared_expected_base: str, - per_user_expected_base: str, + expected_base: str, per_user: bool, monkeypatch: pytest.MonkeyPatch, ): @@ -96,10 +84,19 @@ class TestGithubCopilotResponsesAPITransformation: else {} ), ) - expected_base = per_user_expected_base if per_user else shared_expected_base assert url == f"{expected_base}/responses" config.authenticator.get_api_key.assert_not_called() + def test_github_copilot_responses_url_falls_back_to_the_default_base_without_a_known_plan_host(self, monkeypatch): + monkeypatch.delenv("GITHUB_COPILOT_API_BASE", raising=False) + config = GithubCopilotResponsesAPIConfig() + config.authenticator = MagicMock() + config.authenticator.get_api_key.return_value = "shared-github-token" + + url = config.get_complete_url(api_base=None, litellm_params={}) + + assert url == f"{DEFAULT_GITHUB_COPILOT_API_BASE}/responses" + @patch("litellm.llms.github_copilot.responses.transformation.Authenticator") def test_validate_environment_default_headers(self, mock_authenticator_class, monkeypatch): for environment_variable in ( diff --git a/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py b/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py index ad67261e63d..5c6a99e3db1 100644 --- a/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py +++ b/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py @@ -16,6 +16,7 @@ from litellm.llms.github_copilot.common_utils import ( GetAccessTokenError, GetAPIKeyError, GetDeviceCodeError, + InvalidAPIBaseError, get_copilot_api_base, get_copilot_default_headers, ) @@ -61,54 +62,57 @@ class TestGitHubCopilotAuthenticator: auth = Authenticator() mock_makedirs.assert_called_once_with(auth.token_dir, exist_ok=True) - @pytest.mark.parametrize("scheme", ["http", "https"]) - def test_get_api_base_prefers_environment(self, scheme): - with patch.dict( - os.environ, - {"GITHUB_COPILOT_API_BASE": f"{scheme}://configured.example.com"}, - clear=True, - ): - assert get_copilot_api_base() == f"{scheme}://configured.example.com" + @pytest.mark.parametrize( + "api_base", + ( + "https://configured.example.com", + "http://localhost:8080", + "http://127.0.0.1:8080", + "http://[::1]:8080", + ), + ) + def test_get_api_base_prefers_environment(self, api_base): + with patch.dict(os.environ, {"GITHUB_COPILOT_API_BASE": api_base}, clear=True): + assert get_copilot_api_base() == api_base @pytest.mark.parametrize( "api_base", ( + "http://configured.example.com", + "http://10.0.0.1:8080", "ftp://api.githubcopilot.com", "https://user:password@api.githubcopilot.com", "https://api.githubcopilot.com?tenant=example", "https://api.githubcopilot.com#fragment", "api.githubcopilot.com", "https:///missing-host", + "https://[invalid", ), ) - def test_get_api_base_rejects_invalid_configuration(self, api_base, caplog): + def test_get_api_base_rejects_invalid_configuration(self, api_base): with ( patch.dict(os.environ, {"GITHUB_COPILOT_API_BASE": api_base}, clear=True), - caplog.at_level(logging.WARNING, logger="LiteLLM"), + pytest.raises(InvalidAPIBaseError, match="GitHub Copilot GITHUB_COPILOT_API_BASE must be an HTTPS URL"), ): - assert get_copilot_api_base() == DEFAULT_GITHUB_COPILOT_API_BASE - - assert ( - "Ignoring GITHUB_COPILOT_API_BASE because it must be an HTTP(S) URL without credentials, query, or fragment" - in caplog.text - ) + get_copilot_api_base() def test_get_api_base_uses_default_when_unconfigured(self): with patch.dict(os.environ, {}, clear=True): assert get_copilot_api_base() == DEFAULT_GITHUB_COPILOT_API_BASE - @pytest.mark.parametrize("scheme", ["http", "https"]) - def test_get_api_base_prefers_deployment_endpoint(self, scheme): + @pytest.mark.parametrize("api_base", ("https://deployment.example.com", "http://localhost:9000")) + def test_get_api_base_prefers_deployment_endpoint(self, api_base): with patch.dict( os.environ, {"GITHUB_COPILOT_API_BASE": "https://configured.example.com"}, clear=True, ): - assert get_copilot_api_base(f"{scheme}://deployment.example.com") == f"{scheme}://deployment.example.com" + assert get_copilot_api_base(api_base) == api_base @pytest.mark.parametrize( "api_base", [ + "http://deployment.example.com", "ftp://deployment.example.com", "https://user:pw@deployment.example.com", "https://deployment.example.com?q=1", @@ -117,21 +121,16 @@ class TestGitHubCopilotAuthenticator: "deployment.example.com", ], ) - def test_get_api_base_falls_back_from_invalid_deployment_endpoint(self, api_base, caplog): + def test_get_api_base_rejects_invalid_deployment_endpoint_instead_of_falling_back(self, api_base): with ( patch.dict( os.environ, {"GITHUB_COPILOT_API_BASE": "https://configured.example.com"}, clear=True, ), - caplog.at_level(logging.WARNING, logger="LiteLLM"), + pytest.raises(InvalidAPIBaseError, match="GitHub Copilot api_base must be an HTTPS URL"), ): - assert get_copilot_api_base(api_base) == "https://configured.example.com" - - assert ( - "Ignoring deployment api_base because it must be an HTTP(S) URL without credentials, query, or fragment" - in caplog.text - ) + get_copilot_api_base(api_base) @pytest.mark.parametrize("deployment_base", [None, ""]) @pytest.mark.parametrize("environment_base", [None, ""]) @@ -141,6 +140,34 @@ class TestGitHubCopilotAuthenticator: assert get_copilot_api_base(deployment_base) == DEFAULT_GITHUB_COPILOT_API_BASE assert caplog.text == "" + def test_get_api_base_warns_when_only_an_enterprise_token_exchange_url_is_set(self, caplog): + with ( + patch.dict( + os.environ, + {"GITHUB_COPILOT_API_KEY_URL": "https://api.octo.ghe.com/copilot_internal/v2/token"}, + clear=True, + ), + caplog.at_level(logging.WARNING, logger="LiteLLM"), + ): + assert get_copilot_api_base() == DEFAULT_GITHUB_COPILOT_API_BASE + assert "GITHUB_COPILOT_API_KEY_URL is no longer used" in caplog.text + assert "Set GITHUB_COPILOT_API_BASE" in caplog.text + + @pytest.mark.parametrize( + "environment", + ( + {"GITHUB_COPILOT_API_KEY_URL": "https://api.github.com/copilot_internal/v2/token"}, + { + "GITHUB_COPILOT_API_KEY_URL": "https://api.octo.ghe.com/copilot_internal/v2/token", + "GITHUB_COPILOT_API_BASE": "https://copilot-api.octo.ghe.com", + }, + ), + ) + def test_get_api_base_does_not_warn_when_the_token_exchange_url_needs_no_action(self, environment, caplog): + with patch.dict(os.environ, environment, clear=True), caplog.at_level(logging.WARNING, logger="LiteLLM"): + get_copilot_api_base() + assert caplog.text == "" + def test_get_github_headers(self, authenticator): with patch.dict(os.environ, {}, clear=True): headers = authenticator._get_github_headers() diff --git a/tests/unit/llms/github_copilot/test_per_user_auth.py b/tests/unit/llms/github_copilot/test_per_user_auth.py index d95fab2fda4..275b32675ae 100644 --- a/tests/unit/llms/github_copilot/test_per_user_auth.py +++ b/tests/unit/llms/github_copilot/test_per_user_auth.py @@ -41,14 +41,33 @@ def _credential(auth_type="per_user_oauth"): return CredentialItem(credential_name="copilot-cred", credential_values=values, credential_info={}) +COPILOT_USER_URL = "https://api.github.com/copilot_internal/user" +DEFAULT_COPILOT_HOST = "https://api.githubcopilot.com" +PLAN_COPILOT_HOST = "https://api.business.githubcopilot.com" + + +def answer_plan(endpoints_api: object, status_code: int = 200) -> Callable[[httpx.Request], httpx.Response]: + return lambda request: httpx.Response(status_code, json={"endpoints": {"api": endpoints_api}}, request=request) + + @contextmanager -def github_http(respond: Callable[[httpx.Request], httpx.Response]): +def github_http( + respond: Callable[[httpx.Request], httpx.Response], + plan_lookup: Callable[[httpx.Request], httpx.Response] | None = None, + plan_lookups: list[httpx.Request] | None = None, +): """Swap both module-level HTTP clients for httpx MockTransport versions and collect every request, so tests assert on the real HTTP edge (URL, headers) - while no socket is ever opened.""" + while no socket is ever opened. GitHub's Copilot plan lookup is answered by + plan_lookup (the default host when unset) and collected into plan_lookups instead.""" requests: list[httpx.Request] = [] + answer_plan_lookup = plan_lookup or answer_plan(DEFAULT_COPILOT_HOST) def capture(request: httpx.Request) -> httpx.Response: + if str(request.url) == COPILOT_USER_URL: + if plan_lookups is not None: + plan_lookups.append(request) + return answer_plan_lookup(request) requests.append(request) return respond(request) @@ -142,7 +161,7 @@ def test_attach_strips_authorization_without_mutating_caller_headers(per_user_cr @pytest.mark.asyncio -@pytest.mark.parametrize("api_base", ["http://configured.example.com/", "https://configured.example.com/"]) +@pytest.mark.parametrize("api_base", ["http://localhost:8080/", "https://configured.example.com/"]) async def test_seat_check_sends_stored_token_to_configured_models_endpoint(monkeypatch, api_base): monkeypatch.setenv("GITHUB_COPILOT_API_BASE", api_base) monkeypatch.setenv("GITHUB_COPILOT_USER_AGENT", "test-agent") @@ -369,10 +388,13 @@ def test_shared_mode_second_identical_call_hits_response_cache( # test-quality- monkeypatch.setenv("GITHUB_COPILOT_TOKEN_DIR", str(tmp_path)) monkeypatch.setattr(litellm, "cache", Cache()) - with patch( - "litellm.main.openai_chat_completions.completion", - return_value=_chat_completion_response(), - ) as mock_completion: + with ( + github_http(lambda request: pytest.fail(f"unexpected request to {request.url}")), + patch( + "litellm.main.openai_chat_completions.completion", + return_value=_chat_completion_response(), + ) as mock_completion, + ): call = { "model": "github_copilot/gpt-4o", "messages": [{"role": "user", "content": "hi"}], @@ -426,7 +448,12 @@ def _anthropic_messages_payload(): def _copilot_surface_responder(model_payload): def respond(request: httpx.Request) -> httpx.Response: - if request.url.host in ("api.githubcopilot.com", "configured.example.com"): + if request.url.host in ( + "api.githubcopilot.com", + "api.business.githubcopilot.com", + "configured.example.com", + "deployment.example.com", + ): return httpx.Response(200, json=model_payload, request=request) pytest.fail(f"unexpected request to {request.url}") @@ -447,40 +474,140 @@ def _assert_direct_model_call(requests, github_token, model_host="https://api.gi assert requests[0].url.host == httpx.URL(model_host).host +_SURFACE_MODELS: Final = { + "completion": "github_copilot/gpt-4o", + "embedding": "github_copilot/text-embedding-3-small", + "responses": "github_copilot/gpt-5.3-codex", +} +_SURFACE_PATHS: Final = {"completion": "chat/completions", "embedding": "embeddings", "responses": "responses"} + + +def _surface_payload(surface): + return { + "completion": _chat_completion_response().model_dump, + "embedding": _embedding_response_payload, + "responses": _responses_payload, + }[surface]() + + +def _call_surface(target, surface, **kwargs): + if surface == "completion": + return target.completion(messages=[{"role": "user", "content": "hi"}], **kwargs) + if surface == "embedding": + return target.embedding(input=["hi"], **kwargs) + return target.responses(input="hi", **kwargs) + + @pytest.mark.parametrize("surface", ["completion", "embedding", "responses"]) -@pytest.mark.parametrize("caller_base", [None, "https://attacker.example.com/v1"]) -def test_per_user_request_sends_github_token_only_to_operator_configured_base( - per_user_credential, monkeypatch, surface, caller_base +@pytest.mark.parametrize( + ("deployment_base", "expected_base"), + [(None, "https://configured.example.com/v1"), ("https://deployment.example.com/v1", "https://deployment.example.com/v1")], +) +def test_per_user_request_uses_the_deployment_base_else_the_configured_base( + per_user_credential, monkeypatch, surface, deployment_base, expected_base ): - monkeypatch.setenv("GITHUB_COPILOT_API_BASE", "http://configured.example.com/v1") - payloads = { - "completion": _chat_completion_response().model_dump(), - "embedding": _embedding_response_payload(), - "responses": _responses_payload(), - } - common = { - "litellm_credential_name": "copilot-cred", - "secret_fields": _secret_fields("user-a", "stored-github-token"), - "extra_headers": dict(_EVIL_AUTH_HEADERS), - "api_base": caller_base, - } - with github_http(_copilot_surface_responder(payloads[surface])) as requests, _no_authenticator(): - if surface == "completion": - result = litellm.completion( - model="github_copilot/gpt-4o", messages=[{"role": "user", "content": "hi"}], **common - ) - assert result.choices[0].message.content == "hello" - elif surface == "embedding": - result = litellm.embedding(model="github_copilot/text-embedding-3-small", input=["hi"], **common) - assert result.data[0]["embedding"] == [0.1, 0.2] - else: - result = litellm.responses(model="github_copilot/gpt-5.3-codex", input="hi", **common) - assert result.usage.total_tokens == 6 - expected_base = "http://configured.example.com/v1" - expected_path = {"completion": "chat/completions", "embedding": "embeddings", "responses": "responses"}[surface] - assert len(requests) == 1 - assert str(requests[0].url) == f"{expected_base}/{expected_path}" + monkeypatch.setenv("GITHUB_COPILOT_API_BASE", "https://configured.example.com/v1") + lookups: list[httpx.Request] = [] + with ( + github_http( + _copilot_surface_responder(_surface_payload(surface)), answer_plan(PLAN_COPILOT_HOST), lookups + ) as requests, + _no_authenticator(), + ): + _call_surface( + litellm, + surface, + model=_SURFACE_MODELS[surface], + litellm_credential_name="copilot-cred", + secret_fields=_secret_fields("user-a", "stored-github-token"), + extra_headers=dict(_EVIL_AUTH_HEADERS), + api_base=deployment_base, + ) + assert [str(request.url) for request in requests] == [f"{expected_base}/{_SURFACE_PATHS[surface]}"] assert requests[0].headers["authorization"] == "Bearer stored-github-token" + assert lookups == [] + + +@pytest.mark.parametrize("surface", ["completion", "embedding", "responses"]) +@pytest.mark.parametrize("mode", ["shared", "per_user"]) +def test_gateway_caller_endpoint_never_receives_the_github_token( + per_user_credential, tmp_path, monkeypatch, surface, mode +): + _shared_login(tmp_path, monkeypatch) + per_user = mode == "per_user" + router = litellm.Router( + model_list=[ + { + "model_name": "copilot", + "litellm_params": { + "model": _SURFACE_MODELS[surface], + "api_base": "https://deployment.example.com/v1", + **({"litellm_credential_name": "copilot-cred"} if per_user else {}), + }, + } + ] + ) + with github_http(_copilot_surface_responder(_surface_payload(surface))) as requests: + _call_surface( + router, + surface, + model="copilot", + api_base="https://attacker.example.com/v1", + base_url="https://attacker.example.com/v1", + **({"secret_fields": _secret_fields("user-a", "stored-github-token")} if per_user else {}), + ) + assert [str(request.url) for request in requests] == [ + f"https://deployment.example.com/v1/{_SURFACE_PATHS[surface]}" + ] + expected_token = "stored-github-token" if per_user else "shared-copilot-token" + assert requests[0].headers["authorization"] == f"Bearer {expected_token}" + + +def _unauthorized(request: httpx.Request) -> httpx.Response: + return httpx.Response(401, json={"error": {"message": "Unauthorized"}}, request=request) + + +_RECONNECT_MESSAGE = "Reconnect GitHub Copilot for credential 'copilot-cred'" + + +@pytest.mark.parametrize("surface", ["completion", "embedding", "responses"]) +def test_per_user_token_github_rejects_asks_that_caller_to_reconnect(per_user_credential, surface): + with ( + github_http(_unauthorized), + _no_authenticator(), + pytest.raises(CallerCredentialAuthenticationError, match=_RECONNECT_MESSAGE), + ): + _call_surface( + litellm, + surface, + model=_SURFACE_MODELS[surface], + litellm_credential_name="copilot-cred", + secret_fields=_secret_fields("user-a", "revoked-github-token"), + ) + + +@pytest.mark.asyncio +async def test_per_user_token_github_rejects_on_messages_asks_that_caller_to_reconnect(per_user_credential): + with ( + github_http(_unauthorized), + _no_authenticator(), + pytest.raises(CallerCredentialAuthenticationError, match=_RECONNECT_MESSAGE), + ): + await litellm.anthropic.messages.acreate( + model="github_copilot/claude-haiku-4.5", + max_tokens=16, + messages=[{"role": "user", "content": "hi"}], + litellm_credential_name="copilot-cred", + secret_fields=_secret_fields("user-a", "revoked-github-token"), + ) + + +@pytest.mark.parametrize("surface", ["completion", "embedding", "responses"]) +def test_shared_token_github_rejects_stays_a_deployment_authentication_error(tmp_path, monkeypatch, surface): + _shared_login(tmp_path, monkeypatch) + with github_http(_unauthorized), pytest.raises(AuthenticationError) as excinfo: + _call_surface(litellm, surface, model=_SURFACE_MODELS[surface]) + assert not isinstance(excinfo.value, CallerCredentialAuthenticationError) def test_e2e_completion_per_user_credential(per_user_credential): @@ -716,10 +843,13 @@ def _shared_login(tmp_path, monkeypatch): def test_e2e_completion_shared_mode_still_uses_authenticator(tmp_path, monkeypatch): _shared_login(tmp_path, monkeypatch) - with patch( - "litellm.main.openai_chat_completions.completion", - return_value=_chat_completion_response(), - ) as mock_completion: + with ( + github_http(lambda request: pytest.fail(f"unexpected request to {request.url}")), + patch( + "litellm.main.openai_chat_completions.completion", + return_value=_chat_completion_response(), + ) as mock_completion, + ): response = litellm.completion( model="github_copilot/gpt-4o", messages=[{"role": "user", "content": "hi"}], @@ -773,6 +903,102 @@ async def test_e2e_anthropic_messages_shared_mode_still_uses_authenticator(tmp_p assert response["usage"]["input_tokens"] == 4 +_MODE_TOKENS: Final = {"shared": "shared-copilot-token", "per_user": "gho_a"} + + +def _credential_kwargs_for(mode, tmp_path, monkeypatch): + if mode == "shared": + _shared_login(tmp_path, monkeypatch) + return {} + return {"litellm_credential_name": "copilot-cred", "secret_fields": _secret_fields("user-a", "gho_a")} + + +def _assert_plan_host_served_both_calls(requests, lookups, mode, path): + assert [str(request.url) for request in requests] == [f"{PLAN_COPILOT_HOST}/{path}"] * 2 + assert {request.headers["authorization"] for request in requests} == {f"Bearer {_MODE_TOKENS[mode]}"} + assert [request.headers["authorization"] for request in lookups] == [f"token {_MODE_TOKENS[mode]}"] + + +@pytest.mark.parametrize("surface", ["completion", "embedding", "responses"]) +@pytest.mark.parametrize("mode", ["shared", "per_user"]) +def test_requests_go_to_the_plan_host_github_reports_for_the_token( + per_user_credential, tmp_path, monkeypatch, mode, surface +): + credential_kwargs = _credential_kwargs_for(mode, tmp_path, monkeypatch) + lookups: list[httpx.Request] = [] + with github_http( + _copilot_surface_responder(_surface_payload(surface)), answer_plan(PLAN_COPILOT_HOST), lookups + ) as requests: + for _ in range(2): + _call_surface(litellm, surface, model=_SURFACE_MODELS[surface], **credential_kwargs) + _assert_plan_host_served_both_calls(requests, lookups, mode, _SURFACE_PATHS[surface]) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["shared", "per_user"]) +async def test_messages_go_to_the_plan_host_github_reports_for_the_token( + per_user_credential, tmp_path, monkeypatch, mode +): + credential_kwargs = _credential_kwargs_for(mode, tmp_path, monkeypatch) + lookups: list[httpx.Request] = [] + with github_http( + _copilot_surface_responder(_anthropic_messages_payload()), answer_plan(PLAN_COPILOT_HOST), lookups + ) as requests: + for _ in range(2): + await litellm.anthropic.messages.acreate( + model="github_copilot/claude-haiku-4.5", + max_tokens=16, + messages=[{"role": "user", "content": "hi"}], + **credential_kwargs, + ) + _assert_plan_host_served_both_calls(requests, lookups, mode, "v1/messages") + + +def _refuse_connection(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("connection refused", request=request) + + +@pytest.mark.parametrize( + "plan_lookup", + [ + answer_plan("https://api.example.com"), + answer_plan("https://api.githubcopilot.com.example.com"), + answer_plan("http://api.business.githubcopilot.com"), + answer_plan("https://api.business.githubcopilot.com:8443"), + answer_plan(None), + answer_plan(PLAN_COPILOT_HOST, status_code=500), + lambda request: httpx.Response(200, text="not json", request=request), + _refuse_connection, + ], + ids=[ + "other_host", + "lookalike_host", + "plain_http", + "custom_port", + "no_endpoint", + "server_error", + "malformed_body", + "transport_error", + ], +) +def test_requests_use_the_default_host_when_github_reports_no_usable_plan_host(tmp_path, monkeypatch, plan_lookup): + _shared_login(tmp_path, monkeypatch) + with github_http(_copilot_surface_responder(_responses_payload()), plan_lookup) as requests: + litellm.responses(model="github_copilot/gpt-5.3-codex", input="hi") + assert [str(request.url) for request in requests] == [f"{DEFAULT_COPILOT_HOST}/responses"] + + +def test_provider_resolution_never_looks_up_the_plan_host(tmp_path, monkeypatch): + _shared_login(tmp_path, monkeypatch) + lookups: list[httpx.Request] = [] + with github_http( + lambda request: pytest.fail(f"unexpected request to {request.url}"), answer_plan(PLAN_COPILOT_HOST), lookups + ): + litellm.get_llm_provider("github_copilot/gpt-4o") + litellm.get_api_base(model="github_copilot/gpt-4o", optional_params={}) + assert lookups == [] + + def test_router_constructs_with_per_user_credential_deployment(per_user_credential): from litellm.llms.github_copilot.authenticator import Authenticator @@ -794,7 +1020,13 @@ def test_router_constructs_with_per_user_credential_deployment(per_user_credenti assert any(d["model_name"] == "copilot" for d in deployments) -def test_provider_info_per_user_without_session_returns_default_base_and_no_key(per_user_credential): +@pytest.mark.parametrize( + ("deployment_base", "expected_base"), + [(None, "https://api.githubcopilot.com"), ("https://deployment.example.com", "https://deployment.example.com")], +) +def test_provider_info_per_user_without_session_returns_the_deployment_base_and_no_key( + per_user_credential, deployment_base, expected_base +): from litellm.llms.github_copilot.authenticator import Authenticator from litellm.types.router import LiteLLM_Params @@ -804,10 +1036,10 @@ def test_provider_info_per_user_without_session_returns_default_base_and_no_key( ) with patch.object(Authenticator, "get_api_key", side_effect=AssertionError("shared login")) as get_key: _, _, dynamic_api_key, api_base = litellm.get_llm_provider( - model="github_copilot/gpt-4.1", litellm_params=params + model="github_copilot/gpt-4.1", api_base=deployment_base, litellm_params=params ) get_key.assert_not_called() - assert api_base == "https://api.githubcopilot.com" + assert api_base == expected_base assert dynamic_api_key is None diff --git a/tests/unit/proxy/credential_endpoints/test_endpoints.py b/tests/unit/proxy/credential_endpoints/test_endpoints.py index 762d61db251..24f3550b3a7 100644 --- a/tests/unit/proxy/credential_endpoints/test_endpoints.py +++ b/tests/unit/proxy/credential_endpoints/test_endpoints.py @@ -1467,6 +1467,8 @@ _DEVICE_CODE_URL = "https://github.com/login/device/code" _ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token" _COPILOT_MODELS_URL = f"{DEFAULT_GITHUB_COPILOT_API_BASE}/models" _GITHUB_USER_URL = "https://api.github.com/user" +_COPILOT_USER_URL = "https://api.github.com/copilot_internal/user" +_DEFAULT_PLAN = {"endpoints": {"api": DEFAULT_GITHUB_COPILOT_API_BASE}} def _as_user(user_id="user-a"): @@ -1617,17 +1619,30 @@ def test_user_connection_poll_rejects_an_undecryptable_flow_handle(monkeypatch): assert response.status_code == 400 -@pytest.mark.parametrize("api_base", [None, "http://copilot.test/gateway/"]) -def test_user_connection_poll_connected_persists_and_reports_login(monkeypatch, api_base): +@pytest.mark.parametrize( + ("api_base", "plan_lookup", "seat_check_base"), + [ + (None, {_COPILOT_USER_URL: _DEFAULT_PLAN}, DEFAULT_GITHUB_COPILOT_API_BASE), + ( + None, + {_COPILOT_USER_URL: {"endpoints": {"api": "https://api.business.githubcopilot.com"}}}, + "https://api.business.githubcopilot.com", + ), + ("https://copilot.test/gateway/", {}, "https://copilot.test/gateway"), + ], +) +def test_user_connection_poll_connected_persists_and_reports_login( + monkeypatch, api_base, plan_lookup, seat_check_base +): table = _patch_user_connection_env(monkeypatch, [_per_user_credential()]) if api_base is not None: monkeypatch.setenv("GITHUB_COPILOT_API_BASE", api_base) - models_url = f"{(api_base or DEFAULT_GITHUB_COPILOT_API_BASE).rstrip('/')}/models" with _github_http( { _DEVICE_CODE_URL: _DEVICE_FLOW_START, _ACCESS_TOKEN_URL: {"access_token": "gho_1"}, - models_url: _copilot_models_response("gho_1"), + **plan_lookup, + f"{seat_check_base}/models": _copilot_models_response("gho_1"), _GITHUB_USER_URL: {"login": "octo"}, } ): @@ -1749,6 +1764,8 @@ def test_user_connection_poll_reports_no_copilot_seat(monkeypatch, seat_status): return httpx.Response(200, json=_DEVICE_FLOW_START, request=request) if url == _ACCESS_TOKEN_URL: return httpx.Response(200, json={"access_token": "gho_seat"}, request=request) + if url == _COPILOT_USER_URL: + return httpx.Response(200, json=_DEFAULT_PLAN, request=request) if url == _COPILOT_MODELS_URL: assert request.method == "GET" assert request.headers["Authorization"] == "Bearer gho_seat" @@ -1779,6 +1796,8 @@ def test_user_connection_poll_rate_limit_returns_429(monkeypatch): return httpx.Response(200, json=_DEVICE_FLOW_START, request=request) if url == _ACCESS_TOKEN_URL: return httpx.Response(200, json={"access_token": "gho_limited"}, request=request) + if url == _COPILOT_USER_URL: + return httpx.Response(200, json=_DEFAULT_PLAN, request=request) if url == _COPILOT_MODELS_URL: assert request.method == "GET" assert request.headers["Authorization"] == "Bearer gho_limited" @@ -2140,6 +2159,7 @@ def test_user_connection_poll_valid_handle_works_with_a_fresh_cache(monkeypatch) { _DEVICE_CODE_URL: _DEVICE_FLOW_START, _ACCESS_TOKEN_URL: {"access_token": "gho_1"}, + _COPILOT_USER_URL: _DEFAULT_PLAN, _COPILOT_MODELS_URL: _copilot_models_response("gho_1"), _GITHUB_USER_URL: {"login": "octo"}, } @@ -2224,6 +2244,7 @@ def test_user_connection_poll_reports_503_when_the_cache_overwrite_cannot_land(m { _DEVICE_CODE_URL: _DEVICE_FLOW_START, _ACCESS_TOKEN_URL: {"access_token": "gho_1"}, + _COPILOT_USER_URL: _DEFAULT_PLAN, _COPILOT_MODELS_URL: _copilot_models_response("gho_1"), _GITHUB_USER_URL: {"login": "octo"}, } diff --git a/tests/unit/test_internal_context.py b/tests/unit/test_internal_context.py index 479daba857d..56bb9ce84e7 100644 --- a/tests/unit/test_internal_context.py +++ b/tests/unit/test_internal_context.py @@ -56,6 +56,7 @@ _IN_MEMORY_ONLY_CALLERS: Final = frozenset( "litellm/llms/custom_httpx/http_handler.py", "litellm/llms/databricks/agent/transformation.py", "litellm/llms/gigachat/authenticator.py", + "litellm/llms/github_copilot/common_utils.py", "litellm/llms/litellm_proxy/skills/handler.py", "litellm/llms/openai/common_utils.py", "litellm/llms/openai_like/model_info.py", From b5e88a5dbc282345ea1544d3814da4e0732c8dc5 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:00:40 -0700 Subject: [PATCH 21/32] fix(github-copilot): accept http bases, require a Copilot base for enterprise hosts, report the plan host --- .../llm_response_utils/get_api_base.py | 8 +- .../github_copilot/chat/transformation.py | 13 ++- litellm/llms/github_copilot/common_utils.py | 38 ++++----- litellm/router.py | 22 ++++- .../clientside_credential_handler.py | 6 +- .../test_github_copilot_authenticator.py | 48 ++++++----- .../llms/github_copilot/test_per_user_auth.py | 80 ++++++++++++++----- 7 files changed, 138 insertions(+), 77 deletions(-) diff --git a/litellm/litellm_core_utils/llm_response_utils/get_api_base.py b/litellm/litellm_core_utils/llm_response_utils/get_api_base.py index 4d731b5e63a..5b39e14548b 100644 --- a/litellm/litellm_core_utils/llm_response_utils/get_api_base.py +++ b/litellm/litellm_core_utils/llm_response_utils/get_api_base.py @@ -10,9 +10,11 @@ from ...litellm_core_utils.get_llm_provider_logic import ( from ...types.router import LiteLLM_Params -def _api_base_without_login(provider: str) -> str | None: +def _api_base_without_login(provider: str, params: LiteLLM_Params) -> str | None: if provider == "github_copilot": - return litellm.GithubCopilotConfig().api_base_without_login() + from litellm.llms.github_copilot.per_user_auth import github_copilot_user_session_from + + return litellm.GithubCopilotConfig().api_base_without_login(github_copilot_user_session_from(params)) if provider == "chatgpt": return litellm.ChatGPTConfig().api_base_without_login() return None @@ -68,7 +70,7 @@ def get_api_base(model: str, optional_params: dict | LiteLLM_Params) -> str | No model = litellm.model_alias_map[model] declared: Final = declared_authenticating_provider(model, _optional_params.custom_llm_provider) if declared is not None: - return _api_base_without_login(declared) + return _api_base_without_login(declared, _optional_params) try: ( model, diff --git a/litellm/llms/github_copilot/chat/transformation.py b/litellm/llms/github_copilot/chat/transformation.py index 87d4e956aa1..2fbc225e6f3 100644 --- a/litellm/llms/github_copilot/chat/transformation.py +++ b/litellm/llms/github_copilot/chat/transformation.py @@ -13,10 +13,11 @@ from litellm.types.utils import ModelResponse from ..authenticator import Authenticator from ..common_utils import ( GetAPIKeyError, + InvalidAPIBaseError, get_copilot_api_base, get_copilot_default_headers, ) -from ..per_user_auth import require_github_copilot_user_session +from ..per_user_auth import GithubCopilotUserSession, require_github_copilot_user_session if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj @@ -33,8 +34,14 @@ class GithubCopilotConfig(OpenAIConfig): super().__init__() self.authenticator = Authenticator() - def api_base_without_login(self, api_base: str | None = None) -> str: - return get_copilot_api_base(api_base) + def api_base_without_login(self, user_session: GithubCopilotUserSession | None = None) -> str | None: + try: + return get_copilot_api_base( + None, + lambda: user_session.token if user_session is not None else self.authenticator.stored_access_token(), + ) + except InvalidAPIBaseError: + return None def _get_openai_compatible_provider_info( self, diff --git a/litellm/llms/github_copilot/common_utils.py b/litellm/llms/github_copilot/common_utils.py index eae5b020163..78a62094806 100644 --- a/litellm/llms/github_copilot/common_utils.py +++ b/litellm/llms/github_copilot/common_utils.py @@ -3,7 +3,6 @@ Constants for Copilot integration """ import hashlib -import ipaddress import os from collections.abc import Callable, Mapping, MutableMapping from typing import ( @@ -18,7 +17,6 @@ import httpx from pydantic import ConfigDict, TypeAdapter, with_config from typing_extensions import NotRequired, ReadOnly, TypedDict -from litellm._logging import verbose_logger from litellm.caching.in_memory_cache import InMemoryCache from litellm.llms.base_llm.chat.transformation import BaseLLMException @@ -170,15 +168,6 @@ def _hostname(url: str) -> str | None: return None -def _is_loopback(hostname: str) -> bool: - if hostname == "localhost": - return True - try: - return ipaddress.ip_address(hostname).is_loopback - except ValueError: - return False - - def _is_valid_api_base(api_base: str) -> bool: try: parsed_api_base: Final = urlsplit(api_base) @@ -188,7 +177,7 @@ def _is_valid_api_base(api_base: str) -> bool: hostname: Final = parsed_api_base.hostname return ( hostname is not None - and (scheme == "https" or (scheme == "http" and _is_loopback(hostname))) + and scheme in ("http", "https") and parsed_api_base.username is None and parsed_api_base.password is None and not parsed_api_base.query @@ -196,13 +185,15 @@ def _is_valid_api_base(api_base: str) -> bool: ) -def _warn_if_token_exchange_url_is_set() -> None: +def _reject_enterprise_token_exchange_url() -> None: api_key_url: Final = os.getenv("GITHUB_COPILOT_API_KEY_URL") if api_key_url and _hostname(api_key_url) != _GITHUB_API_HOST: - verbose_logger.warning( - "GITHUB_COPILOT_API_KEY_URL is no longer used, so GitHub Copilot requests go to %s. " - "Set GITHUB_COPILOT_API_BASE to your Copilot API host, such as https://copilot-api..ghe.com", - DEFAULT_GITHUB_COPILOT_API_BASE, + raise InvalidAPIBaseError( + status_code=500, + message=( + "GITHUB_COPILOT_API_KEY_URL is no longer used. Set GITHUB_COPILOT_API_BASE to your Copilot API host, " + "such as https://copilot-api..ghe.com" + ), ) @@ -213,15 +204,12 @@ def _configured_copilot_api_base_source(api_base: str | None) -> tuple[str, str def _configured_copilot_api_base(api_base: str | None) -> str | None: source, configured_api_base = _configured_copilot_api_base_source(api_base) if not configured_api_base: - _warn_if_token_exchange_url_is_set() + _reject_enterprise_token_exchange_url() return None if not _is_valid_api_base(configured_api_base): raise InvalidAPIBaseError( status_code=500, - message=( - f"GitHub Copilot {source} must be an HTTPS URL, or HTTP on a loopback host, " - "without credentials, query, or fragment" - ), + message=(f"GitHub Copilot {source} must be an HTTP or HTTPS URL without credentials, query, or fragment"), ) return configured_api_base @@ -265,8 +253,10 @@ def _remember_plan_api_base(key: str, resolved: tuple[str, int]) -> None: def _plan_api_base_is_needed(github_token: str, api_base: str | None) -> bool: - _, configured_api_base = _configured_copilot_api_base_source(api_base) - return not configured_api_base and _cached_plan_api_base(_plan_api_base_key(github_token)) is None + return ( + _configured_copilot_api_base(api_base) is None + and _cached_plan_api_base(_plan_api_base_key(github_token)) is None + ) def _lookup_plan_api_base(github_token: str) -> tuple[str, int]: diff --git a/litellm/router.py b/litellm/router.py index de9e2e3f541..9b7aae46300 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -85,6 +85,7 @@ from litellm.litellm_core_utils.asyncify import run_async_function from litellm.litellm_core_utils.completion_timeout import CompletionTimeout from litellm.litellm_core_utils.core_helpers import ( coerce_token_limit, + drops_unsupported_params, get_litellm_metadata_from_kwargs, get_metadata_variable_name_from_kwargs, get_or_create_metadata_bucket, @@ -196,8 +197,8 @@ from litellm.router_utils.batch_utils import ( ) from litellm.router_utils.client_initalization_utils import InitalizeCachedClient, MaxParallelRequestsLimit from litellm.router_utils.clientside_credential_handler import ( - caller_endpoint_keys_to_drop, get_dynamic_litellm_params, + github_copilot_caller_endpoint_keys, is_clientside_credential, ) from litellm.router_utils.common_utils import ( @@ -4586,13 +4587,26 @@ class Router: - Merges tools from deployment with request (proxy-configured tools + request tools). - On a fallback hop, drops the encrypted reasoning this deployment cannot decrypt, keeping its summary. """ + deployment_litellm_params: Final = cast( # cast-ok: deployment litellm_params is a str-keyed dict + "Mapping[str, object]", deployment["litellm_params"] + ) + caller_kwargs: Final = cast("Mapping[str, object]", kwargs) # cast-ok: router call kwargs are a str-keyed dict + caller_endpoint_keys: Final = github_copilot_caller_endpoint_keys(deployment_litellm_params, caller_kwargs) + call_drop_params: Final = {**deployment_litellm_params, **caller_kwargs}.get("drop_params") + if caller_endpoint_keys and not drops_unsupported_params(call_drop_params): + raise litellm.BadRequestError( + message=( + f"GitHub Copilot deployments do not accept {' or '.join(caller_endpoint_keys)} from the caller. " + "Set api_base on the deployment, or set drop_params to drop it" + ), + model=cast(str, deployment["model_name"]), # cast-ok: every deployment carries a str model_name + llm_provider="github_copilot", + ) for key in ( *self._forwarded_alias_marker_keys_the_deployment_sets( deployment=deployment, forwarded_keys=kwargs.pop(_ALIAS_MARKER_FORWARDED_PARAMS_KWARG, ()) ), - *caller_endpoint_keys_to_drop( - cast("Mapping[str, object]", deployment["litellm_params"]) # cast-ok: untyped deployment dict - ), + *caller_endpoint_keys, ): kwargs.pop(key, None) self._merge_tools_from_deployment(deployment=deployment, kwargs=kwargs, defaults=self.default_litellm_params) diff --git a/litellm/router_utils/clientside_credential_handler.py b/litellm/router_utils/clientside_credential_handler.py index ccd122e3e67..d4f21a3a6f0 100644 --- a/litellm/router_utils/clientside_credential_handler.py +++ b/litellm/router_utils/clientside_credential_handler.py @@ -82,12 +82,14 @@ def _admin_config_fields_to_clear_on_base_override() -> list[str]: _ADMIN_CONFIG_FIELDS_TO_CLEAR_ON_BASE_OVERRIDE: Final = _admin_config_fields_to_clear_on_base_override() -def caller_endpoint_keys_to_drop(deployment_litellm_params: Mapping[str, object]) -> tuple[str, ...]: +def github_copilot_caller_endpoint_keys( + deployment_litellm_params: Mapping[str, object], request_kwargs: Mapping[str, object] +) -> tuple[str, ...]: model: Final = deployment_litellm_params.get("model") is_github_copilot: Final = deployment_litellm_params.get("custom_llm_provider") == "github_copilot" or ( isinstance(model, str) and model.startswith("github_copilot/") ) - return ("api_base", "base_url") if is_github_copilot else () + return tuple(key for key in ("api_base", "base_url") if is_github_copilot and request_kwargs.get(key)) def is_clientside_credential(request_kwargs: dict) -> bool: diff --git a/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py b/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py index 5c6a99e3db1..f839397295c 100644 --- a/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py +++ b/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py @@ -69,6 +69,8 @@ class TestGitHubCopilotAuthenticator: "http://localhost:8080", "http://127.0.0.1:8080", "http://[::1]:8080", + "http://copilot-relay:8080", + "http://10.0.0.1:8080", ), ) def test_get_api_base_prefers_environment(self, api_base): @@ -78,8 +80,6 @@ class TestGitHubCopilotAuthenticator: @pytest.mark.parametrize( "api_base", ( - "http://configured.example.com", - "http://10.0.0.1:8080", "ftp://api.githubcopilot.com", "https://user:password@api.githubcopilot.com", "https://api.githubcopilot.com?tenant=example", @@ -92,7 +92,9 @@ class TestGitHubCopilotAuthenticator: def test_get_api_base_rejects_invalid_configuration(self, api_base): with ( patch.dict(os.environ, {"GITHUB_COPILOT_API_BASE": api_base}, clear=True), - pytest.raises(InvalidAPIBaseError, match="GitHub Copilot GITHUB_COPILOT_API_BASE must be an HTTPS URL"), + pytest.raises( + InvalidAPIBaseError, match="GitHub Copilot GITHUB_COPILOT_API_BASE must be an HTTP or HTTPS URL" + ), ): get_copilot_api_base() @@ -100,7 +102,9 @@ class TestGitHubCopilotAuthenticator: with patch.dict(os.environ, {}, clear=True): assert get_copilot_api_base() == DEFAULT_GITHUB_COPILOT_API_BASE - @pytest.mark.parametrize("api_base", ("https://deployment.example.com", "http://localhost:9000")) + @pytest.mark.parametrize( + "api_base", ("https://deployment.example.com", "http://localhost:9000", "http://deployment.example.com") + ) def test_get_api_base_prefers_deployment_endpoint(self, api_base): with patch.dict( os.environ, @@ -112,7 +116,6 @@ class TestGitHubCopilotAuthenticator: @pytest.mark.parametrize( "api_base", [ - "http://deployment.example.com", "ftp://deployment.example.com", "https://user:pw@deployment.example.com", "https://deployment.example.com?q=1", @@ -128,7 +131,7 @@ class TestGitHubCopilotAuthenticator: {"GITHUB_COPILOT_API_BASE": "https://configured.example.com"}, clear=True, ), - pytest.raises(InvalidAPIBaseError, match="GitHub Copilot api_base must be an HTTPS URL"), + pytest.raises(InvalidAPIBaseError, match="GitHub Copilot api_base must be an HTTP or HTTPS URL"), ): get_copilot_api_base(api_base) @@ -140,33 +143,36 @@ class TestGitHubCopilotAuthenticator: assert get_copilot_api_base(deployment_base) == DEFAULT_GITHUB_COPILOT_API_BASE assert caplog.text == "" - def test_get_api_base_warns_when_only_an_enterprise_token_exchange_url_is_set(self, caplog): + def test_get_api_base_refuses_an_enterprise_token_exchange_url_without_a_copilot_base(self): with ( patch.dict( os.environ, {"GITHUB_COPILOT_API_KEY_URL": "https://api.octo.ghe.com/copilot_internal/v2/token"}, clear=True, ), - caplog.at_level(logging.WARNING, logger="LiteLLM"), + pytest.raises(InvalidAPIBaseError, match="Set GITHUB_COPILOT_API_BASE"), ): - assert get_copilot_api_base() == DEFAULT_GITHUB_COPILOT_API_BASE - assert "GITHUB_COPILOT_API_KEY_URL is no longer used" in caplog.text - assert "Set GITHUB_COPILOT_API_BASE" in caplog.text + get_copilot_api_base() @pytest.mark.parametrize( - "environment", + ("environment", "expected"), ( - {"GITHUB_COPILOT_API_KEY_URL": "https://api.github.com/copilot_internal/v2/token"}, - { - "GITHUB_COPILOT_API_KEY_URL": "https://api.octo.ghe.com/copilot_internal/v2/token", - "GITHUB_COPILOT_API_BASE": "https://copilot-api.octo.ghe.com", - }, + ( + {"GITHUB_COPILOT_API_KEY_URL": "https://api.github.com/copilot_internal/v2/token"}, + DEFAULT_GITHUB_COPILOT_API_BASE, + ), + ( + { + "GITHUB_COPILOT_API_KEY_URL": "https://api.octo.ghe.com/copilot_internal/v2/token", + "GITHUB_COPILOT_API_BASE": "https://copilot-api.octo.ghe.com", + }, + "https://copilot-api.octo.ghe.com", + ), ), ) - def test_get_api_base_does_not_warn_when_the_token_exchange_url_needs_no_action(self, environment, caplog): - with patch.dict(os.environ, environment, clear=True), caplog.at_level(logging.WARNING, logger="LiteLLM"): - get_copilot_api_base() - assert caplog.text == "" + def test_get_api_base_accepts_a_token_exchange_url_that_needs_no_action(self, environment, expected): + with patch.dict(os.environ, environment, clear=True): + assert get_copilot_api_base() == expected def test_get_github_headers(self, authenticator): with patch.dict(os.environ, {}, clear=True): diff --git a/tests/unit/llms/github_copilot/test_per_user_auth.py b/tests/unit/llms/github_copilot/test_per_user_auth.py index 275b32675ae..4a60938ab59 100644 --- a/tests/unit/llms/github_copilot/test_per_user_auth.py +++ b/tests/unit/llms/github_copilot/test_per_user_auth.py @@ -19,6 +19,7 @@ from litellm.exceptions import ( ServiceUnavailableError, ) from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler +from litellm.llms.github_copilot.common_utils import InvalidAPIBaseError from litellm.llms.github_copilot.per_user_auth import ( GITHUB_COPILOT_USER_SESSION_KWARG_KEY, GithubCopilotUserSession, @@ -528,38 +529,59 @@ def test_per_user_request_uses_the_deployment_base_else_the_configured_base( assert lookups == [] -@pytest.mark.parametrize("surface", ["completion", "embedding", "responses"]) -@pytest.mark.parametrize("mode", ["shared", "per_user"]) -def test_gateway_caller_endpoint_never_receives_the_github_token( - per_user_credential, tmp_path, monkeypatch, surface, mode -): - _shared_login(tmp_path, monkeypatch) - per_user = mode == "per_user" - router = litellm.Router( +def _copilot_router(surface, mode, **deployment_params): + return litellm.Router( model_list=[ { "model_name": "copilot", "litellm_params": { "model": _SURFACE_MODELS[surface], "api_base": "https://deployment.example.com/v1", - **({"litellm_credential_name": "copilot-cred"} if per_user else {}), + **({"litellm_credential_name": "copilot-cred"} if mode == "per_user" else {}), + **deployment_params, }, } ] ) + + +def _call_with_caller_endpoint(router, surface, mode): + return _call_surface( + router, + surface, + model="copilot", + api_base="https://caller.example.com/v1", + base_url="https://caller.example.com/v1", + **({"secret_fields": _secret_fields("user-a", "stored-github-token")} if mode == "per_user" else {}), + ) + + +@pytest.mark.parametrize("surface", ["completion", "embedding", "responses"]) +@pytest.mark.parametrize("mode", ["shared", "per_user"]) +def test_caller_endpoint_on_a_copilot_deployment_answers_400(per_user_credential, tmp_path, monkeypatch, surface, mode): + _shared_login(tmp_path, monkeypatch) + router = _copilot_router(surface, mode) + with ( + github_http(_copilot_surface_responder(_surface_payload(surface))) as requests, + pytest.raises(BadRequestError, match="do not accept api_base or base_url from the caller"), + ): + _call_with_caller_endpoint(router, surface, mode) + assert requests == [] + + +@pytest.mark.parametrize("surface", ["completion", "embedding", "responses"]) +@pytest.mark.parametrize("mode", ["shared", "per_user"]) +def test_caller_endpoint_on_a_copilot_deployment_is_dropped_under_drop_params( + per_user_credential, tmp_path, monkeypatch, surface, mode +): + _shared_login(tmp_path, monkeypatch) + router = _copilot_router(surface, mode, drop_params=True) with github_http(_copilot_surface_responder(_surface_payload(surface))) as requests: - _call_surface( - router, - surface, - model="copilot", - api_base="https://attacker.example.com/v1", - base_url="https://attacker.example.com/v1", - **({"secret_fields": _secret_fields("user-a", "stored-github-token")} if per_user else {}), - ) + _call_with_caller_endpoint(router, surface, mode) assert [str(request.url) for request in requests] == [ f"https://deployment.example.com/v1/{_SURFACE_PATHS[surface]}" ] - expected_token = "stored-github-token" if per_user else "shared-copilot-token" + expected_token = "stored-github-token" if mode == "per_user" else "shared-copilot-token" assert requests[0].headers["authorization"] == f"Bearer {expected_token}" @@ -929,9 +951,11 @@ def test_requests_go_to_the_plan_host_github_reports_for_the_token( with github_http( _copilot_surface_responder(_surface_payload(surface)), answer_plan(PLAN_COPILOT_HOST), lookups ) as requests: - for _ in range(2): - _call_surface(litellm, surface, model=_SURFACE_MODELS[surface], **credential_kwargs) + responses = [ + _call_surface(litellm, surface, model=_SURFACE_MODELS[surface], **credential_kwargs) for _ in range(2) + ] _assert_plan_host_served_both_calls(requests, lookups, mode, _SURFACE_PATHS[surface]) + assert [response._hidden_params["api_base"] for response in responses] == [PLAN_COPILOT_HOST] * 2 @pytest.mark.asyncio @@ -988,6 +1012,22 @@ def test_requests_use_the_default_host_when_github_reports_no_usable_plan_host(t assert [str(request.url) for request in requests] == [f"{DEFAULT_COPILOT_HOST}/responses"] +@pytest.mark.parametrize("surface", ["completion", "embedding", "responses"]) +def test_enterprise_token_exchange_url_without_a_copilot_base_sends_nothing(tmp_path, monkeypatch, surface): + _shared_login(tmp_path, monkeypatch) + monkeypatch.setenv("GITHUB_COPILOT_API_KEY_URL", "https://api.octo.ghe.com/copilot_internal/v2/token") + lookups: list[httpx.Request] = [] + with ( + github_http( + _copilot_surface_responder(_surface_payload(surface)), answer_plan(PLAN_COPILOT_HOST), lookups + ) as requests, + pytest.raises(InvalidAPIBaseError, match="Set GITHUB_COPILOT_API_BASE"), + ): + _call_surface(litellm, surface, model=_SURFACE_MODELS[surface]) + assert requests == [] + assert lookups == [] + + def test_provider_resolution_never_looks_up_the_plan_host(tmp_path, monkeypatch): _shared_login(tmp_path, monkeypatch) lookups: list[httpx.Request] = [] From c4b7a55a96cfb2191a3f35e20b0991289f2743f2 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:48:05 -0700 Subject: [PATCH 22/32] fix(router): read drop_params from deployment params objects --- litellm/router.py | 7 ++-- .../clientside_credential_handler.py | 8 +++- tests/unit/test_router/test_router.py | 38 +++++++++++++++++++ 3 files changed, 48 insertions(+), 5 deletions(-) diff --git a/litellm/router.py b/litellm/router.py index 9b7aae46300..484fdf651dd 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -197,6 +197,7 @@ from litellm.router_utils.batch_utils import ( ) from litellm.router_utils.client_initalization_utils import InitalizeCachedClient, MaxParallelRequestsLimit from litellm.router_utils.clientside_credential_handler import ( + DeploymentParamLookup, get_dynamic_litellm_params, github_copilot_caller_endpoint_keys, is_clientside_credential, @@ -4587,12 +4588,12 @@ class Router: - Merges tools from deployment with request (proxy-configured tools + request tools). - On a fallback hop, drops the encrypted reasoning this deployment cannot decrypt, keeping its summary. """ - deployment_litellm_params: Final = cast( # cast-ok: deployment litellm_params is a str-keyed dict - "Mapping[str, object]", deployment["litellm_params"] + deployment_litellm_params: Final = cast( # cast-ok: a dict or LiteLLM_Params, both answer get + "DeploymentParamLookup", deployment["litellm_params"] ) caller_kwargs: Final = cast("Mapping[str, object]", kwargs) # cast-ok: router call kwargs are a str-keyed dict caller_endpoint_keys: Final = github_copilot_caller_endpoint_keys(deployment_litellm_params, caller_kwargs) - call_drop_params: Final = {**deployment_litellm_params, **caller_kwargs}.get("drop_params") + call_drop_params: Final = caller_kwargs.get("drop_params", deployment_litellm_params.get("drop_params")) if caller_endpoint_keys and not drops_unsupported_params(call_drop_params): raise litellm.BadRequestError( message=( diff --git a/litellm/router_utils/clientside_credential_handler.py b/litellm/router_utils/clientside_credential_handler.py index d4f21a3a6f0..f898e13507a 100644 --- a/litellm/router_utils/clientside_credential_handler.py +++ b/litellm/router_utils/clientside_credential_handler.py @@ -12,7 +12,7 @@ Ensures cooldowns are applied correctly. """ from collections.abc import Mapping, MutableMapping -from typing import Final, cast # noqa: TID251 # narrows the untyped request dict for mutation +from typing import Final, Protocol, cast # noqa: TID251 # narrows the untyped request dict for mutation from litellm.types.utils import server_owned_wif_litellm_params @@ -82,8 +82,12 @@ def _admin_config_fields_to_clear_on_base_override() -> list[str]: _ADMIN_CONFIG_FIELDS_TO_CLEAR_ON_BASE_OVERRIDE: Final = _admin_config_fields_to_clear_on_base_override() +class DeploymentParamLookup(Protocol): + def get(self, key: str, /) -> object: ... + + def github_copilot_caller_endpoint_keys( - deployment_litellm_params: Mapping[str, object], request_kwargs: Mapping[str, object] + deployment_litellm_params: DeploymentParamLookup, request_kwargs: Mapping[str, object] ) -> tuple[str, ...]: model: Final = deployment_litellm_params.get("model") is_github_copilot: Final = deployment_litellm_params.get("custom_llm_provider") == "github_copilot" or ( diff --git a/tests/unit/test_router/test_router.py b/tests/unit/test_router/test_router.py index 2dc34a46823..f923b3ef133 100644 --- a/tests/unit/test_router/test_router.py +++ b/tests/unit/test_router/test_router.py @@ -9594,6 +9594,44 @@ def test_update_kwargs_with_deployment_no_tags(): assert "tags" not in kwargs["metadata"] +def _copilot_deployment_object(token_dir, monkeypatch, **litellm_params): + (token_dir / "access-token").write_text("shared-copilot-token") + monkeypatch.setenv("GITHUB_COPILOT_TOKEN_DIR", str(token_dir)) + router = litellm.Router( + model_list=[ + { + "model_name": "copilot", + "litellm_params": { + "model": "github_copilot/gpt-4.1", + "api_base": "https://deployment.example.com/v1", + **litellm_params, + }, + } + ] + ) + return router, router.get_deployment_by_model_group_name(model_group_name="copilot") + + +def test_update_kwargs_with_deployment_drops_caller_api_base_for_a_copilot_deployment_object_with_drop_params( + tmp_path, monkeypatch +): + router, deployment = _copilot_deployment_object(tmp_path, monkeypatch, drop_params=True) + kwargs: dict = {"metadata": {}, "api_base": "https://caller.example.com"} + + router._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs) + + assert "api_base" not in kwargs + + +def test_update_kwargs_with_deployment_refuses_caller_api_base_for_a_copilot_deployment_object(tmp_path, monkeypatch): + router, deployment = _copilot_deployment_object(tmp_path, monkeypatch) + + with pytest.raises(litellm.BadRequestError, match="do not accept api_base from the caller"): + router._update_kwargs_with_deployment( + deployment=deployment, kwargs={"metadata": {}, "api_base": "https://caller.example.com"} + ) + + @pytest.mark.asyncio async def test_retry_does_not_narrow_tag_filtered_group_to_failed_deployments_tags(): router = Router( From 920c7c3ce27d3bed4fbe92100df55ba18946ab81 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 20 Oct 2026 02:06:02 -0700 Subject: [PATCH 23/32] fix(github-copilot): share one plan host lookup per token and retry malformed answers --- litellm/llms/github_copilot/common_utils.py | 29 ++++++++++++- .../llms/github_copilot/test_per_user_auth.py | 42 +++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/litellm/llms/github_copilot/common_utils.py b/litellm/llms/github_copilot/common_utils.py index 78a62094806..b09e9147984 100644 --- a/litellm/llms/github_copilot/common_utils.py +++ b/litellm/llms/github_copilot/common_utils.py @@ -2,9 +2,11 @@ Constants for Copilot integration """ +import asyncio import hashlib import os from collections.abc import Callable, Mapping, MutableMapping +from functools import partial from typing import ( Final, Protocol, @@ -89,6 +91,9 @@ _PLAN_API_BASES: Final = cast( # cast-ok: pins the untyped module cache to _Pla _PlanApiBaseCache, InMemoryCache(max_size_in_memory=1000, default_ttl=_PLAN_API_BASE_TTL_SECONDS), ) +_PLAN_API_BASE_LOOKUPS: Final[ # mutable-ok: single-flight registry of in-progress plan host lookups per token + dict[str, "asyncio.Task[None]"] +] = {} class GithubCopilotError(BaseLLMException): @@ -233,7 +238,7 @@ def _plan_api_base_from(response: httpx.Response) -> tuple[str, int]: try: endpoints: Final = _COPILOT_USER.validate_python(response.json()).get("endpoints") except ValueError: - return DEFAULT_GITHUB_COPILOT_API_BASE, _PLAN_API_BASE_TTL_SECONDS + return DEFAULT_GITHUB_COPILOT_API_BASE, _PLAN_API_BASE_RETRY_SECONDS endpoints_api: Final = endpoints.get("api") if endpoints is not None else None return _trusted_plan_api_base(endpoints_api) or DEFAULT_GITHUB_COPILOT_API_BASE, _PLAN_API_BASE_TTL_SECONDS @@ -292,9 +297,29 @@ def prefetch_copilot_plan_api_base(github_token: str, api_base: str | None = Non _remember_plan_api_base(_plan_api_base_key(github_token), _lookup_plan_api_base(github_token)) +async def _alookup_and_remember_plan_api_base(key: str, github_token: str) -> None: + _remember_plan_api_base(key, await _alookup_plan_api_base(github_token)) + + +def _forget_plan_api_base_lookup(key: str, lookup: "asyncio.Task[None]") -> None: + if _PLAN_API_BASE_LOOKUPS.get(key) is lookup: + _PLAN_API_BASE_LOOKUPS.pop(key, None) + + +def _plan_api_base_lookup(key: str, github_token: str) -> "asyncio.Task[None]": + loop: Final = asyncio.get_running_loop() + in_flight: Final = _PLAN_API_BASE_LOOKUPS.get(key) + if in_flight is not None and in_flight.get_loop() is loop: + return in_flight + lookup: Final = loop.create_task(_alookup_and_remember_plan_api_base(key, github_token)) + _PLAN_API_BASE_LOOKUPS[key] = lookup + lookup.add_done_callback(partial(_forget_plan_api_base_lookup, key)) + return lookup + + async def aprefetch_copilot_plan_api_base(github_token: str, api_base: str | None = None) -> None: if _plan_api_base_is_needed(github_token, api_base): - _remember_plan_api_base(_plan_api_base_key(github_token), await _alookup_plan_api_base(github_token)) + await asyncio.shield(_plan_api_base_lookup(_plan_api_base_key(github_token), github_token)) def get_copilot_api_base(api_base: str | None = None, github_token: Callable[[], str | None] | None = None) -> str: diff --git a/tests/unit/llms/github_copilot/test_per_user_auth.py b/tests/unit/llms/github_copilot/test_per_user_auth.py index 4a60938ab59..c664fe148fe 100644 --- a/tests/unit/llms/github_copilot/test_per_user_auth.py +++ b/tests/unit/llms/github_copilot/test_per_user_auth.py @@ -1,6 +1,8 @@ """Unit tests for per-user GitHub Copilot OAuth connections (per_user_auth).""" +import asyncio import datetime +import time from collections.abc import Callable from contextlib import contextmanager from typing import Final @@ -978,6 +980,46 @@ async def test_messages_go_to_the_plan_host_github_reports_for_the_token( _assert_plan_host_served_both_calls(requests, lookups, mode, "v1/messages") +@pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["shared", "per_user"]) +async def test_concurrent_requests_on_a_cold_cache_share_one_plan_lookup( + per_user_credential, tmp_path, monkeypatch, mode +): + credential_kwargs = _credential_kwargs_for(mode, tmp_path, monkeypatch) + lookups: list[httpx.Request] = [] + + async def answer_plan_slowly(request: httpx.Request) -> httpx.Response: + await asyncio.sleep(0.05) + return answer_plan(PLAN_COPILOT_HOST)(request) + + with github_http(_copilot_surface_responder(_responses_payload()), answer_plan_slowly, lookups) as requests: + await asyncio.gather( + *(litellm.aresponses(model=_SURFACE_MODELS["responses"], input="hi", **credential_kwargs) for _ in range(5)) + ) + assert len(lookups) == 1 + assert [str(request.url) for request in requests] == [f"{PLAN_COPILOT_HOST}/responses"] * 5 + + +@pytest.mark.parametrize( + ("plan_lookup", "lookups_after_a_minute"), + [ + (lambda request: httpx.Response(200, text="not json", request=request), 2), + (answer_plan(PLAN_COPILOT_HOST, status_code=500), 2), + (answer_plan(PLAN_COPILOT_HOST), 1), + ], + ids=["malformed_body_is_retried", "server_error_is_retried", "plan_host_is_kept"], +) +def test_a_failed_plan_lookup_is_retried_a_minute_later(tmp_path, monkeypatch, plan_lookup, lookups_after_a_minute): + _shared_login(tmp_path, monkeypatch) + lookups: list[httpx.Request] = [] + started: Final = time.time() + with github_http(_copilot_surface_responder(_responses_payload()), plan_lookup, lookups): + litellm.responses(model="github_copilot/gpt-5.3-codex", input="hi") + monkeypatch.setattr(time, "time", lambda: started + 61) + litellm.responses(model="github_copilot/gpt-5.3-codex", input="hi") + assert len(lookups) == lookups_after_a_minute + + def _refuse_connection(request: httpx.Request) -> httpx.Response: raise httpx.ConnectError("connection refused", request=request) From cb1112ae274d55d73284e0dc4c145b134fb319dd Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 20 Oct 2026 02:59:58 -0700 Subject: [PATCH 24/32] test(github-copilot): read a stored token file instead of mocking the authenticator, annotate header tables as Final --- litellm/llms/github_copilot/common_utils.py | 6 +-- ...github_copilot_responses_transformation.py | 40 +++++++++---------- 2 files changed, 21 insertions(+), 25 deletions(-) diff --git a/litellm/llms/github_copilot/common_utils.py b/litellm/llms/github_copilot/common_utils.py index b09e9147984..3cd08793eb3 100644 --- a/litellm/llms/github_copilot/common_utils.py +++ b/litellm/llms/github_copilot/common_utils.py @@ -37,7 +37,7 @@ 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_AUTH_HEADER_CONFIG = ( +_COPILOT_AUTH_HEADER_CONFIG: Final = ( ("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), @@ -45,7 +45,7 @@ _COPILOT_AUTH_HEADER_CONFIG = ( ("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 + ( +_COPILOT_REQUEST_HEADER_CONFIG: Final = _COPILOT_AUTH_HEADER_CONFIG + ( ("openai-intent", "GITHUB_COPILOT_OPENAI_INTENT", "conversation-panel"), ("x-github-api-version", "GITHUB_COPILOT_API_VERSION", API_VERSION), ( @@ -133,7 +133,7 @@ class InvalidAPIBaseError(GithubCopilotError): def _get_copilot_header_value(environment_variable: str, default: str | None) -> str | None: - value = os.getenv(environment_variable) + value: Final = os.getenv(environment_variable) if value is None: return default return value or None diff --git a/tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py b/tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py index 13cc778e294..a1e2adb1be6 100644 --- a/tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py +++ b/tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py @@ -26,6 +26,16 @@ from litellm.types.utils import LlmProviders from litellm.utils import ProviderConfigManager +STORED_ACCESS_TOKEN = "stored-oauth-access-token" + + +@pytest.fixture +def stored_access_token(tmp_path, monkeypatch): + monkeypatch.setenv("GITHUB_COPILOT_TOKEN_DIR", str(tmp_path)) + monkeypatch.delenv("GITHUB_COPILOT_ACCESS_TOKEN_FILE", raising=False) + (tmp_path / "access-token").write_text(STORED_ACCESS_TOKEN) + + @pytest.fixture(autouse=True) def use_local_model_cost_map(monkeypatch: pytest.MonkeyPatch): """Pin litellm.model_cost to the bundled local backup so tests don't depend @@ -97,8 +107,8 @@ class TestGithubCopilotResponsesAPITransformation: assert url == f"{DEFAULT_GITHUB_COPILOT_API_BASE}/responses" - @patch("litellm.llms.github_copilot.responses.transformation.Authenticator") - def test_validate_environment_default_headers(self, mock_authenticator_class, monkeypatch): + @pytest.mark.usefixtures("stored_access_token") + def test_validate_environment_default_headers(self, monkeypatch): for environment_variable in ( "GITHUB_COPILOT_OPENAI_INTENT", "GITHUB_COPILOT_API_VERSION", @@ -106,15 +116,11 @@ class TestGithubCopilotResponsesAPITransformation: ): 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={}) next_headers = config.validate_environment(headers={}, model="gpt-5.1-codex", litellm_params={}) - assert headers["Authorization"] == "Bearer test-api-key-123" + assert headers["Authorization"] == f"Bearer {STORED_ACCESS_TOKEN}" assert headers["accept"] == "application/json" assert headers["content-type"] == "application/json" assert headers["copilot-integration-id"] == "vscode-chat" @@ -127,13 +133,8 @@ class TestGithubCopilotResponsesAPITransformation: assert str(uuid.UUID(headers["x-request-id"])) == headers["x-request-id"] assert next_headers["x-request-id"] != headers["x-request-id"] - @patch( # test-quality-ok: matches the Authenticator-mocking convention already used throughout this file - "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 + @pytest.mark.usefixtures("stored_access_token") + def test_validate_environment_headers_from_environment(self): environment = { "GITHUB_COPILOT_ACCEPT": "application/vnd.github+json", "GITHUB_COPILOT_CONTENT_TYPE": "application/custom+json", @@ -151,7 +152,7 @@ class TestGithubCopilotResponsesAPITransformation: headers={}, model="gpt-5.1-codex", litellm_params={} ) - assert headers["Authorization"] == "Bearer test-api-key-123" + assert headers["Authorization"] == f"Bearer {STORED_ACCESS_TOKEN}" assert headers["accept"] == "application/vnd.github+json" assert headers["content-type"] == "application/custom+json" assert headers["copilot-integration-id"] == "custom-integration" @@ -162,13 +163,8 @@ class TestGithubCopilotResponsesAPITransformation: assert headers["x-github-api-version"] == "2099-01-01" assert headers["x-vscode-user-agent-library-version"] == "custom-library" - @patch( # test-quality-ok: matches the Authenticator-mocking convention already used throughout this file - "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 + @pytest.mark.usefixtures("stored_access_token") + def test_empty_environment_header_omits_default(self): with patch.dict(os.environ, {"GITHUB_COPILOT_USER_AGENT": ""}): headers = GithubCopilotResponsesAPIConfig().validate_environment( From 478dc568276f0b75b776d96d66dd84fe9cf4054c Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:12:08 -0700 Subject: [PATCH 25/32] fix(github-copilot): send per-user calls only to the user's plan host and honor router drop_params Per-user calls resolve their base from GITHUB_COPILOT_API_BASE or the connected user's plan host, as on main, and never from the deployment api_base. The caller endpoint guard also reads drop_params from the router's default_litellm_params --- .../get_llm_provider_logic.py | 4 +- .../embedding/transformation.py | 8 +--- .../github_copilot/messages/transformation.py | 10 ++--- litellm/llms/github_copilot/per_user_auth.py | 11 ++++- .../responses/transformation.py | 8 +--- litellm/router.py | 16 +++++++- ...github_copilot_embedding_transformation.py | 11 ++--- ..._github_copilot_messages_transformation.py | 3 +- ...github_copilot_responses_transformation.py | 13 +++--- .../llms/github_copilot/test_per_user_auth.py | 41 +++++++++---------- 10 files changed, 67 insertions(+), 58 deletions(-) diff --git a/litellm/litellm_core_utils/get_llm_provider_logic.py b/litellm/litellm_core_utils/get_llm_provider_logic.py index d8438170cfd..1612d762190 100644 --- a/litellm/litellm_core_utils/get_llm_provider_logic.py +++ b/litellm/litellm_core_utils/get_llm_provider_logic.py @@ -817,7 +817,7 @@ def _get_openai_compatible_provider_info( cast("dict[str, object]", litellm_params) # cast-ok: litellm_params arrives as an untyped request dict ) if user_session is not None: - api_base = get_copilot_api_base(api_base, lambda: user_session.token) # rebind-ok: provider args + api_base = get_copilot_api_base(None, lambda: user_session.token) # rebind-ok: provider args dynamic_api_key = user_session.token # rebind-ok: resolves provider args in place elif ( github_copilot_per_user_credential_name( @@ -827,7 +827,7 @@ def _get_openai_compatible_provider_info( ): # per-user deployments resolve no token at provider-info time; the # caller's session supplies the key per request - api_base = get_copilot_api_base(api_base) # rebind-ok: resolves provider args in place + api_base = get_copilot_api_base(None) # rebind-ok: resolves provider args in place dynamic_api_key = None # rebind-ok: resolves provider args in place else: ( diff --git a/litellm/llms/github_copilot/embedding/transformation.py b/litellm/llms/github_copilot/embedding/transformation.py index 762e613e1ba..4cbbd5954de 100644 --- a/litellm/llms/github_copilot/embedding/transformation.py +++ b/litellm/llms/github_copilot/embedding/transformation.py @@ -28,11 +28,10 @@ from litellm.utils import convert_to_model_response_object from ..authenticator import Authenticator from ..common_utils import ( GetAPIKeyError, - get_copilot_api_base, get_copilot_default_headers, pin_session_authorization, ) -from ..per_user_auth import require_github_copilot_user_session +from ..per_user_auth import require_github_copilot_user_session, resolve_copilot_api_base if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj @@ -120,10 +119,7 @@ class GithubCopilotEmbeddingConfig(BaseEmbeddingConfig): user_session: Final = require_github_copilot_user_session( cast("dict[str, object]", litellm_params) # cast-ok: litellm_params arrives as an untyped request dict ) - resolved: Final = get_copilot_api_base( - api_base, - lambda: user_session.token if user_session is not None else self.authenticator.get_api_key(), - ) + resolved: Final = resolve_copilot_api_base(api_base, user_session, self.authenticator.get_api_key) return f"{resolved.rstrip('/')}/embeddings" def transform_embedding_request( diff --git a/litellm/llms/github_copilot/messages/transformation.py b/litellm/llms/github_copilot/messages/transformation.py index 86f4ab5f72f..c3bb9d5ecb8 100644 --- a/litellm/llms/github_copilot/messages/transformation.py +++ b/litellm/llms/github_copilot/messages/transformation.py @@ -8,11 +8,10 @@ from litellm.llms.anthropic.pass_through.messages.transformation import ( from ..authenticator import Authenticator from ..common_utils import ( GetAPIKeyError, - get_copilot_api_base, get_copilot_default_headers, pin_session_authorization, ) -from ..per_user_auth import require_github_copilot_user_session +from ..per_user_auth import require_github_copilot_user_session, resolve_copilot_api_base _MESSAGES_PROXY_API_VERSION: Final = "2026-06-01" @@ -75,7 +74,7 @@ class GithubCopilotAnthropicMessagesConfig(AnthropicMessagesConfig): llm_provider="github_copilot", message=str(e), ) - dynamic_api_base: Final = get_copilot_api_base(api_base, lambda: dynamic_api_key).rstrip("/") + dynamic_api_base: Final = resolve_copilot_api_base(api_base, user_session, lambda: dynamic_api_key).rstrip("/") # Merge Copilot headers with provided headers copilot_headers: Final = get_copilot_default_headers(dynamic_api_key) @@ -116,8 +115,5 @@ class GithubCopilotAnthropicMessagesConfig(AnthropicMessagesConfig): user_session: Final = require_github_copilot_user_session( cast("dict[str, object]", litellm_params) # cast-ok: litellm_params arrives as an untyped request dict ) - resolved: Final = get_copilot_api_base( - api_base, - lambda: user_session.token if user_session is not None else self.authenticator.get_api_key(), - ).rstrip("/") + resolved: Final = resolve_copilot_api_base(api_base, user_session, self.authenticator.get_api_key).rstrip("/") return resolved if resolved.endswith("/v1/messages") else f"{resolved}/v1/messages" diff --git a/litellm/llms/github_copilot/per_user_auth.py b/litellm/llms/github_copilot/per_user_auth.py index d9fa9675380..c1b514c4454 100644 --- a/litellm/llms/github_copilot/per_user_auth.py +++ b/litellm/llms/github_copilot/per_user_auth.py @@ -40,6 +40,7 @@ from .authenticator import ( from .common_utils import ( aget_copilot_api_base, aprefetch_copilot_plan_api_base, + get_copilot_api_base, get_copilot_auth_headers, get_copilot_default_headers, prefetch_copilot_plan_api_base, @@ -420,6 +421,14 @@ def require_github_copilot_user_session(litellm_params: object) -> GithubCopilot return None +def resolve_copilot_api_base( + api_base: str | None, user_session: GithubCopilotUserSession | None, shared_token: Callable[[], str | None] +) -> str: + if user_session is None: + return get_copilot_api_base(api_base, shared_token) + return get_copilot_api_base(None, lambda: user_session.token) + + def github_copilot_caller_rejection( original_exception: object, request_kwargs: object ) -> CallerCredentialAuthenticationError | None: @@ -515,7 +524,7 @@ def _plan_api_base_request(kwargs: Mapping[str, object], model: object) -> tuple api_base: Final = api_base_obj if isinstance(api_base_obj, str) else None session: Final = kwargs.get(GITHUB_COPILOT_USER_SESSION_KWARG_KEY) if isinstance(session, GithubCopilotUserSession): - return session.token, api_base + return session.token, None targets_github_copilot: Final = kwargs.get("custom_llm_provider") == _LLM_PROVIDER or ( isinstance(model, str) and model.startswith(f"{_LLM_PROVIDER}/") ) diff --git a/litellm/llms/github_copilot/responses/transformation.py b/litellm/llms/github_copilot/responses/transformation.py index f671e5ee346..c1e406b7e1c 100644 --- a/litellm/llms/github_copilot/responses/transformation.py +++ b/litellm/llms/github_copilot/responses/transformation.py @@ -32,11 +32,10 @@ from litellm.utils import cached_get_model_info_helper from ..authenticator import Authenticator from ..common_utils import ( GetAPIKeyError, - get_copilot_api_base, get_copilot_default_headers, pin_session_authorization, ) -from ..per_user_auth import require_github_copilot_user_session +from ..per_user_auth import require_github_copilot_user_session, resolve_copilot_api_base if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj @@ -265,10 +264,7 @@ class GithubCopilotResponsesAPIConfig(OpenAIResponsesAPIConfig): user_session: Final = require_github_copilot_user_session( cast("dict[str, object]", litellm_params) # cast-ok: litellm_params arrives as an untyped request dict ) - resolved: Final = get_copilot_api_base( - api_base, - lambda: user_session.token if user_session is not None else self.authenticator.get_api_key(), - ) + resolved: Final = resolve_copilot_api_base(api_base, user_session, self.authenticator.get_api_key) return f"{resolved.rstrip('/')}/responses" def _handle_reasoning_item(self, item: dict[str, object]) -> dict[str, object]: diff --git a/litellm/router.py b/litellm/router.py index 484fdf651dd..bdd3813eb57 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -4593,7 +4593,21 @@ class Router: ) caller_kwargs: Final = cast("Mapping[str, object]", kwargs) # cast-ok: router call kwargs are a str-keyed dict caller_endpoint_keys: Final = github_copilot_caller_endpoint_keys(deployment_litellm_params, caller_kwargs) - call_drop_params: Final = caller_kwargs.get("drop_params", deployment_litellm_params.get("drop_params")) + router_defaults: Final = cast( # cast-ok: router default params are a str-keyed dict + "Mapping[str, object]", self.default_litellm_params + ) + call_drop_params: Final = next( + ( + value + for value in ( + caller_kwargs.get("drop_params"), + deployment_litellm_params.get("drop_params"), + router_defaults.get("drop_params"), + ) + if value is not None + ), + None, + ) if caller_endpoint_keys and not drops_unsupported_params(call_drop_params): raise litellm.BadRequestError( message=( diff --git a/tests/unit/llms/github_copilot/embedding/test_github_copilot_embedding_transformation.py b/tests/unit/llms/github_copilot/embedding/test_github_copilot_embedding_transformation.py index 99cae29be13..7b91a64ece2 100644 --- a/tests/unit/llms/github_copilot/embedding/test_github_copilot_embedding_transformation.py +++ b/tests/unit/llms/github_copilot/embedding/test_github_copilot_embedding_transformation.py @@ -316,12 +316,9 @@ def test_per_user_session_token_wins_over_caller_authorization(authorization_hea assert config.authenticator.mock_calls == [] -@pytest.mark.parametrize( - ("deployment_base", "expected_base"), - [(None, "http://localhost:8080"), ("https://deployment.example.test/", "https://deployment.example.test")], -) -def test_get_complete_url_uses_the_deployment_base_with_per_user_session( - monkeypatch: pytest.MonkeyPatch, deployment_base: str | None, expected_base: str +@pytest.mark.parametrize("deployment_base", [None, "https://deployment.example.test/"]) +def test_get_complete_url_with_per_user_session_ignores_the_deployment_base( + monkeypatch: pytest.MonkeyPatch, deployment_base: str | None ) -> None: from litellm.llms.github_copilot.per_user_auth import GithubCopilotUserSession @@ -337,5 +334,5 @@ def test_get_complete_url_uses_the_deployment_base_with_per_user_session( litellm_params={"github_copilot_user_session": session}, ) - assert url == f"{expected_base}/embeddings" + assert url == "http://localhost:8080/embeddings" assert config.authenticator.mock_calls == [] diff --git a/tests/unit/llms/github_copilot/messages/test_github_copilot_messages_transformation.py b/tests/unit/llms/github_copilot/messages/test_github_copilot_messages_transformation.py index 1d147bfc8c3..1a1c9271f52 100644 --- a/tests/unit/llms/github_copilot/messages/test_github_copilot_messages_transformation.py +++ b/tests/unit/llms/github_copilot/messages/test_github_copilot_messages_transformation.py @@ -353,7 +353,8 @@ def test_github_copilot_messages_config_probes_capabilities_under_copilot_namesp [ (None, None, DEFAULT_GITHUB_COPILOT_API_BASE), (None, "http://localhost:8080/", "http://localhost:8080"), - ("https://deployment.example.test", "http://localhost:8080/", "https://deployment.example.test"), + ("https://deployment.example.test", "http://localhost:8080/", "http://localhost:8080"), + ("https://deployment.example.test", None, DEFAULT_GITHUB_COPILOT_API_BASE), ], ) def test_validate_environment_uses_per_user_session_and_skips_authenticator( diff --git a/tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py b/tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py index a1e2adb1be6..cc5d7aac2cd 100644 --- a/tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py +++ b/tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py @@ -64,11 +64,11 @@ class TestGithubCopilotResponsesAPITransformation: assert config.custom_llm_provider == LlmProviders.GITHUB_COPILOT, "custom_llm_provider should be GITHUB_COPILOT" @pytest.mark.parametrize( - ("api_base", "environment_base", "expected_base"), + ("api_base", "environment_base", "shared_base", "per_user_base"), ( - (None, "https://copilot-env.example", "https://copilot-env.example"), - ("http://localhost:8080", "https://copilot-env.example", "http://localhost:8080"), - ("https://copilot-deployment.example/", None, "https://copilot-deployment.example"), + (None, "https://copilot-env.example", "https://copilot-env.example", "https://copilot-env.example"), + ("http://localhost:8080", "https://copilot-env.example", "http://localhost:8080", "https://copilot-env.example"), + ("https://copilot-deployment.example/", None, "https://copilot-deployment.example", DEFAULT_GITHUB_COPILOT_API_BASE), ), ) @pytest.mark.parametrize("per_user", (False, True)) @@ -76,7 +76,8 @@ class TestGithubCopilotResponsesAPITransformation: self, api_base: str | None, environment_base: str | None, - expected_base: str, + shared_base: str, + per_user_base: str, per_user: bool, monkeypatch: pytest.MonkeyPatch, ): @@ -94,7 +95,7 @@ class TestGithubCopilotResponsesAPITransformation: else {} ), ) - assert url == f"{expected_base}/responses" + assert url == f"{per_user_base if per_user else shared_base}/responses" config.authenticator.get_api_key.assert_not_called() def test_github_copilot_responses_url_falls_back_to_the_default_base_without_a_known_plan_host(self, monkeypatch): diff --git a/tests/unit/llms/github_copilot/test_per_user_auth.py b/tests/unit/llms/github_copilot/test_per_user_auth.py index c664fe148fe..2f97762c1fd 100644 --- a/tests/unit/llms/github_copilot/test_per_user_auth.py +++ b/tests/unit/llms/github_copilot/test_per_user_auth.py @@ -502,12 +502,9 @@ def _call_surface(target, surface, **kwargs): @pytest.mark.parametrize("surface", ["completion", "embedding", "responses"]) -@pytest.mark.parametrize( - ("deployment_base", "expected_base"), - [(None, "https://configured.example.com/v1"), ("https://deployment.example.com/v1", "https://deployment.example.com/v1")], -) -def test_per_user_request_uses_the_deployment_base_else_the_configured_base( - per_user_credential, monkeypatch, surface, deployment_base, expected_base +@pytest.mark.parametrize("deployment_base", [None, "https://deployment.example.com/v1"]) +def test_per_user_request_goes_to_the_configured_base_never_the_deployment_base( + per_user_credential, monkeypatch, surface, deployment_base ): monkeypatch.setenv("GITHUB_COPILOT_API_BASE", "https://configured.example.com/v1") lookups: list[httpx.Request] = [] @@ -526,13 +523,14 @@ def test_per_user_request_uses_the_deployment_base_else_the_configured_base( extra_headers=dict(_EVIL_AUTH_HEADERS), api_base=deployment_base, ) - assert [str(request.url) for request in requests] == [f"{expected_base}/{_SURFACE_PATHS[surface]}"] + assert [str(request.url) for request in requests] == [f"https://configured.example.com/v1/{_SURFACE_PATHS[surface]}"] assert requests[0].headers["authorization"] == "Bearer stored-github-token" assert lookups == [] -def _copilot_router(surface, mode, **deployment_params): +def _copilot_router(surface, mode, default_litellm_params=None, **deployment_params): return litellm.Router( + default_litellm_params=default_litellm_params, model_list=[ { "model_name": "copilot", @@ -573,16 +571,20 @@ def test_caller_endpoint_on_a_copilot_deployment_answers_400(per_user_credential @pytest.mark.parametrize("surface", ["completion", "embedding", "responses"]) @pytest.mark.parametrize("mode", ["shared", "per_user"]) +@pytest.mark.parametrize( + "router_settings", + [{"drop_params": True}, {"default_litellm_params": {"drop_params": True}}], + ids=["deployment_drop_params", "router_default_drop_params"], +) def test_caller_endpoint_on_a_copilot_deployment_is_dropped_under_drop_params( - per_user_credential, tmp_path, monkeypatch, surface, mode + per_user_credential, tmp_path, monkeypatch, surface, mode, router_settings ): _shared_login(tmp_path, monkeypatch) - router = _copilot_router(surface, mode, drop_params=True) - with github_http(_copilot_surface_responder(_surface_payload(surface))) as requests: + router = _copilot_router(surface, mode, **router_settings) + with github_http(_copilot_surface_responder(_surface_payload(surface)), answer_plan(PLAN_COPILOT_HOST)) as requests: _call_with_caller_endpoint(router, surface, mode) - assert [str(request.url) for request in requests] == [ - f"https://deployment.example.com/v1/{_SURFACE_PATHS[surface]}" - ] + expected_base = PLAN_COPILOT_HOST if mode == "per_user" else "https://deployment.example.com/v1" + assert [str(request.url) for request in requests] == [f"{expected_base}/{_SURFACE_PATHS[surface]}"] expected_token = "stored-github-token" if mode == "per_user" else "shared-copilot-token" assert requests[0].headers["authorization"] == f"Bearer {expected_token}" @@ -1102,12 +1104,9 @@ def test_router_constructs_with_per_user_credential_deployment(per_user_credenti assert any(d["model_name"] == "copilot" for d in deployments) -@pytest.mark.parametrize( - ("deployment_base", "expected_base"), - [(None, "https://api.githubcopilot.com"), ("https://deployment.example.com", "https://deployment.example.com")], -) -def test_provider_info_per_user_without_session_returns_the_deployment_base_and_no_key( - per_user_credential, deployment_base, expected_base +@pytest.mark.parametrize("deployment_base", [None, "https://deployment.example.com"]) +def test_provider_info_per_user_without_session_returns_the_copilot_base_and_no_key( + per_user_credential, deployment_base ): from litellm.llms.github_copilot.authenticator import Authenticator from litellm.types.router import LiteLLM_Params @@ -1121,7 +1120,7 @@ def test_provider_info_per_user_without_session_returns_the_deployment_base_and_ model="github_copilot/gpt-4.1", api_base=deployment_base, litellm_params=params ) get_key.assert_not_called() - assert api_base == expected_base + assert api_base == "https://api.githubcopilot.com" assert dynamic_api_key is None From 0b5b365a09efec01bbfeedc767361ef5719e108c Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 20 Oct 2026 05:20:20 -0700 Subject: [PATCH 26/32] fix(github-copilot): send each client header once on chat completions The OpenAI SDK adds its own Accept, Content-Type and User-Agent, so the lowercase Copilot defaults went out next to them. Chat now hands the SDK those names in its casing, so a configured GITHUB_COPILOT_USER_AGENT replaces the SDK's and Accept goes out once. Test constants get Final. --- litellm/llms/github_copilot/common_utils.py | 12 +++++++ litellm/main.py | 3 +- ...github_copilot_responses_transformation.py | 3 +- .../llms/github_copilot/test_per_user_auth.py | 34 ++++++++++++++++--- .../credential_endpoints/test_endpoints.py | 6 ++-- 5 files changed, 49 insertions(+), 9 deletions(-) diff --git a/litellm/llms/github_copilot/common_utils.py b/litellm/llms/github_copilot/common_utils.py index 3cd08793eb3..9075b0c5081 100644 --- a/litellm/llms/github_copilot/common_utils.py +++ b/litellm/llms/github_copilot/common_utils.py @@ -7,6 +7,7 @@ import hashlib import os from collections.abc import Callable, Mapping, MutableMapping from functools import partial +from types import MappingProxyType from typing import ( Final, Protocol, @@ -166,6 +167,17 @@ def get_copilot_default_headers( } +_OPENAI_SDK_HEADER_NAMES: Final = MappingProxyType( + {"accept": "Accept", "content-type": "Content-Type", "user-agent": "User-Agent"} +) + + +def with_openai_sdk_header_names( + headers: Mapping[str, str], +) -> dict[str, str]: # mutable-ok: becomes the chat request's extra_headers, which the SDK merges as a dict + return {_OPENAI_SDK_HEADER_NAMES.get(name.lower(), name): value for name, value in headers.items()} + + def _hostname(url: str) -> str | None: try: return urlsplit(url).hostname diff --git a/litellm/main.py b/litellm/main.py index 38591441e01..5ca354abfed 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -2656,6 +2656,7 @@ def _complete_custom_openai( from litellm.llms.github_copilot.common_utils import ( get_copilot_default_headers, pin_session_authorization, + with_openai_sdk_header_names, ) from litellm.llms.github_copilot.per_user_auth import ( require_github_copilot_user_session, @@ -2671,7 +2672,7 @@ def _complete_custom_openai( ) if user_session is not None: pin_session_authorization(copilot_headers, user_session.token) - extra_headers = copilot_headers + extra_headers = with_openai_sdk_header_names(copilot_headers) use_base_llm_http_handler: Final = get_secret_bool("EXPERIMENTAL_OPENAI_BASE_LLM_HTTP_HANDLER") diff --git a/tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py b/tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py index cc5d7aac2cd..1a86f4f5c11 100644 --- a/tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py +++ b/tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py @@ -7,6 +7,7 @@ transformations for the Responses API. Source: litellm/llms/github_copilot/responses/transformation.py """ +from typing import Final import os import uuid from unittest.mock import MagicMock, patch @@ -26,7 +27,7 @@ from litellm.types.utils import LlmProviders from litellm.utils import ProviderConfigManager -STORED_ACCESS_TOKEN = "stored-oauth-access-token" +STORED_ACCESS_TOKEN: Final = "stored-oauth-access-token" @pytest.fixture diff --git a/tests/unit/llms/github_copilot/test_per_user_auth.py b/tests/unit/llms/github_copilot/test_per_user_auth.py index 2f97762c1fd..d6ff45cdd32 100644 --- a/tests/unit/llms/github_copilot/test_per_user_auth.py +++ b/tests/unit/llms/github_copilot/test_per_user_auth.py @@ -44,9 +44,9 @@ def _credential(auth_type="per_user_oauth"): return CredentialItem(credential_name="copilot-cred", credential_values=values, credential_info={}) -COPILOT_USER_URL = "https://api.github.com/copilot_internal/user" -DEFAULT_COPILOT_HOST = "https://api.githubcopilot.com" -PLAN_COPILOT_HOST = "https://api.business.githubcopilot.com" +COPILOT_USER_URL: Final = "https://api.github.com/copilot_internal/user" +DEFAULT_COPILOT_HOST: Final = "https://api.githubcopilot.com" +PLAN_COPILOT_HOST: Final = "https://api.business.githubcopilot.com" def answer_plan(endpoints_api: object, status_code: int = 200) -> Callable[[httpx.Request], httpx.Response]: @@ -593,7 +593,7 @@ def _unauthorized(request: httpx.Request) -> httpx.Response: return httpx.Response(401, json={"error": {"message": "Unauthorized"}}, request=request) -_RECONNECT_MESSAGE = "Reconnect GitHub Copilot for credential 'copilot-cred'" +_RECONNECT_MESSAGE: Final = "Reconnect GitHub Copilot for credential 'copilot-cred'" @pytest.mark.parametrize("surface", ["completion", "embedding", "responses"]) @@ -884,6 +884,32 @@ def test_e2e_completion_shared_mode_still_uses_authenticator(tmp_path, monkeypat assert response.usage.total_tokens > 0 +def test_completion_sends_each_copilot_client_header_once_with_its_configured_value(tmp_path, monkeypatch): + from openai import OpenAI + + _shared_login(tmp_path, monkeypatch) + monkeypatch.setenv("GITHUB_COPILOT_USER_AGENT", "opencode/1.18.7") + sent: list[httpx.Request] = [] + + def capture(request: httpx.Request) -> httpx.Response: + sent.append(request) + return httpx.Response(200, json=_chat_completion_response().model_dump(), request=request) + + sdk_client: Final = OpenAI(api_key="unused", http_client=httpx.Client(transport=httpx.MockTransport(capture))) + with github_http(lambda request: pytest.fail(f"unexpected request to {request.url}")): + litellm.completion( + model="github_copilot/gpt-4o", + messages=[{"role": "user", "content": "hi"}], + client=sdk_client, + ) + assert len(sent) == 1 + assert [sent[0].headers.get_list(name) for name in ("accept", "content-type", "user-agent")] == [ + ["application/json"], + ["application/json"], + ["opencode/1.18.7"], + ] + + def test_e2e_embedding_shared_mode_still_uses_authenticator(tmp_path, monkeypatch): _shared_login(tmp_path, monkeypatch) with github_http(_copilot_surface_responder(_embedding_response_payload())) as requests: diff --git a/tests/unit/proxy/credential_endpoints/test_endpoints.py b/tests/unit/proxy/credential_endpoints/test_endpoints.py index 24f3550b3a7..0a8ac32432a 100644 --- a/tests/unit/proxy/credential_endpoints/test_endpoints.py +++ b/tests/unit/proxy/credential_endpoints/test_endpoints.py @@ -1465,10 +1465,10 @@ from litellm.proxy.credential_endpoints import user_provider_credentials as upc _DEVICE_CODE_URL = "https://github.com/login/device/code" _ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token" -_COPILOT_MODELS_URL = f"{DEFAULT_GITHUB_COPILOT_API_BASE}/models" +_COPILOT_MODELS_URL: Final = f"{DEFAULT_GITHUB_COPILOT_API_BASE}/models" _GITHUB_USER_URL = "https://api.github.com/user" -_COPILOT_USER_URL = "https://api.github.com/copilot_internal/user" -_DEFAULT_PLAN = {"endpoints": {"api": DEFAULT_GITHUB_COPILOT_API_BASE}} +_COPILOT_USER_URL: Final = "https://api.github.com/copilot_internal/user" +_DEFAULT_PLAN: Final = {"endpoints": {"api": DEFAULT_GITHUB_COPILOT_API_BASE}} def _as_user(user_id="user-a"): From 89c7e544ab08f54ddac586d641bff43c58f0b4c1 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 20 Oct 2026 06:19:24 -0700 Subject: [PATCH 27/32] test(github-copilot): annotate the captured requests list as Final --- tests/unit/llms/github_copilot/test_per_user_auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/llms/github_copilot/test_per_user_auth.py b/tests/unit/llms/github_copilot/test_per_user_auth.py index d6ff45cdd32..d4e6acc94dc 100644 --- a/tests/unit/llms/github_copilot/test_per_user_auth.py +++ b/tests/unit/llms/github_copilot/test_per_user_auth.py @@ -889,7 +889,7 @@ def test_completion_sends_each_copilot_client_header_once_with_its_configured_va _shared_login(tmp_path, monkeypatch) monkeypatch.setenv("GITHUB_COPILOT_USER_AGENT", "opencode/1.18.7") - sent: list[httpx.Request] = [] + sent: Final[list[httpx.Request]] = [] def capture(request: httpx.Request) -> httpx.Response: sent.append(request) From b9483805d6702827239b56bf078e3383cfbebb67 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 20 Oct 2026 07:33:45 -0700 Subject: [PATCH 28/32] fix(github-copilot): show the Copilot API base error on Connect instead of a generic seat check failure --- litellm/llms/github_copilot/per_user_auth.py | 3 ++- .../llms/github_copilot/test_per_user_auth.py | 23 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/litellm/llms/github_copilot/per_user_auth.py b/litellm/llms/github_copilot/per_user_auth.py index c1b514c4454..e5c6846a4ae 100644 --- a/litellm/llms/github_copilot/per_user_auth.py +++ b/litellm/llms/github_copilot/per_user_auth.py @@ -300,9 +300,10 @@ async def afetch_github_login(github_token: str) -> str: async def acheck_copilot_seat(github_token: str, credential_name: str) -> None: client: Final = _new_async_github_client() + api_base: Final = await aget_copilot_api_base(github_token) try: response: Final = await client.get( - f"{(await aget_copilot_api_base(github_token)).rstrip('/')}/models", + f"{api_base.rstrip('/')}/models", headers=get_copilot_default_headers(github_token), ) except Exception as e: diff --git a/tests/unit/llms/github_copilot/test_per_user_auth.py b/tests/unit/llms/github_copilot/test_per_user_auth.py index d4e6acc94dc..dec10275a07 100644 --- a/tests/unit/llms/github_copilot/test_per_user_auth.py +++ b/tests/unit/llms/github_copilot/test_per_user_auth.py @@ -209,6 +209,29 @@ async def test_seat_check_transport_failure_raises_connection_error(): assert "private transport detail" not in str(exc.value) +@pytest.mark.asyncio +@pytest.mark.parametrize( + "env_name,env_value,message", + [ + ( + "GITHUB_COPILOT_API_KEY_URL", + "https://api.octo.ghe.com/copilot_internal/v2/token", + "Set GITHUB_COPILOT_API_BASE", + ), + ("GITHUB_COPILOT_API_BASE", "ftp://copilot.example.com", "must be an HTTP or HTTPS URL"), + ], +) +async def test_seat_check_surfaces_the_copilot_api_base_configuration_error(monkeypatch, env_name, env_value, message): + monkeypatch.setenv(env_name, env_value) + with ( + github_http( + lambda request: pytest.fail(f"unexpected request to {request.url}"), answer_plan(PLAN_COPILOT_HOST) + ), + pytest.raises(InvalidAPIBaseError, match=message), + ): + await acheck_copilot_seat("stored-github-token", "copilot-cred") + + @pytest.mark.asyncio async def test_github_login_uses_configured_auth_headers(monkeypatch): monkeypatch.setenv("GITHUB_COPILOT_USER_AGENT", "test-agent") From fb41b24000fe9f93e1aeb8e62d09a4493667adf2 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:49:16 -0700 Subject: [PATCH 29/32] test(github-copilot): keep the mock completion test off the real Copilot token dir --- .../llms/github_copilot/test_github_copilot_transformation.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/unit/llms/github_copilot/test_github_copilot_transformation.py b/tests/unit/llms/github_copilot/test_github_copilot_transformation.py index 2d1b0281a3d..7bd0a4156f8 100644 --- a/tests/unit/llms/github_copilot/test_github_copilot_transformation.py +++ b/tests/unit/llms/github_copilot/test_github_copilot_transformation.py @@ -85,10 +85,12 @@ def test_github_copilot_config_get_openai_compatible_provider_info(monkeypatch: @patch("litellm.main.openai_chat_completions.completion") @patch("litellm.llms.openai.openai.OpenAIChatCompletion.completion") def test_completion_github_copilot_mock_response( - mock_class_completion, mock_instance_completion, mock_get_api_key, monkeypatch + mock_class_completion, mock_instance_completion, mock_get_api_key, monkeypatch, tmp_path ): """Test the completion function with GitHub Copilot provider.""" + monkeypatch.setenv("GITHUB_COPILOT_TOKEN_DIR", str(tmp_path)) + # Force chat path through the patched openai_chat_completions instance even if # a previous test left EXPERIMENTAL_OPENAI_BASE_LLM_HTTP_HANDLER set in the env. monkeypatch.delenv("EXPERIMENTAL_OPENAI_BASE_LLM_HTTP_HANDLER", raising=False) From 90be6cb12cfb509de7f572976544dcc4cd163574 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:47:18 -0700 Subject: [PATCH 30/32] fix(github-copilot): load per-user models whatever the github.com host env vars say A per-user Copilot deployment resolved the github.com API base at startup, so an old ghe.com GITHUB_COPILOT_API_KEY_URL or a scheme-less GITHUB_COPILOT_API_BASE left it out of the router. Its requests take the base from the user's session, so startup now uses the default host as a placeholder. Adds a ghe.com per-user integration test that connects and chats on the tenant hosts. --- .../get_llm_provider_logic.py | 6 +- tests/integration/_support/device_login.py | 46 +++++- .../test_github_copilot_ghe_per_user.py | 154 ++++++++++++++++++ .../llms/github_copilot/test_per_user_auth.py | 22 +++ 4 files changed, 219 insertions(+), 9 deletions(-) create mode 100644 tests/integration/providers/test_github_copilot_ghe_per_user.py diff --git a/litellm/litellm_core_utils/get_llm_provider_logic.py b/litellm/litellm_core_utils/get_llm_provider_logic.py index 87855f5b92e..127f1491f89 100644 --- a/litellm/litellm_core_utils/get_llm_provider_logic.py +++ b/litellm/litellm_core_utils/get_llm_provider_logic.py @@ -807,7 +807,7 @@ def _get_openai_compatible_provider_info( api_base = api_base or get_secret("GALADRIEL_API_BASE") or "https://api.galadriel.com/v1" dynamic_api_key = api_key or get_secret_str("GALADRIEL_API_KEY") elif custom_llm_provider == "github_copilot": - from litellm.llms.github_copilot.common_utils import get_copilot_api_base + from litellm.llms.github_copilot.common_utils import DEFAULT_GITHUB_COPILOT_API_BASE, get_copilot_api_base from litellm.llms.github_copilot.per_user_auth import ( github_copilot_per_user_credential_name, github_copilot_user_session_from, @@ -828,8 +828,8 @@ def _get_openai_compatible_provider_info( is not None ): # per-user deployments resolve no token at provider-info time; the - # caller's session supplies the key per request - api_base = get_copilot_api_base(None) # rebind-ok: resolves provider args in place + # caller's session supplies base + key per request + api_base = DEFAULT_GITHUB_COPILOT_API_BASE # rebind-ok: resolves provider args in place dynamic_api_key = None # rebind-ok: resolves provider args in place else: ( diff --git a/tests/integration/_support/device_login.py b/tests/integration/_support/device_login.py index 57188b9ea50..0286f6b0be6 100644 --- a/tests/integration/_support/device_login.py +++ b/tests/integration/_support/device_login.py @@ -26,6 +26,10 @@ CHATGPT_AUTH_HOST: Final = "auth.openai.com" GITHUB_HOST: Final = "github.com" GITHUB_API_HOST: Final = "api.github.com" AUTH_HOSTS: Final = (CHATGPT_AUTH_HOST, GITHUB_HOST, GITHUB_API_HOST) +GHE_HOST: Final = "octo.ghe.com" +GHE_API_HOST: Final = "api.octo.ghe.com" +GHE_COPILOT_HOST: Final = "copilot-api.octo.ghe.com" +TUNNELED_HOSTS: Final = (*AUTH_HOSTS, GHE_HOST, GHE_API_HOST, GHE_COPILOT_HOST) CHATGPT_ACCOUNT: Final = "acct-device-login" _FAR_FUTURE: Final = 4102444800 _LONG_AGO: Final = 946684800 @@ -73,6 +77,8 @@ COPILOT_USER_CODE: Final = "COPI-LEAK" COPILOT_ACCESS: Final = "device-login-github-access" COPILOT_REJECTED_ACCESS: Final = "device-login-github-access-rejected" COPILOT_FIRST_LOGIN_ACCESS: Final = "device-login-github-access-first-login" +GHE_ACCESS: Final = "device-login-ghe-access" +GHE_LOGIN: Final = "octo-user" CONTROL_KEY: Final = "device-login-control-key" ACCEPTED_BEARERS: Final = frozenset( {CHATGPT_STORED, CHATGPT_REFRESHED, CHATGPT_FIRST_LOGIN, COPILOT_ACCESS, COPILOT_FIRST_LOGIN_ACCESS, CONTROL_KEY} @@ -132,9 +138,15 @@ def _switched_off() -> Reply: return _json(503, {"error": "device login is switched off in this scenario"}) -def _auth_hosts(grant: threading.Event) -> Callable[[Request], Reply]: +def _ghe_token(request: Request) -> str: + scheme, _, token = request.headers.get("authorization", "").partition(" ") + return token if scheme in ("token", "Bearer") else "" + + +def _auth_hosts(grant: threading.Event, vendor: rv.ResponsesVendor) -> Callable[[Request], Reply]: def respond(request: Request) -> Reply: - match (request.headers.get("host", ""), request.method, urlsplit(request.target).path): + host: Final = request.headers.get("host", "") + match (host, request.method, urlsplit(request.target).path): case ("auth.openai.com", "POST", "/oauth/token"): return _oauth_token(request, grant) case ("auth.openai.com", "POST", "/api/accounts/deviceauth/usercode") if grant.is_set(): @@ -161,6 +173,27 @@ def _auth_hosts(grant: threading.Event) -> Callable[[Request], Reply]: ) case ("github.com", "POST", "/login/oauth/access_token") if grant.is_set(): return _json(200, {"access_token": COPILOT_FIRST_LOGIN_ACCESS, "token_type": "bearer"}) + case ("octo.ghe.com", "POST", "/login/device/code") if grant.is_set(): + return _json( + 200, + { + "device_code": _GITHUB_DEVICE_CODE, + "user_code": COPILOT_USER_CODE, + "verification_uri": f"https://{GHE_HOST}/login/device", + "expires_in": 900, + "interval": 5, + }, + ) + case ("octo.ghe.com", "POST", "/login/oauth/access_token") if grant.is_set(): + return _json(200, {"access_token": GHE_ACCESS, "token_type": "bearer"}) + case _ if host in (GHE_API_HOST, GHE_COPILOT_HOST) and _ghe_token(request) != GHE_ACCESS: + return _json(401, {"message": "Bad credentials"}) + case ("api.octo.ghe.com", "GET", "/copilot_internal/user"): + return _json(200, {"endpoints": {"api": f"https://{GHE_COPILOT_HOST}"}}) + case ("api.octo.ghe.com", "GET", "/user"): + return _json(200, {"login": GHE_LOGIN}) + case ("copilot-api.octo.ghe.com", _, _): + return vendor.respond(request) case _: return _switched_off() @@ -287,7 +320,7 @@ def _tunnel( if len(request_line) < 2: return authority: Final = request_line[1] - if authority.rsplit(":", 1)[0] not in AUTH_HOSTS: + if authority.rsplit(":", 1)[0] not in TUNNELED_HOSTS: self.wfile.write(b"HTTP/1.1 403 Forbidden\r\ncontent-length: 0\r\n\r\n") return authorities.put(authority) @@ -321,13 +354,14 @@ def _tunnel( @contextmanager def device_login_peers(directory: Path) -> Generator[Peers]: - cert, key = write_self_signed_cert(directory, AUTH_HOSTS) + cert, key = write_self_signed_cert(directory, TUNNELED_HOSTS) switches: Final = Switches(threading.Event(), threading.Event(), threading.Event(), threading.Event()) authorities: Final[SimpleQueue[str]] = SimpleQueue() hangups: Final[SimpleQueue[Hangup]] = SimpleQueue() + vendor: Final = rv.ResponsesVendor() with ( - wire_server(_provider_api(rv.ResponsesVendor())) as api, - wire_server(_auth_hosts(switches.grant), tls=server_context(cert, key)) as auth, + wire_server(_provider_api(vendor)) as api, + wire_server(_auth_hosts(switches.grant, vendor), tls=server_context(cert, key)) as auth, _tunnel(auth, switches, authorities, hangups) as tunnel, ): yield Peers(api, auth, tunnel, cert, authorities, hangups, switches) diff --git a/tests/integration/providers/test_github_copilot_ghe_per_user.py b/tests/integration/providers/test_github_copilot_ghe_per_user.py new file mode 100644 index 00000000000..a7cd11fe2cb --- /dev/null +++ b/tests/integration/providers/test_github_copilot_ghe_per_user.py @@ -0,0 +1,154 @@ +import uuid +from collections.abc import Iterator, Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import Final +from urllib.parse import urlsplit + +import httpx +import pytest +import yaml +from integration._support import device_login as dl +from integration._support import responses_vendor as rv +from integration._support.client import gateway_from_environment +from integration._support.process import OwnedProxy, owned_proxy_process +from integration._support.wire import Request +from pydantic import JsonValue + +pytestmark: Final = [pytest.mark.timeout(300), pytest.mark.shares_worker_state] + +_CREDENTIAL: Final = "copilot-ghe" +_MODEL: Final = "copilot-ghe-per-user" +_CLIENT_SECONDS: Final = 60 + + +@dataclass(frozen=True, slots=True) +class _Rig: + owned: OwnedProxy + peers: dl.Peers + + def post(self, path: str, key: str, body: Mapping[str, JsonValue]) -> httpx.Response: + with httpx.Client( + base_url=str(self.owned.gateway.client.base_url), timeout=_CLIENT_SECONDS, trust_env=False + ) as client: + return client.post(path, json=dict(body), headers={"Authorization": f"Bearer {key}"}) + + def disconnect(self, key: str) -> None: + with httpx.Client( + base_url=str(self.owned.gateway.client.base_url), timeout=_CLIENT_SECONDS, trust_env=False + ) as client: + response: Final = client.delete( + f"/credentials/{_CREDENTIAL}/user_connection", headers={"Authorization": f"Bearer {key}"} + ) + assert response.status_code == 200, response.text + + +def _config(directory: Path) -> Path: + config: Final = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text()) + config["credential_list"] = [ + { + "credential_name": _CREDENTIAL, + "credential_values": {"github_copilot_auth_type": "per_user_oauth", "github_copilot_host": dl.GHE_HOST}, + "credential_info": {"custom_llm_provider": "github_copilot"}, + } + ] + config["model_list"] = [ + { + "model_name": _MODEL, + "litellm_params": { + "model": "github_copilot/gpt-5.4", + "litellm_credential_name": _CREDENTIAL, + "github_copilot_auth_type": "per_user_oauth", + }, + } + ] + path: Final = directory / "copilot-ghe-per-user.yaml" + path.write_text(yaml.safe_dump(config)) + return path + + +@pytest.fixture(scope="module") +def rig(tmp_path_factory: pytest.TempPathFactory) -> Iterator[_Rig]: + directory: Final = tmp_path_factory.mktemp("copilot-ghe-per-user") + copilot_dir: Final = directory / "copilot" + copilot_dir.mkdir() + with ( + gateway_from_environment() as gateway, + dl.device_login_peers(directory) as peers, + owned_proxy_process( + gateway, + directory, + {**peers.environment(), "GITHUB_COPILOT_TOKEN_DIR": str(copilot_dir)}, + config=_config(directory), + workers=2, + ) as owned, + ): + yield _Rig(owned, peers) + + +def _outbound(request: Request) -> tuple[str, str, str, str]: + return ( + request.headers.get("host", ""), + request.method, + urlsplit(request.target).path, + request.headers.get("authorization", ""), + ) + + +def test_a_ghe_user_connects_and_chats_with_their_own_token_on_the_tenant_hosts(rig: _Rig) -> None: + rig.peers.reset() + rig.peers.switches.grant.set() + with rig.owned.gateway.scenario() as scenario: + user_key: Final = scenario.key(user_id=scenario.user(user_role="internal_user")) + scenario.cleanups.callback(rig.disconnect, user_key) + + started: Final = rig.post(f"/credentials/{_CREDENTIAL}/user_connection/start", user_key, {}) + assert started.status_code == 200, started.text + flow: Final = rv.JSON_OBJECT.validate_json(started.text) + assert flow["verification_uri"] == f"https://{dl.GHE_HOST}/login/device", started.text + + polled: Final = rig.post( + f"/credentials/{_CREDENTIAL}/user_connection/poll", user_key, {"flow_handle": flow["flow_handle"]} + ) + assert polled.status_code == 200, polled.text + connection: Final = rv.JSON_OBJECT.validate_json(polled.text) + assert (connection["status"], connection["github_login"]) == ("connected", dl.GHE_LOGIN), polled.text + + marker: Final = uuid.uuid4().hex + chat: Final = rig.post( + "/v1/chat/completions", + user_key, + { + "model": _MODEL, + "messages": [{"role": "user", "content": f"Reply to marker-{marker}"}], + "num_retries": 0, + "cache": {"no-cache": True}, + }, + ) + assert chat.status_code == 200, chat.text + assert set(rv.MARKER.findall(chat.text)) == {marker}, chat.text + + received: Final = rig.peers.auth.drain() + outbound: Final = tuple(_outbound(request) for request in received) + (forwarded,) = (request for request in received if marker.encode() in request.body) + assert _outbound(forwarded) == ( + dl.GHE_COPILOT_HOST, + "POST", + "/chat/completions", + f"Bearer {dl.GHE_ACCESS}", + ), outbound + assert {(host, method, path) for host, method, path, _ in outbound} == { + (dl.GHE_HOST, "POST", "/login/device/code"), + (dl.GHE_HOST, "POST", "/login/oauth/access_token"), + (dl.GHE_API_HOST, "GET", "/copilot_internal/user"), + (dl.GHE_COPILOT_HOST, "GET", "/models"), + (dl.GHE_API_HOST, "GET", "/user"), + (dl.GHE_COPILOT_HOST, "POST", "/chat/completions"), + }, outbound + assert {authorization for host, _, _, authorization in outbound if host == dl.GHE_API_HOST} == { + f"token {dl.GHE_ACCESS}" + }, outbound + assert {authorization for host, _, _, authorization in outbound if host == dl.GHE_COPILOT_HOST} == { + f"Bearer {dl.GHE_ACCESS}" + }, outbound + assert rig.peers.api.drain() == (), "the github.com GITHUB_COPILOT_API_BASE took a ghe.com call" diff --git a/tests/unit/llms/github_copilot/test_per_user_auth.py b/tests/unit/llms/github_copilot/test_per_user_auth.py index 394ad5395b4..e9e60983c1c 100644 --- a/tests/unit/llms/github_copilot/test_per_user_auth.py +++ b/tests/unit/llms/github_copilot/test_per_user_auth.py @@ -1223,6 +1223,28 @@ def test_router_constructs_with_per_user_credential_deployment(per_user_credenti assert any(d["model_name"] == "copilot" for d in deployments) +@pytest.mark.parametrize( + ("env_name", "env_value"), + [ + ("GITHUB_COPILOT_API_KEY_URL", "https://api.octo.ghe.com/copilot_internal/v2/token"), + ("GITHUB_COPILOT_API_BASE", "copilot-api.example.com"), + ], +) +def test_router_keeps_a_ghe_per_user_deployment_whatever_the_github_com_env_says(monkeypatch, env_name, env_value): + monkeypatch.delenv("GITHUB_COPILOT_API_BASE", raising=False) + monkeypatch.setenv(env_name, env_value) + with patch.object(litellm, "credential_list", [_credential(github_host="octo.ghe.com")]): + router: Final = litellm.Router( + model_list=[ + { + "model_name": "copilot-ghe", + "litellm_params": {"model": "github_copilot/gpt-4.1", "litellm_credential_name": "copilot-cred"}, + } + ] + ) + assert [d["model_name"] for d in router.get_model_list() or []] == ["copilot-ghe"] + + @pytest.mark.parametrize("deployment_base", [None, "https://deployment.example.com"]) def test_provider_info_per_user_without_session_returns_the_copilot_base_and_no_key( per_user_credential, deployment_base From 22197d9e4f738ab9f270a87174ccf57c1d7f8084 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:19:47 -0700 Subject: [PATCH 31/32] fix(github-copilot): report a ghe.com session's own host and prefetch the plan host for aliased models A per-user ghe.com call reported the github.com Copilot host as its api base, since the report ignored the session's GitHub host. A shared call through litellm.model_alias_map skipped the plan host lookup, because the check read the alias before completion resolved it, so a cold cache sent it to the default host. --- .../llms/github_copilot/chat/transformation.py | 7 +++---- litellm/llms/github_copilot/per_user_auth.py | 5 ++++- .../llms/github_copilot/test_per_user_auth.py | 16 +++++++++++++++- tests/unit/test_router/test_router.py | 8 +++++--- 4 files changed, 27 insertions(+), 9 deletions(-) diff --git a/litellm/llms/github_copilot/chat/transformation.py b/litellm/llms/github_copilot/chat/transformation.py index 2fbc225e6f3..306ed528691 100644 --- a/litellm/llms/github_copilot/chat/transformation.py +++ b/litellm/llms/github_copilot/chat/transformation.py @@ -36,10 +36,9 @@ class GithubCopilotConfig(OpenAIConfig): def api_base_without_login(self, user_session: GithubCopilotUserSession | None = None) -> str | None: try: - return get_copilot_api_base( - None, - lambda: user_session.token if user_session is not None else self.authenticator.stored_access_token(), - ) + if user_session is not None: + return get_copilot_api_base(None, lambda: user_session.token, user_session.endpoints) + return get_copilot_api_base(None, self.authenticator.stored_access_token) except InvalidAPIBaseError: return None diff --git a/litellm/llms/github_copilot/per_user_auth.py b/litellm/llms/github_copilot/per_user_auth.py index 7443be5959f..ffd92196e80 100644 --- a/litellm/llms/github_copilot/per_user_auth.py +++ b/litellm/llms/github_copilot/per_user_auth.py @@ -556,8 +556,11 @@ def _plan_api_base_request( session: Final = kwargs.get(GITHUB_COPILOT_USER_SESSION_KWARG_KEY) if isinstance(session, GithubCopilotUserSession): return session.token, None, session.endpoints + import litellm + + requested_model: Final = litellm.model_alias_map.get(model, model) if isinstance(model, str) else model targets_github_copilot: Final = kwargs.get("custom_llm_provider") == _LLM_PROVIDER or ( - isinstance(model, str) and model.startswith(f"{_LLM_PROVIDER}/") + isinstance(requested_model, str) and requested_model.startswith(f"{_LLM_PROVIDER}/") ) shared_token: Final = Authenticator().stored_access_token() if targets_github_copilot else None return (shared_token, api_base, None) if shared_token is not None else None diff --git a/tests/unit/llms/github_copilot/test_per_user_auth.py b/tests/unit/llms/github_copilot/test_per_user_auth.py index e9e60983c1c..9f91250d3c0 100644 --- a/tests/unit/llms/github_copilot/test_per_user_auth.py +++ b/tests/unit/llms/github_copilot/test_per_user_auth.py @@ -5,6 +5,7 @@ import datetime import time from collections.abc import Callable from contextlib import contextmanager +from pathlib import Path from typing import Final from unittest.mock import AsyncMock, MagicMock, patch @@ -1081,6 +1082,18 @@ def test_requests_go_to_the_plan_host_github_reports_for_the_token( assert [response._hidden_params["api_base"] for response in responses] == [PLAN_COPILOT_HOST] * 2 +def test_an_aliased_shared_model_goes_to_the_plan_host(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + _shared_login(tmp_path, monkeypatch) + monkeypatch.setattr(litellm, "model_alias_map", {"copilot": _SURFACE_MODELS["completion"]}) + lookups: Final[list[httpx.Request]] = [] + with github_http( + _copilot_surface_responder(_surface_payload("completion")), answer_plan(PLAN_COPILOT_HOST), lookups + ) as requests: + _call_surface(litellm, "completion", model="copilot") + assert [str(request.url) for request in requests] == [f"{PLAN_COPILOT_HOST}/chat/completions"] + assert [request.headers["authorization"] for request in lookups] == ["token shared-copilot-token"] + + @pytest.mark.asyncio @pytest.mark.parametrize("mode", ["shared", "per_user"]) async def test_messages_go_to_the_plan_host_github_reports_for_the_token( @@ -1466,7 +1479,7 @@ def test_e2e_completion_per_user_ghe_credential_sends_the_token_to_its_tenant(mo github_http(respond, answer_plan(GHE_COPILOT_HOST), lookups) as requests, _no_authenticator(), ): - litellm.completion( + response: Final = litellm.completion( model="github_copilot/gpt-4o", messages=[{"role": "user", "content": "hi"}], litellm_credential_name="copilot-cred", @@ -1475,3 +1488,4 @@ def test_e2e_completion_per_user_ghe_credential_sends_the_token_to_its_tenant(mo assert [str(request.url) for request in lookups] == [GHE_COPILOT_USER_URL] assert [str(request.url) for request in requests] == [f"{GHE_COPILOT_HOST}/chat/completions"] assert requests[0].headers["authorization"] == "Bearer ghe-github-token" + assert response._hidden_params["api_base"] == GHE_COPILOT_HOST diff --git a/tests/unit/test_router/test_router.py b/tests/unit/test_router/test_router.py index 6956ff37f90..76bbc0fe538 100644 --- a/tests/unit/test_router/test_router.py +++ b/tests/unit/test_router/test_router.py @@ -9751,10 +9751,12 @@ def test_update_kwargs_with_deployment_no_tags(): assert "tags" not in kwargs["metadata"] -def _copilot_deployment_object(token_dir, monkeypatch, **litellm_params): +def _copilot_deployment_object( + token_dir: Path, monkeypatch: pytest.MonkeyPatch, **litellm_params: object +) -> tuple[litellm.Router, Deployment | None]: (token_dir / "access-token").write_text("shared-copilot-token") monkeypatch.setenv("GITHUB_COPILOT_TOKEN_DIR", str(token_dir)) - router = litellm.Router( + router: Final = litellm.Router( model_list=[ { "model_name": "copilot", @@ -9773,7 +9775,7 @@ def test_update_kwargs_with_deployment_drops_caller_api_base_for_a_copilot_deplo tmp_path, monkeypatch ): router, deployment = _copilot_deployment_object(tmp_path, monkeypatch, drop_params=True) - kwargs: dict = {"metadata": {}, "api_base": "https://caller.example.com"} + kwargs: Final[dict[str, object]] = {"metadata": {}, "api_base": "https://caller.example.com"} router._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs) From 5f9186d1f9bb38edfa6e2108d1ef2f9f42d4ecfc Mon Sep 17 00:00:00 2001 From: codgician <15964984+codgician@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:56:17 +0800 Subject: [PATCH 32/32] fix(github-copilot): adapt main's new session and router code to the merged branch --- litellm/router.py | 6 +++--- .../_support/copilot_session_prometheus_worker.py | 14 +++++++++----- .../test_prometheus_invalid_key_filtering.py | 3 ++- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/litellm/router.py b/litellm/router.py index adb1a116e64..4d817de1c30 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -4797,11 +4797,11 @@ class Router: - Merges tools from deployment with request (proxy-configured tools + request tools). - On a fallback hop, drops the encrypted reasoning this deployment cannot decrypt, keeping its summary. """ - deployment_litellm_params: Final = cast( # cast-ok: a dict or LiteLLM_Params, both answer get + deployment_param_lookup: Final = cast( # cast-ok: a dict or LiteLLM_Params, both answer get "DeploymentParamLookup", deployment["litellm_params"] ) caller_kwargs: Final = cast("Mapping[str, object]", kwargs) # cast-ok: router call kwargs are a str-keyed dict - caller_endpoint_keys: Final = github_copilot_caller_endpoint_keys(deployment_litellm_params, caller_kwargs) + caller_endpoint_keys: Final = github_copilot_caller_endpoint_keys(deployment_param_lookup, caller_kwargs) router_defaults: Final = cast( # cast-ok: router default params are a str-keyed dict "Mapping[str, object]", self.default_litellm_params ) @@ -4810,7 +4810,7 @@ class Router: value for value in ( caller_kwargs.get("drop_params"), - deployment_litellm_params.get("drop_params"), + deployment_param_lookup.get("drop_params"), router_defaults.get("drop_params"), ) if value is not None diff --git a/tests/integration/_support/copilot_session_prometheus_worker.py b/tests/integration/_support/copilot_session_prometheus_worker.py index a6b2c5d4692..279807f3170 100644 --- a/tests/integration/_support/copilot_session_prometheus_worker.py +++ b/tests/integration/_support/copilot_session_prometheus_worker.py @@ -7,19 +7,22 @@ from __future__ import annotations import asyncio import json +import os import subprocess import sys -from itertools import chain from dataclasses import dataclass +from itertools import chain from typing import Final -import litellm import openai -from litellm.integrations.prometheus import PrometheusLogger -from litellm.llms.github_copilot.per_user_auth import GithubCopilotUserSession from prometheus_client import REGISTRY from pydantic import TypeAdapter +import litellm +from litellm.integrations.prometheus import PrometheusLogger +from litellm.llms.github_copilot.github_host import github_host_endpoints +from litellm.llms.github_copilot.per_user_auth import GithubCopilotUserSession + COPILOT_DEPLOYMENT: Final = "copilot-per-user-deployment" OPERATOR_DEPLOYMENT: Final = "operator-key-deployment" CALLER_SESSION_TOKEN: Final = "caller-copilot-session-token" @@ -77,6 +80,7 @@ _REPORT: Final = TypeAdapter(WorkerReport) def run_copilot_session_worker(api_base: str) -> WorkerReport: completed: Final = subprocess.run( (sys.executable, "-P", __file__, api_base), + env={**os.environ, "GITHUB_COPILOT_API_BASE": api_base}, capture_output=True, text=True, timeout=WORKER_TIMEOUT_SECONDS, @@ -146,7 +150,7 @@ async def _direct_attempt(api_base: str) -> Outcome: async def main(api_base: str) -> None: litellm.callbacks = [PrometheusLogger()] # test-quality-ok: this worker process exits right after the report - session: Final = GithubCopilotUserSession(token=CALLER_SESSION_TOKEN, api_base=api_base) + session: Final = GithubCopilotUserSession(token=CALLER_SESSION_TOKEN, endpoints=github_host_endpoints("github.com")) router: Final = _router(api_base) outcomes: Final = ( await _attempt(router, COPILOT_DEPLOYMENT, github_copilot_user_session=session), diff --git a/tests/unit/integrations/test_prometheus_invalid_key_filtering.py b/tests/unit/integrations/test_prometheus_invalid_key_filtering.py index 453fd28636d..8c5f22c5fd0 100644 --- a/tests/unit/integrations/test_prometheus_invalid_key_filtering.py +++ b/tests/unit/integrations/test_prometheus_invalid_key_filtering.py @@ -19,6 +19,7 @@ from prometheus_client.samples import Sample import litellm from litellm.constants import LITELLM_LOGGING_NO_UPSTREAM_LLM_CALL from litellm.integrations.prometheus import PrometheusLogger +from litellm.llms.github_copilot.github_host import github_host_endpoints from litellm.llms.github_copilot.per_user_auth import GithubCopilotUserSession from litellm.proxy._types import ProxyErrorTypes, ProxyException, UserAPIKeyAuth @@ -223,7 +224,7 @@ class TestSkipMetricsValidation: { "litellm_params": { "github_copilot_user_session": GithubCopilotUserSession( - token="caller-session-token", api_base="https://api.githubcopilot.com" + token="caller-session-token", endpoints=github_host_endpoints("github.com") ) } },