From c3e673b627440fd831f7e255a1ed1b2365c27946 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Fri, 4 Jul 2025 13:12:16 -0700 Subject: [PATCH] [Feat] Add github co-pilot as a new LLM API provider (#12325) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Litellm dev 03 05 2025 contributor prs (#9079) * feat: add support for copilot provider * test: add tests for github copilot * chore: clean up github copilot authenticator * test: add test for github copilot authenticator * test: add test for github copilot for sonnet 3.7 thought model * Fix #7629 - Add tzdata package to Dockerfile (#8915) * Add tzdata package to Dockerfile * Move tzdata to python requirement.txt * feat: add support for copilot provider (#8577) * feat: add support for copilot provider * test: add tests for github copilot * chore: clean up github copilot authenticator * test: add test for github copilot authenticator * test: add test for github copilot for sonnet 3.7 thought model --------- Co-authored-by: Krish Dholakia * feat: add model information for copilot models * fix: fix linting errors * test: remove integration test for github_copilot + fix misisng mock * fix: use print to make sure the logger message shown * test: remove debug print * fix lint (#11112) * Add init files to make test directories Python packages and update import paths in test_token_counter.py (#11119) * Update litellm/model_prices_and_context_window_backup.json Co-authored-by: மனோஜ்குமார் பழனிச்சாமி --------- Co-authored-by: Son H. Nguyen Co-authored-by: subnet.dev <50828879+subnet-dev@users.noreply.github.com> Co-authored-by: Son H. Nguyen <33925625+nhs000@users.noreply.github.com> Co-authored-by: மனோஜ்குமார் பழனிச்சாமி Co-authored-by: Ishaan Jaff * refactor github copilot * test_github_copilot_transformation.py * test_github_copilot_authenticator.py * add GitHub Copilot * fix order * doc fix --------- Co-authored-by: Krish Dholakia Co-authored-by: Son H. Nguyen Co-authored-by: subnet.dev <50828879+subnet-dev@users.noreply.github.com> Co-authored-by: Son H. Nguyen <33925625+nhs000@users.noreply.github.com> Co-authored-by: மனோஜ்குமார் பழனிச்சாமி --- .../docs/providers/github_copilot.md | 186 ++++++++++ docs/my-website/sidebars.js | 3 +- litellm/__init__.py | 1 + litellm/constants.py | 2 + .../get_llm_provider_logic.py | 8 + litellm/llms/github_copilot/authenticator.py | 345 ++++++++++++++++++ .../github_copilot/chat/transformation.py | 37 ++ litellm/llms/github_copilot/common_utils.py | 49 +++ litellm/types/utils.py | 1 + tests/__init__.py | 1 + tests/test_litellm/__init__.py | 1 + .../litellm_core_utils/__init__.py | 1 + .../test_github_copilot_authenticator.py | 180 +++++++++ .../test_github_copilot_transformation.py | 128 +++++++ 14 files changed, 942 insertions(+), 1 deletion(-) create mode 100644 docs/my-website/docs/providers/github_copilot.md create mode 100644 litellm/llms/github_copilot/authenticator.py create mode 100644 litellm/llms/github_copilot/chat/transformation.py create mode 100644 litellm/llms/github_copilot/common_utils.py create mode 100644 tests/test_litellm/llms/github_copilot/test_github_copilot_authenticator.py create mode 100644 tests/test_litellm/llms/github_copilot/test_github_copilot_transformation.py diff --git a/docs/my-website/docs/providers/github_copilot.md b/docs/my-website/docs/providers/github_copilot.md new file mode 100644 index 0000000000..2ebe6eacb1 --- /dev/null +++ b/docs/my-website/docs/providers/github_copilot.md @@ -0,0 +1,186 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# GitHub Copilot + +https://docs.github.com/en/copilot + +:::tip + +**We support GitHub Copilot Chat API with automatic authentication handling** + +::: + +| Property | Details | +|-------|-------| +| Description | GitHub Copilot Chat API provides access to GitHub's AI-powered coding assistant. | +| Provider Route on LiteLLM | `github_copilot/` | +| Supported Endpoints | `/chat/completions` | +| API Reference | [GitHub Copilot docs](https://docs.github.com/en/copilot) | + +## Authentication + +GitHub Copilot uses OAuth device flow for authentication. On first use, you'll be prompted to authenticate via GitHub: + +1. LiteLLM will display a device code and verification URL +2. Visit the URL and enter the code to authenticate +3. Your credentials will be stored locally for future use + +## Usage - LiteLLM Python SDK + +### Chat Completion + +```python showLineNumbers title="GitHub Copilot Chat Completion" +from litellm import completion + +response = completion( + model="github_copilot/gpt-4", + messages=[{"role": "user", "content": "Write a Python function to calculate fibonacci numbers"}], + extra_headers={ + "editor-version": "vscode/1.85.1", + "Copilot-Integration-Id": "vscode-chat" + } +) +print(response) +``` + +```python showLineNumbers title="GitHub Copilot Chat Completion - Streaming" +from litellm import completion + +stream = completion( + model="github_copilot/gpt-4", + messages=[{"role": "user", "content": "Explain async/await in Python"}], + stream=True, + extra_headers={ + "editor-version": "vscode/1.85.1", + "Copilot-Integration-Id": "vscode-chat" + } +) + +for chunk in stream: + if chunk.choices[0].delta.content is not None: + print(chunk.choices[0].delta.content, end="") +``` + +## Usage - LiteLLM Proxy + +Add the following to your LiteLLM Proxy configuration file: + +```yaml showLineNumbers title="config.yaml" +model_list: + - model_name: github_copilot/gpt-4 + litellm_params: + model: github_copilot/gpt-4 +``` + +Start your LiteLLM Proxy server: + +```bash showLineNumbers title="Start LiteLLM Proxy" +litellm --config config.yaml + +# RUNNING on http://0.0.0.0:4000 +``` + + + + +```python showLineNumbers title="GitHub Copilot via Proxy - Non-streaming" +from openai import OpenAI + +# Initialize client with your proxy URL +client = OpenAI( + base_url="http://localhost:4000", # Your proxy URL + api_key="your-proxy-api-key" # Your proxy API key +) + +# Non-streaming response +response = client.chat.completions.create( + model="github_copilot/gpt-4", + messages=[{"role": "user", "content": "How do I optimize this SQL query?"}], + extra_headers={ + "editor-version": "vscode/1.85.1", + "Copilot-Integration-Id": "vscode-chat" + } +) + +print(response.choices[0].message.content) +``` + + + + + +```python showLineNumbers title="GitHub Copilot via Proxy - LiteLLM SDK" +import litellm + +# Configure LiteLLM to use your proxy +response = litellm.completion( + model="litellm_proxy/github_copilot/gpt-4", + messages=[{"role": "user", "content": "Review this code for bugs"}], + api_base="http://localhost:4000", + api_key="your-proxy-api-key", + extra_headers={ + "editor-version": "vscode/1.85.1", + "Copilot-Integration-Id": "vscode-chat" + } +) + +print(response.choices[0].message.content) +``` + + + + + +```bash showLineNumbers title="GitHub Copilot via Proxy - cURL" +curl http://localhost:4000/v1/chat/completions \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer your-proxy-api-key" \ + -H "editor-version: vscode/1.85.1" \ + -H "Copilot-Integration-Id: vscode-chat" \ + -d '{ + "model": "github_copilot/gpt-4", + "messages": [{"role": "user", "content": "Explain this error message"}] + }' +``` + + + + +## Getting Started + +1. Ensure you have GitHub Copilot access (paid GitHub subscription required) +2. Run your first LiteLLM request - you'll be prompted to authenticate +3. Follow the device flow authentication process +4. Start making requests to GitHub Copilot through LiteLLM + +## Configuration + +### Environment Variables + +You can customize token storage locations: + +```bash showLineNumbers title="Environment Variables" +# Optional: Custom token directory +export GITHUB_COPILOT_TOKEN_DIR="~/.config/litellm/github_copilot" + +# Optional: Custom access token file name +export GITHUB_COPILOT_ACCESS_TOKEN_FILE="access-token" + +# Optional: Custom API key file name +export GITHUB_COPILOT_API_KEY_FILE="api-key.json" +``` + +### Headers + +GitHub Copilot supports various editor-specific headers: + +```python showLineNumbers title="Common Headers" +extra_headers = { + "editor-version": "vscode/1.85.1", # Editor version + "editor-plugin-version": "copilot/1.155.0", # Plugin version + "Copilot-Integration-Id": "vscode-chat", # Integration ID + "user-agent": "GithubCopilot/1.155.0" # User agent +} +``` + diff --git a/docs/my-website/sidebars.js b/docs/my-website/sidebars.js index 6e3fd05eea..e0bbfe2145 100644 --- a/docs/my-website/sidebars.js +++ b/docs/my-website/sidebars.js @@ -415,7 +415,6 @@ const sidebars = { "providers/galadriel", "providers/topaz", "providers/groq", - "providers/github", "providers/deepseek", "providers/elevenlabs", "providers/fireworks_ai", @@ -426,6 +425,8 @@ const sidebars = { "providers/xinference", "providers/cloudflare_workers", "providers/deepinfra", + "providers/github", + "providers/github_copilot", "providers/ai21", "providers/nlp_cloud", "providers/replicate", diff --git a/litellm/__init__.py b/litellm/__init__.py index d4ef336dc8..937ecc77ee 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -1119,6 +1119,7 @@ from .llms.azure.chat.o_series_transformation import AzureOpenAIO1Config from .llms.watsonx.completion.transformation import IBMWatsonXAIConfig from .llms.watsonx.chat.transformation import IBMWatsonXChatConfig from .llms.watsonx.embed.transformation import IBMWatsonXEmbeddingConfig +from .llms.github_copilot.chat.transformation import GithubCopilotConfig from .llms.nebius.chat.transformation import NebiusConfig from .main import * # type: ignore from .integrations import * diff --git a/litellm/constants.py b/litellm/constants.py index eaec161ebe..5add0bba5e 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -261,6 +261,7 @@ LITELLM_CHAT_PROVIDERS = [ "llamafile", "lm_studio", "galadriel", + "github_copilot", # GitHub Copilot Chat API "novita", "meta_llama", "featherless_ai", @@ -421,6 +422,7 @@ openai_compatible_providers: List = [ "llamafile", "lm_studio", "galadriel", + "github_copilot", # GitHub Copilot Chat API "novita", "meta_llama", "featherless_ai", diff --git a/litellm/litellm_core_utils/get_llm_provider_logic.py b/litellm/litellm_core_utils/get_llm_provider_logic.py index 33e0b47d84..7400a6819f 100644 --- a/litellm/litellm_core_utils/get_llm_provider_logic.py +++ b/litellm/litellm_core_utils/get_llm_provider_logic.py @@ -622,6 +622,14 @@ def _get_openai_compatible_provider_info( # noqa: PLR0915 or "https://api.galadriel.com/v1" ) # type: ignore dynamic_api_key = api_key or get_secret_str("GALADRIEL_API_KEY") + elif custom_llm_provider == "github_copilot": + ( + api_base, + dynamic_api_key, + custom_llm_provider, + ) = litellm.GithubCopilotConfig()._get_openai_compatible_provider_info( + model, api_base, api_key, custom_llm_provider + ) elif custom_llm_provider == "novita": api_base = ( api_base diff --git a/litellm/llms/github_copilot/authenticator.py b/litellm/llms/github_copilot/authenticator.py new file mode 100644 index 0000000000..fdf59a9a21 --- /dev/null +++ b/litellm/llms/github_copilot/authenticator.py @@ -0,0 +1,345 @@ +import json +import os +import time +from datetime import datetime +from typing import Any, Dict, Optional + +import httpx + +from litellm._logging import verbose_logger +from litellm.llms.custom_httpx.http_handler import _get_httpx_client + +from .common_utils import ( + APIKeyExpiredError, + GetAccessTokenError, + GetAPIKeyError, + GetDeviceCodeError, + RefreshAPIKeyError, +) + +# Constants +GITHUB_CLIENT_ID = "Iv1.b507a08c87ecfe98" +GITHUB_DEVICE_CODE_URL = "https://github.com/login/device/code" +GITHUB_ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token" +GITHUB_API_KEY_URL = "https://api.github.com/copilot_internal/v2/token" + + +class Authenticator: + def __init__(self) -> None: + """Initialize the GitHub Copilot authenticator with configurable token paths.""" + # Token storage paths + self.token_dir = os.getenv( + "GITHUB_COPILOT_TOKEN_DIR", + os.path.expanduser("~/.config/litellm/github_copilot"), + ) + self.access_token_file = os.path.join( + self.token_dir, + os.getenv("GITHUB_COPILOT_ACCESS_TOKEN_FILE", "access-token"), + ) + self.api_key_file = os.path.join( + self.token_dir, os.getenv("GITHUB_COPILOT_API_KEY_FILE", "api-key.json") + ) + self._ensure_token_dir() + + def get_access_token(self) -> str: + """ + Login to Copilot with retry 3 times. + + Returns: + str: The GitHub access token. + + 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 IOError: + verbose_logger.warning( + "No existing access token found or error reading file" + ) + + for attempt in range(3): + verbose_logger.debug(f"Access token acquisition attempt {attempt + 1}/3") + try: + access_token = self._login() + try: + with open(self.access_token_file, "w") as f: + f.write(access_token) + except IOError: + verbose_logger.error("Error saving access token to file") + return access_token + except (GetDeviceCodeError, GetAccessTokenError, RefreshAPIKeyError) as e: + verbose_logger.warning(f"Failed attempt {attempt + 1}: {str(e)}") + continue + + raise GetAccessTokenError( + message="Failed to get access token after 3 attempts", + status_code=401, + ) + + 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. + """ + 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 IOError: + verbose_logger.warning("No API key file found or error opening file") + except (json.JSONDecodeError, KeyError) as e: + verbose_logger.warning(f"Error reading API key from file: {str(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 = api_key_info.get("token") + if token: + return token + else: + raise GetAPIKeyError( + message="API key response missing token", + status_code=401, + ) + except IOError as e: + verbose_logger.error(f"Error saving API key to file: {str(e)}") + raise GetAPIKeyError( + message=f"Failed to save API key: {str(e)}", + status_code=500, + ) + except RefreshAPIKeyError as e: + raise GetAPIKeyError( + message=f"Failed to refresh API key: {str(e)}", + status_code=401, + ) + + 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 = self.get_access_token() + headers = self._get_github_headers(access_token) + + max_retries = 3 + for attempt in range(max_retries): + try: + sync_client = _get_httpx_client() + response = sync_client.get(GITHUB_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( + f"API key response missing token: {response_json}" + ) + except httpx.HTTPStatusError as e: + verbose_logger.error( + f"HTTP error refreshing API key (attempt {attempt+1}/{max_retries}): {str(e)}" + ) + except Exception as e: + verbose_logger.error(f"Unexpected error refreshing API key: {str(e)}") + + raise RefreshAPIKeyError( + message="Failed to refresh API key after maximum retries", + status_code=401, + ) + + 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: Optional[str] = None) -> Dict[str, str]: + """ + Generate standard GitHub headers for API requests. + + Args: + access_token: Optional access token to include in the headers. + + Returns: + Dict[str, str]: Headers for GitHub API requests. + """ + headers = { + "accept": "application/json", + "editor-version": "vscode/1.85.1", + "editor-plugin-version": "copilot/1.155.0", + "user-agent": "GithubCopilot/1.155.0", + "accept-encoding": "gzip,deflate,br", + } + + if access_token: + headers["authorization"] = f"token {access_token}" + + if "content-type" not in headers: + headers["content-type"] = "application/json" + + return headers + + def _get_device_code(self) -> Dict[str, str]: + """ + Get a device code for GitHub authentication. + + Returns: + Dict[str, str]: Device code information. + + Raises: + GetDeviceCodeError: If unable to get a device code. + """ + try: + sync_client = _get_httpx_client() + resp = sync_client.post( + GITHUB_DEVICE_CODE_URL, + headers=self._get_github_headers(), + json={"client_id": GITHUB_CLIENT_ID, "scope": "read:user"}, + ) + resp.raise_for_status() + resp_json = resp.json() + + required_fields = ["device_code", "user_code", "verification_uri"] + if not all(field in resp_json for field in required_fields): + verbose_logger.error(f"Response missing required fields: {resp_json}") + raise GetDeviceCodeError( + message="Response missing required fields", + status_code=400, + ) + + return resp_json + except httpx.HTTPStatusError as e: + verbose_logger.error(f"HTTP error getting device code: {str(e)}") + raise GetDeviceCodeError( + message=f"Failed to get device code: {str(e)}", + status_code=400, + ) + except json.JSONDecodeError as e: + verbose_logger.error(f"Error decoding JSON response: {str(e)}") + raise GetDeviceCodeError( + message=f"Failed to decode device code response: {str(e)}", + status_code=400, + ) + except Exception as e: + verbose_logger.error(f"Unexpected error getting device code: {str(e)}") + raise GetDeviceCodeError( + message=f"Failed to get device code: {str(e)}", + status_code=400, + ) + + def _poll_for_access_token(self, device_code: str) -> str: + """ + Poll for an access token after user authentication. + + Args: + device_code: The device code to use for polling. + + Returns: + str: The access token. + + Raises: + GetAccessTokenError: If unable to get an access token. + """ + sync_client = _get_httpx_client() + max_attempts = 12 # 1 minute (12 * 5 seconds) + + for attempt in range(max_attempts): + try: + resp = sync_client.post( + GITHUB_ACCESS_TOKEN_URL, + headers=self._get_github_headers(), + json={ + "client_id": GITHUB_CLIENT_ID, + "device_code": device_code, + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + }, + ) + resp.raise_for_status() + resp_json = resp.json() + + if "access_token" in resp_json: + verbose_logger.info("Authentication successful!") + return resp_json["access_token"] + elif ( + "error" in resp_json + and resp_json.get("error") == "authorization_pending" + ): + verbose_logger.debug( + f"Authorization pending (attempt {attempt+1}/{max_attempts})" + ) + else: + verbose_logger.warning(f"Unexpected response: {resp_json}") + except httpx.HTTPStatusError as e: + verbose_logger.error(f"HTTP error polling for access token: {str(e)}") + raise GetAccessTokenError( + message=f"Failed to get access token: {str(e)}", + status_code=400, + ) + except json.JSONDecodeError as e: + verbose_logger.error(f"Error decoding JSON response: {str(e)}") + raise GetAccessTokenError( + message=f"Failed to decode access token response: {str(e)}", + status_code=400, + ) + except Exception as e: + verbose_logger.error( + f"Unexpected error polling for access token: {str(e)}" + ) + raise GetAccessTokenError( + message=f"Failed to get access token: {str(e)}", + status_code=400, + ) + + time.sleep(5) + + raise GetAccessTokenError( + message="Timed out waiting for user to authorize the device", + status_code=400, + ) + + def _login(self) -> str: + """ + Login to GitHub Copilot using device code flow. + + Returns: + str: The GitHub access token. + + Raises: + GetDeviceCodeError: If unable to get a device code. + GetAccessTokenError: If unable to get an access token. + """ + device_code_info = self._get_device_code() + + device_code = device_code_info["device_code"] + user_code = device_code_info["user_code"] + verification_uri = device_code_info["verification_uri"] + + print( # noqa: T201 + f"Please visit {verification_uri} and enter code {user_code} to authenticate." + ) + + return self._poll_for_access_token(device_code) diff --git a/litellm/llms/github_copilot/chat/transformation.py b/litellm/llms/github_copilot/chat/transformation.py new file mode 100644 index 0000000000..395eb81c5b --- /dev/null +++ b/litellm/llms/github_copilot/chat/transformation.py @@ -0,0 +1,37 @@ +from typing import Optional, Tuple + +from litellm.exceptions import AuthenticationError +from litellm.llms.openai.openai import OpenAIConfig + +from ..authenticator import Authenticator +from ..common_utils import GetAPIKeyError + + +class GithubCopilotConfig(OpenAIConfig): + GITHUB_COPILOT_API_BASE = "https://api.github.com/copilot/v1" + def __init__( + self, + api_key: Optional[str] = None, + api_base: Optional[str] = None, + custom_llm_provider: str = "openai", + ) -> None: + super().__init__() + self.authenticator = Authenticator() + + def _get_openai_compatible_provider_info( + self, + model: str, + api_base: Optional[str], + api_key: Optional[str], + custom_llm_provider: str, + ) -> Tuple[Optional[str], Optional[str], str]: + api_base = self.GITHUB_COPILOT_API_BASE + try: + dynamic_api_key = self.authenticator.get_api_key() + except GetAPIKeyError as e: + raise AuthenticationError( + model=model, + llm_provider=custom_llm_provider, + message=str(e), + ) + return api_base, dynamic_api_key, custom_llm_provider diff --git a/litellm/llms/github_copilot/common_utils.py b/litellm/llms/github_copilot/common_utils.py new file mode 100644 index 0000000000..4c9a4b6dad --- /dev/null +++ b/litellm/llms/github_copilot/common_utils.py @@ -0,0 +1,49 @@ +""" +Constants for Copilot integration +""" +from typing import Optional, Union + +import httpx + +from litellm.llms.base_llm.chat.transformation import BaseLLMException + + +class GithubCopilotError(BaseLLMException): + def __init__( + self, + status_code, + message, + request: Optional[httpx.Request] = None, + response: Optional[httpx.Response] = None, + headers: Optional[Union[httpx.Headers, dict]] = None, + body: Optional[dict] = None, + ): + super().__init__( + status_code=status_code, + message=message, + request=request, + response=response, + headers=headers, + body=body, + ) + + + +class GetDeviceCodeError(GithubCopilotError): + pass + + +class GetAccessTokenError(GithubCopilotError): + pass + + +class APIKeyExpiredError(GithubCopilotError): + pass + + +class RefreshAPIKeyError(GithubCopilotError): + pass + + +class GetAPIKeyError(GithubCopilotError): + pass diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 3f5ea0785c..4ff678d329 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -2310,6 +2310,7 @@ class LlmProviders(str, Enum): HUMANLOOP = "humanloop" TOPAZ = "topaz" ASSEMBLYAI = "assemblyai" + GITHUB_COPILOT = "github_copilot" SNOWFLAKE = "snowflake" LLAMA = "meta_llama" NSCALE = "nscale" diff --git a/tests/__init__.py b/tests/__init__.py index e69de29bb2..9360c76fa8 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1 @@ +# This file makes the tests directory a Python package diff --git a/tests/test_litellm/__init__.py b/tests/test_litellm/__init__.py index e69de29bb2..a2f3cf8de0 100644 --- a/tests/test_litellm/__init__.py +++ b/tests/test_litellm/__init__.py @@ -0,0 +1 @@ +# This file makes the tests/litellm directory a Python package diff --git a/tests/test_litellm/litellm_core_utils/__init__.py b/tests/test_litellm/litellm_core_utils/__init__.py index e69de29bb2..8c64613a5d 100644 --- a/tests/test_litellm/litellm_core_utils/__init__.py +++ b/tests/test_litellm/litellm_core_utils/__init__.py @@ -0,0 +1 @@ +# This file makes the tests/litellm/litellm_core_utils directory a Python package diff --git a/tests/test_litellm/llms/github_copilot/test_github_copilot_authenticator.py b/tests/test_litellm/llms/github_copilot/test_github_copilot_authenticator.py new file mode 100644 index 0000000000..65413d46b9 --- /dev/null +++ b/tests/test_litellm/llms/github_copilot/test_github_copilot_authenticator.py @@ -0,0 +1,180 @@ +import json +import os +import time +from datetime import datetime, timedelta +from unittest.mock import MagicMock, mock_open, patch + +import pytest + +from litellm.llms.github_copilot.authenticator import Authenticator +from litellm.llms.github_copilot.common_utils import ( + APIKeyExpiredError, + GetAccessTokenError, + GetAPIKeyError, + GetDeviceCodeError, + RefreshAPIKeyError, +) + + +class TestGitHubCopilotAuthenticator: + @pytest.fixture + def authenticator(self): + with patch("os.path.exists", return_value=False), patch("os.makedirs") as mock_makedirs: + auth = Authenticator() + mock_makedirs.assert_called_once() + return auth + + @pytest.fixture + def mock_http_client(self): + mock_client = MagicMock() + mock_response = MagicMock() + mock_client.get.return_value = mock_response + mock_client.post.return_value = mock_response + mock_response.raise_for_status.return_value = None + return mock_client, mock_response + + def test_init(self): + """Test the initialization of the authenticator.""" + with patch("os.path.exists", return_value=False), patch("os.makedirs") as mock_makedirs: + auth = Authenticator() + assert auth.token_dir.endswith("/github_copilot") + assert auth.access_token_file.endswith("/access-token") + assert auth.api_key_file.endswith("/api-key.json") + mock_makedirs.assert_called_once() + + def test_ensure_token_dir(self): + """Test that the token directory is created if it doesn't exist.""" + with patch("os.path.exists", return_value=False), patch("os.makedirs") as mock_makedirs: + auth = Authenticator() + 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 + + headers_with_token = authenticator._get_github_headers("test-token") + assert headers_with_token["authorization"] == "token test-token" + + def test_get_access_token_from_file(self, authenticator): + """Test retrieving an access token from a file.""" + mock_token = "mock-access-token" + + with patch("builtins.open", mock_open(read_data=mock_token)): + token = authenticator.get_access_token() + 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" + + with patch.object(authenticator, "_login", return_value=mock_token), \ + patch("builtins.open", mock_open()), \ + patch("builtins.open", side_effect=IOError) as mock_read: + token = authenticator.get_access_token() + assert token == mock_token + authenticator._login.assert_called_once() + + def test_get_access_token_failure(self, authenticator): + """Test that an exception is raised after multiple login failures.""" + with patch.object(authenticator, "_login", side_effect=GetDeviceCodeError(message="Test error", status_code=400)), \ + patch("builtins.open", side_effect=IOError): + with pytest.raises(GetAccessTokenError): + 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: + 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 + + def test_get_device_code(self, authenticator, mock_http_client): + """Test getting a device code.""" + mock_client, mock_response = mock_http_client + mock_device_code_data = { + "device_code": "mock-device-code", + "user_code": "ABCD-EFGH", + "verification_uri": "https://github.com/login/device" + } + + with patch("litellm.llms.github_copilot.authenticator._get_httpx_client", return_value=mock_client), \ + patch.object(mock_response, "json", return_value=mock_device_code_data): + result = authenticator._get_device_code() + assert result == mock_device_code_data + mock_client.post.assert_called_once() + + def test_poll_for_access_token(self, authenticator, mock_http_client): + """Test polling for an access token.""" + mock_client, mock_response = mock_http_client + mock_token_data = {"access_token": "mock-access-token"} + + with patch("litellm.llms.github_copilot.authenticator._get_httpx_client", return_value=mock_client), \ + patch.object(mock_response, "json", return_value=mock_token_data), \ + patch("time.sleep"): + result = authenticator._poll_for_access_token("mock-device-code") + assert result == "mock-access-token" + mock_client.post.assert_called_once() + + def test_login(self, authenticator): + """Test the login process.""" + mock_device_code_data = { + "device_code": "mock-device-code", + "user_code": "ABCD-EFGH", + "verification_uri": "https://github.com/login/device" + } + 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("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") + mock_print.assert_called_once() diff --git a/tests/test_litellm/llms/github_copilot/test_github_copilot_transformation.py b/tests/test_litellm/llms/github_copilot/test_github_copilot_transformation.py new file mode 100644 index 0000000000..0e5a149f04 --- /dev/null +++ b/tests/test_litellm/llms/github_copilot/test_github_copilot_transformation.py @@ -0,0 +1,128 @@ +import asyncio +import json +import os +import sys +from datetime import datetime, timedelta +from typing import AsyncGenerator +from unittest.mock import AsyncMock, MagicMock, mock_open, patch + +import pytest + +sys.path.insert(0, os.path.abspath("../..")) + +import httpx +import pytest +from respx import MockRouter + +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.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, +) + + +def test_github_copilot_config_get_openai_compatible_provider_info(): + """Test the GitHub Copilot configuration provider info retrieval.""" + + config = GithubCopilotConfig() + + # Mock the authenticator to avoid actual API calls + mock_api_key = "gh.test-key-123456789" + config.authenticator = MagicMock() + config.authenticator.get_api_key.return_value = mock_api_key + + # Test with default values + model = "github_copilot/gpt-4" + ( + api_base, + dynamic_api_key, + custom_llm_provider, + ) = config._get_openai_compatible_provider_info( + model=model, + api_base=None, + api_key=None, + custom_llm_provider="github_copilot", + ) + + assert api_base == "https://api.github.com/copilot/v1" + assert dynamic_api_key == mock_api_key + assert custom_llm_provider == "github_copilot" + + # Test with authentication failure + config.authenticator.get_api_key.side_effect = GetAPIKeyError( + message="Failed to get API key", + status_code=401, + ) + + with pytest.raises(AuthenticationError) as excinfo: + config._get_openai_compatible_provider_info( + model=model, + api_base=None, + api_key=None, + custom_llm_provider="github_copilot", + ) + + assert "Failed to get API key" in str(excinfo.value) + + +@patch("litellm.llms.github_copilot.authenticator.Authenticator.get_api_key") +@patch("litellm.llms.openai.openai.OpenAIChatCompletion.completion") +def test_completion_github_copilot_mock_response(mock_completion, mock_get_api_key): + """Test the completion function with GitHub Copilot provider.""" + + # Mock the API key return value + mock_api_key = "gh.test-key-123456789" + mock_get_api_key.return_value = mock_api_key + + # Mock completion response + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "Hello, I'm GitHub Copilot!" + mock_completion.return_value = mock_response + + # Test non-streaming completion + messages = [ + {"role": "system", "content": "You're GitHub Copilot, an AI assistant."}, + {"role": "user", "content": "Hello, who are you?"}, + ] + + # Create a properly formatted headers dictionary + headers = { + "editor-version": "Neovim/0.9.0", + "Copilot-Integration-Id": "vscode-chat", + } + + response = completion( + model="github_copilot/gpt-4", + messages=messages, + extra_headers=headers, + ) + + assert response is not None + + # Verify the get_api_key call was made (can be called multiple times) + assert mock_get_api_key.call_count >= 1 + + # Verify the completion call was made with the expected params + mock_completion.assert_called_once() + args, kwargs = mock_completion.call_args + + # Check that the proper authorization header is set + assert "headers" in kwargs + # Check that the model name is correctly formatted + assert ( + kwargs.get("model") == "gpt-4" + ) # Model name should be without provider prefix + assert kwargs.get("messages") == messages + +