feat(github-copilot): Add Responses API support for gpt-5.1-codex model (#16845)

- Implement GithubCopilotResponsesAPIConfig for /responses endpoint
- Add support for models requiring responses API (e.g., gpt-5.1-codex)
- Auto-detect vision requests and set X-Initiator header
- Follow OpenAI Responses API compatibility pattern
- Add comprehensive unit tests (16 tests passing)

Fixes #16820
This commit is contained in:
Naki 2025-11-20 06:17:19 +09:00 committed by GitHub
parent 3ebe489082
commit 98dd866b26
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 610 additions and 0 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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"