diff --git a/litellm/__init__.py b/litellm/__init__.py index c86768490f3..2624565c2c3 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -1342,6 +1342,7 @@ 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.github_copilot.responses.transformation import GithubCopilotResponsesAPIConfig from .llms.nebius.chat.transformation import NebiusConfig from .llms.wandb.chat.transformation import WandbConfig from .llms.dashscope.chat.transformation import DashScopeChatConfig diff --git a/litellm/llms/github_copilot/responses/transformation.py b/litellm/llms/github_copilot/responses/transformation.py new file mode 100644 index 00000000000..a85c37fd9b8 --- /dev/null +++ b/litellm/llms/github_copilot/responses/transformation.py @@ -0,0 +1,304 @@ +""" +GitHub Copilot Responses API Configuration. + +This module provides the configuration for GitHub Copilot's Responses API, +which is required for models like gpt-5.1-codex that only support the /responses endpoint. + +Implementation based on analysis of the copilot-api project by caozhiyuan: +https://github.com/caozhiyuan/copilot-api +""" +from typing import TYPE_CHECKING, Any, Dict, Optional, Union +from uuid import uuid4 + +from litellm._logging import verbose_logger +from litellm.exceptions import AuthenticationError +from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig +from litellm.types.llms.openai import ( + ResponseInputParam, + ResponsesAPIOptionalRequestParams, +) +from litellm.types.router import GenericLiteLLMParams +from litellm.types.utils import LlmProviders + +from ..authenticator import Authenticator +from ..common_utils import GetAPIKeyError + +if TYPE_CHECKING: + from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj + + LiteLLMLoggingObj = _LiteLLMLoggingObj +else: + LiteLLMLoggingObj = Any + +# GitHub Copilot API Constants (from copilot-api) +COPILOT_VERSION = "0.26.7" +EDITOR_PLUGIN_VERSION = f"copilot-chat/{COPILOT_VERSION}" +USER_AGENT = f"GitHubCopilotChat/{COPILOT_VERSION}" +API_VERSION = "2025-04-01" + + +class GithubCopilotResponsesAPIConfig(OpenAIResponsesAPIConfig): + """ + Configuration for GitHub Copilot's Responses API. + + Inherits from OpenAIResponsesAPIConfig since GitHub Copilot's Responses API + is compatible with OpenAI's Responses API specification. + + Key differences from OpenAI: + - Uses OAuth Device Flow authentication (handled by Authenticator) + - Uses api.githubcopilot.com as the API base + - Requires specific headers for VSCode/Copilot integration + - Supports vision requests with special header + - Requires X-Initiator header based on input analysis + + Reference: https://api.githubcopilot.com/ + """ + + GITHUB_COPILOT_API_BASE = "https://api.githubcopilot.com" + + def __init__(self) -> None: + super().__init__() + self.authenticator = Authenticator() + + @property + def custom_llm_provider(self) -> LlmProviders: + """Return the GitHub Copilot provider identifier.""" + return LlmProviders.GITHUB_COPILOT + + def get_supported_openai_params(self, model: str) -> list: + """ + Get supported parameters for GitHub Copilot Responses API. + + GitHub Copilot supports all standard OpenAI Responses API parameters. + """ + return super().get_supported_openai_params(model) + + def map_openai_params( + self, + response_api_optional_params: ResponsesAPIOptionalRequestParams, + model: str, + drop_params: bool, + ) -> Dict: + """ + Map parameters for GitHub Copilot Responses API. + + GitHub Copilot uses the same parameter format as OpenAI, + so no transformation is needed. + """ + return dict(response_api_optional_params) + + def validate_environment( + self, + headers: dict, + model: str, + litellm_params: Optional[GenericLiteLLMParams], + ) -> dict: + """ + Validate environment and set up headers for GitHub Copilot API. + + Uses the Authenticator to obtain GitHub Copilot API key via OAuth Device Flow, + then configures all required headers for the Responses API. + + Headers include: + - Authorization with API key + - Standard GitHub Copilot headers (editor-version, user-agent, etc.) + - X-Initiator based on input analysis + - copilot-vision-request if vision content detected + - User-provided extra_headers (merged with priority) + """ + try: + # Get GitHub Copilot API key via OAuth + api_key = self.authenticator.get_api_key() + + if not api_key: + raise AuthenticationError( + model=model, + llm_provider="github_copilot", + message="GitHub Copilot API key is required. Please authenticate via OAuth Device Flow.", + ) + + # Get default headers (from copilot-api configuration) + default_headers = self._get_default_headers(api_key) + + # Merge with existing headers (user's extra_headers take priority) + merged_headers = {**default_headers, **headers} + + # Analyze input to determine additional headers + input_param = self._get_input_from_params(litellm_params) + + # Add X-Initiator header based on input analysis + if input_param is not None: + initiator = self._get_initiator(input_param) + merged_headers["X-Initiator"] = initiator + verbose_logger.debug( + f"GitHub Copilot Responses API: Set X-Initiator={initiator}" + ) + + # Add vision header if input contains images + if self._has_vision_input(input_param): + merged_headers["copilot-vision-request"] = "true" + verbose_logger.debug( + "GitHub Copilot Responses API: Enabled vision request" + ) + + verbose_logger.debug( + f"GitHub Copilot Responses API: Successfully configured headers for model {model}" + ) + + return merged_headers + + except GetAPIKeyError as e: + raise AuthenticationError( + model=model, + llm_provider="github_copilot", + message=str(e), + ) + + def get_complete_url( + self, + api_base: Optional[str], + litellm_params: dict, + ) -> str: + """ + Get the complete URL for GitHub Copilot Responses API endpoint. + + Returns: https://api.githubcopilot.com/responses + + Note: Currently only supports individual accounts. + Business/enterprise accounts (api.business.githubcopilot.com) can be + added in the future by detecting account type. + """ + # Use provided api_base or fall back to authenticator's base or default + api_base = ( + api_base + or self.authenticator.get_api_base() + or self.GITHUB_COPILOT_API_BASE + ) + + # Remove trailing slashes + api_base = api_base.rstrip("/") + + # Return the responses endpoint + return f"{api_base}/responses" + + # ==================== Helper Methods ==================== + + def _get_default_headers(self, api_key: str) -> Dict[str, str]: + """ + Get default headers for GitHub Copilot Responses API. + + 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_input_from_params( + self, litellm_params: Optional[GenericLiteLLMParams] + ) -> Optional[Union[str, ResponseInputParam]]: + """ + Extract input parameter from litellm_params. + + The input parameter contains the conversation history and is needed + for vision detection and initiator determination. + """ + if litellm_params is None: + return None + + # Try to get input from litellm_params + # This might be in different locations depending on how LiteLLM structures it + if hasattr(litellm_params, "input"): + return litellm_params.input + + # If not found, return None and let the API handle it + return None + + def _get_initiator(self, input_param: Union[str, ResponseInputParam]) -> str: + """ + Determine X-Initiator header value based on input analysis. + + Based on copilot-api's hasAgentInitiator logic: + - Returns "agent" if input contains assistant role or items without role + - Returns "user" otherwise + + Args: + input_param: The input parameter (string or list of input items) + + Returns: + "agent" or "user" + """ + # If input is a string, it's user-initiated + if isinstance(input_param, str): + return "user" + + # If input is a list, analyze items + if isinstance(input_param, list): + for item in input_param: + if not isinstance(item, dict): + continue + + # Check if item has no role (agent-initiated) + if "role" not in item or not item.get("role"): + return "agent" + + # Check if role is assistant (agent-initiated) + role = item.get("role") + if isinstance(role, str) and role.lower() == "assistant": + return "agent" + + # Default to user-initiated + return "user" + + def _has_vision_input(self, input_param: Union[str, ResponseInputParam]) -> bool: + """ + Check if input contains vision content (images). + + Based on copilot-api's hasVisionInput and containsVisionContent logic. + Recursively searches for input_image type in the input structure. + + Args: + input_param: The input parameter to analyze + + Returns: + True if input contains image content, False otherwise + """ + return self._contains_vision_content(input_param) + + def _contains_vision_content(self, value: Any) -> bool: + """ + Recursively check if a value contains vision content. + + Looks for items with type="input_image" in the structure. + """ + if value is None: + return False + + # Check arrays + if isinstance(value, list): + return any(self._contains_vision_content(item) for item in value) + + # Only check dict/object types + if not isinstance(value, dict): + return False + + # Check if this item is an input_image + item_type = value.get("type") + if isinstance(item_type, str) and item_type.lower() == "input_image": + return True + + # Check content field recursively + if "content" in value and isinstance(value["content"], list): + return any( + self._contains_vision_content(item) for item in value["content"] + ) + + return False diff --git a/litellm/utils.py b/litellm/utils.py index 47fd06a9f27..2e80a0bb5bf 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -7389,6 +7389,8 @@ class ProviderConfigManager: return litellm.AzureOpenAIResponsesAPIConfig() elif litellm.LlmProviders.XAI == provider: return litellm.XAIResponsesAPIConfig() + elif litellm.LlmProviders.GITHUB_COPILOT == provider: + return litellm.GithubCopilotResponsesAPIConfig() elif litellm.LlmProviders.LITELLM_PROXY == provider: return litellm.LiteLLMProxyResponsesAPIConfig() return None diff --git a/tests/test_litellm/llms/github_copilot/responses/test_github_copilot_responses_transformation.py b/tests/test_litellm/llms/github_copilot/responses/test_github_copilot_responses_transformation.py new file mode 100644 index 00000000000..d6032c61c60 --- /dev/null +++ b/tests/test_litellm/llms/github_copilot/responses/test_github_copilot_responses_transformation.py @@ -0,0 +1,303 @@ +""" +Tests for GitHub Copilot Responses API transformation + +Tests the GithubCopilotResponsesAPIConfig class that handles GitHub Copilot-specific +transformations for the Responses API. + +Source: litellm/llms/github_copilot/responses/transformation.py +""" +import sys +import os +from unittest.mock import patch, MagicMock + +sys.path.insert(0, os.path.abspath("../../../../..")) + +import pytest +from litellm.types.utils import LlmProviders +from litellm.utils import ProviderConfigManager +from litellm.llms.github_copilot.responses.transformation import ( + GithubCopilotResponsesAPIConfig, +) +from litellm.types.llms.openai import ResponsesAPIOptionalRequestParams + + +class TestGithubCopilotResponsesAPITransformation: + """Test GitHub Copilot Responses API configuration and transformations""" + + def test_github_copilot_provider_config_registration(self): + """Test that GitHub Copilot provider returns GithubCopilotResponsesAPIConfig""" + config = ProviderConfigManager.get_provider_responses_api_config( + model="github_copilot/gpt-5.1-codex", + provider=LlmProviders.GITHUB_COPILOT, + ) + + assert ( + config is not None + ), "Config should not be None for GitHub Copilot provider" + assert isinstance( + config, GithubCopilotResponsesAPIConfig + ), f"Expected GithubCopilotResponsesAPIConfig, got {type(config)}" + assert ( + config.custom_llm_provider == LlmProviders.GITHUB_COPILOT + ), "custom_llm_provider should be GITHUB_COPILOT" + + @patch("litellm.llms.github_copilot.responses.transformation.Authenticator") + def test_github_copilot_responses_endpoint_url(self, mock_authenticator_class): + """Test that get_complete_url returns correct GitHub Copilot endpoint""" + # Mock authenticator to return default base + mock_auth_instance = MagicMock() + mock_auth_instance.get_api_base.return_value = ( + "https://api.individual.githubcopilot.com" + ) + mock_authenticator_class.return_value = mock_auth_instance + + config = GithubCopilotResponsesAPIConfig() + + # Test with default GitHub Copilot API base (from authenticator) + url = config.get_complete_url(api_base=None, litellm_params={}) + assert url == "https://api.individual.githubcopilot.com/responses", ( + f"Expected GitHub Copilot responses endpoint, got {url}" + ) + + # Test with custom api_base (overrides authenticator) + custom_url = config.get_complete_url( + api_base="https://custom.githubcopilot.com", litellm_params={} + ) + assert custom_url == "https://custom.githubcopilot.com/responses", ( + f"Expected custom endpoint, got {custom_url}" + ) + + # Test with trailing slash + url_with_slash = config.get_complete_url( + api_base="https://api.githubcopilot.com/", litellm_params={} + ) + assert url_with_slash == "https://api.githubcopilot.com/responses", ( + "Should handle trailing slash" + ) + + @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 + 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["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 + + @patch("litellm.llms.github_copilot.responses.transformation.Authenticator") + def test_validate_environment_user_headers_override(self, mock_authenticator_class): + """Test that user-provided headers override default headers""" + mock_auth_instance = MagicMock() + mock_auth_instance.get_api_key.return_value = "test-api-key-123" + mock_authenticator_class.return_value = mock_auth_instance + + config = GithubCopilotResponsesAPIConfig() + + custom_headers = { + "editor-version": "custom/2.0.0", + "custom-header": "custom-value", + } + + headers = config.validate_environment( + headers=custom_headers, model="gpt-5.1-codex", litellm_params={} + ) + + # User header should override default + assert headers["editor-version"] == "custom/2.0.0" + # Custom header should be preserved + assert headers["custom-header"] == "custom-value" + # Default headers should still be present + assert headers["Authorization"] == "Bearer test-api-key-123" + + def test_get_initiator_with_assistant_role(self): + """Test _get_initiator returns 'agent' for assistant role""" + config = GithubCopilotResponsesAPIConfig() + + input_with_assistant = [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi there"}, + ] + + initiator = config._get_initiator(input_with_assistant) + assert initiator == "agent", "Should return 'agent' for assistant role" + + def test_get_initiator_with_no_role(self): + """Test _get_initiator returns 'agent' for items without role""" + config = GithubCopilotResponsesAPIConfig() + + input_without_role = [ + {"role": "user", "content": "Hello"}, + {"type": "reasoning", "content": "thinking..."}, # No role field + ] + + initiator = config._get_initiator(input_without_role) + assert initiator == "agent", "Should return 'agent' for items without role" + + def test_get_initiator_with_user_only(self): + """Test _get_initiator returns 'user' for user-only messages""" + config = GithubCopilotResponsesAPIConfig() + + input_user_only = [{"role": "user", "content": "Hello"}] + + initiator = config._get_initiator(input_user_only) + assert initiator == "user", "Should return 'user' for user-only messages" + + def test_get_initiator_with_string_input(self): + """Test _get_initiator returns 'user' for string input""" + config = GithubCopilotResponsesAPIConfig() + + initiator = config._get_initiator("Hello, how are you?") + assert initiator == "user", "Should return 'user' for string input" + + def test_has_vision_input_with_input_image(self): + """Test _has_vision_input detects input_image type""" + config = GithubCopilotResponsesAPIConfig() + + input_with_vision = [ + {"role": "user", "content": [{"type": "input_image", "data": "base64..."}]} + ] + + has_vision = config._has_vision_input(input_with_vision) + assert has_vision is True, "Should detect input_image type" + + def test_has_vision_input_nested(self): + """Test _has_vision_input detects nested input_image""" + config = GithubCopilotResponsesAPIConfig() + + input_nested_vision = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "What's in this image?"}, + { + "type": "multipart", + "content": [{"type": "input_image", "data": "base64..."}], + }, + ], + } + ] + + has_vision = config._has_vision_input(input_nested_vision) + assert has_vision is True, "Should detect nested input_image" + + def test_has_vision_input_without_vision(self): + """Test _has_vision_input returns False for text-only input""" + config = GithubCopilotResponsesAPIConfig() + + input_text_only = [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi"}, + ] + + has_vision = config._has_vision_input(input_text_only) + assert has_vision is False, "Should return False for text-only input" + + def test_has_vision_input_with_string(self): + """Test _has_vision_input returns False for string input""" + config = GithubCopilotResponsesAPIConfig() + + has_vision = config._has_vision_input("Just a text message") + assert has_vision is False, "Should return False for string input" + + @patch("litellm.llms.github_copilot.responses.transformation.Authenticator") + def test_validate_environment_with_vision_header(self, mock_authenticator_class): + """Test that copilot-vision-request header is added for vision input""" + mock_auth_instance = MagicMock() + mock_auth_instance.get_api_key.return_value = "test-api-key" + mock_authenticator_class.return_value = mock_auth_instance + + config = GithubCopilotResponsesAPIConfig() + + # Create mock litellm_params with input attribute + mock_litellm_params = MagicMock() + mock_litellm_params.input = [ + { + "role": "user", + "content": [{"type": "input_image", "data": "base64..."}], + } + ] + + headers = config.validate_environment( + headers={}, model="gpt-5.1-codex", litellm_params=mock_litellm_params + ) + + assert headers.get("copilot-vision-request") == "true", ( + "Should add copilot-vision-request header for vision input" + ) + + @patch("litellm.llms.github_copilot.responses.transformation.Authenticator") + def test_validate_environment_with_x_initiator(self, mock_authenticator_class): + """Test that X-Initiator header is set based on input""" + mock_auth_instance = MagicMock() + mock_auth_instance.get_api_key.return_value = "test-api-key" + mock_authenticator_class.return_value = mock_auth_instance + + config = GithubCopilotResponsesAPIConfig() + + # Create mock litellm_params with input attribute + mock_litellm_params = MagicMock() + mock_litellm_params.input = [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi"}, + ] + + headers = config.validate_environment( + headers={}, model="gpt-5.1-codex", litellm_params=mock_litellm_params + ) + + assert headers.get("X-Initiator") == "agent", ( + "Should set X-Initiator to 'agent' for assistant role" + ) + + def test_map_openai_params_no_transformation(self): + """Test that map_openai_params passes through parameters unchanged""" + config = GithubCopilotResponsesAPIConfig() + + params = ResponsesAPIOptionalRequestParams( + temperature=0.7, max_output_tokens=1000, stream=False + ) + + result = config.map_openai_params( + response_api_optional_params=params, model="gpt-5.1-codex", drop_params=False + ) + + assert result.get("temperature") == 0.7 + assert result.get("max_output_tokens") == 1000 + assert result.get("stream") is False + + def test_get_supported_openai_params(self): + """Test that get_supported_openai_params returns expected parameters""" + config = GithubCopilotResponsesAPIConfig() + + supported = config.get_supported_openai_params("gpt-5.1-codex") + + # Should include standard OpenAI Responses API parameters + expected_params = [ + "model", + "input", + "instructions", + "temperature", + "max_output_tokens", + "stream", + "tools", + "tool_choice", + ] + + for param in expected_params: + assert param in supported, f"{param} should be in supported params"