fix(github-copilot): make client headers configurable

This commit is contained in:
codgician 2026-07-28 01:19:42 +08:00
parent 5290150a05
commit e264b893bf
No known key found for this signature in database
5 changed files with 169 additions and 60 deletions

View file

@ -15,6 +15,7 @@ from .common_utils import (
GetAPIKeyError,
GetDeviceCodeError,
RefreshAPIKeyError,
get_copilot_default_headers,
)
# Constants (default values — overridable via environment variables at call time)
@ -188,30 +189,7 @@ class Authenticator:
os.makedirs(self.token_dir, exist_ok=True)
def _get_github_headers(self, access_token: str | None = None) -> dict[str, str]:
"""
Generate standard GitHub headers for API requests.
Args:
access_token: Optional access token to include in the headers.
Returns:
Dict[str, str]: Headers for GitHub API requests.
"""
headers: Final = {
"accept": "application/json",
"editor-version": "vscode/1.85.1",
"editor-plugin-version": "copilot/1.155.0",
"user-agent": "GithubCopilot/1.155.0",
"accept-encoding": "gzip,deflate,br",
}
if access_token:
headers["authorization"] = f"token {access_token}"
if "content-type" not in headers:
headers["content-type"] = "application/json"
return headers
return get_copilot_default_headers(access_token=access_token)
def _get_device_code(self) -> dict[str, str]:
"""

View file

@ -2,6 +2,7 @@
Constants for Copilot integration
"""
import os
from typing import Final
from uuid import uuid4
@ -9,12 +10,31 @@ import httpx
from litellm.llms.base_llm.chat.transformation import BaseLLMException
# Constants
COPILOT_VERSION: Final = "0.26.7"
EDITOR_PLUGIN_VERSION: Final = f"copilot-chat/{COPILOT_VERSION}"
USER_AGENT: Final = f"GitHubCopilotChat/{COPILOT_VERSION}"
API_VERSION: Final = "2025-04-01"
DEFAULT_GITHUB_COPILOT_API_BASE: Final = "https://api.githubcopilot.com"
DEFAULT_COPILOT_INTEGRATION_ID: Final = "vscode-chat"
DEFAULT_COPILOT_EDITOR_VERSION: Final = "vscode/1.115.0"
DEFAULT_COPILOT_EDITOR_PLUGIN_VERSION: Final = EDITOR_PLUGIN_VERSION
DEFAULT_COPILOT_USER_AGENT: Final = USER_AGENT
_COPILOT_HEADER_CONFIG = (
("accept", "GITHUB_COPILOT_ACCEPT", "application/json"),
("content-type", "GITHUB_COPILOT_CONTENT_TYPE", "application/json"),
("copilot-integration-id", "GITHUB_COPILOT_INTEGRATION_ID", DEFAULT_COPILOT_INTEGRATION_ID),
("editor-version", "GITHUB_COPILOT_EDITOR_VERSION", DEFAULT_COPILOT_EDITOR_VERSION),
("editor-plugin-version", "GITHUB_COPILOT_EDITOR_PLUGIN_VERSION", DEFAULT_COPILOT_EDITOR_PLUGIN_VERSION),
("user-agent", "GITHUB_COPILOT_USER_AGENT", DEFAULT_COPILOT_USER_AGENT),
("openai-intent", "GITHUB_COPILOT_OPENAI_INTENT", None),
("x-github-api-version", "GITHUB_COPILOT_API_VERSION", None),
(
"x-vscode-user-agent-library-version",
"GITHUB_COPILOT_USER_AGENT_LIBRARY_VERSION",
None,
),
)
class GithubCopilotError(BaseLLMException):
@ -57,21 +77,23 @@ class GetAPIKeyError(GithubCopilotError):
pass
def get_copilot_default_headers(api_key: str) -> dict:
"""
Get default headers for GitHub Copilot Responses API.
def _get_copilot_header_value(environment_variable: str, default: str | None) -> str | None:
value = os.getenv(environment_variable)
if value is None:
return default
return value or None
Based on copilot-api's header configuration.
"""
return {
"Authorization": f"Bearer {api_key}",
"content-type": "application/json",
"copilot-integration-id": "vscode-chat",
"editor-version": "vscode/1.95.0", # Fixed version for stability
"editor-plugin-version": EDITOR_PLUGIN_VERSION,
"user-agent": USER_AGENT,
"openai-intent": "conversation-panel",
"x-github-api-version": API_VERSION,
"x-request-id": str(uuid4()),
"x-vscode-user-agent-library-version": "electron-fetch",
def get_copilot_default_headers(
api_key: str | None = None,
*,
access_token: str | None = None,
) -> dict[str, str]:
configured_headers = {
header: value
for header, environment_variable, default in _COPILOT_HEADER_CONFIG
if (value := _get_copilot_header_value(environment_variable, default)) is not None
}
authorization = f"token {access_token}" if access_token else f"Bearer {api_key}" if api_key else None
authorization_header = {"Authorization": authorization} if authorization else {}
return {**configured_headers, **authorization_header}

View file

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

View file

@ -92,29 +92,80 @@ class TestGithubCopilotResponsesAPITransformation:
), "Should handle trailing slash"
@patch("litellm.llms.github_copilot.responses.transformation.Authenticator")
def test_validate_environment_default_headers(self, mock_authenticator_class):
"""Test that validate_environment generates correct default headers"""
# Mock the authenticator
def test_validate_environment_default_headers(self, mock_authenticator_class, monkeypatch):
for environment_variable in (
"GITHUB_COPILOT_OPENAI_INTENT",
"GITHUB_COPILOT_API_VERSION",
"GITHUB_COPILOT_USER_AGENT_LIBRARY_VERSION",
):
monkeypatch.delenv(environment_variable, raising=False)
mock_auth_instance = MagicMock()
mock_auth_instance.get_api_key.return_value = "test-api-key-123"
mock_authenticator_class.return_value = mock_auth_instance
config = GithubCopilotResponsesAPIConfig()
headers = config.validate_environment(
headers={}, model="gpt-5.1-codex", litellm_params={}
)
# Check required headers
assert headers["Authorization"] == "Bearer test-api-key-123"
assert headers["accept"] == "application/json"
assert headers["content-type"] == "application/json"
assert headers["copilot-integration-id"] == "vscode-chat"
assert headers["editor-version"] == "vscode/1.95.0"
assert headers["editor-plugin-version"] == "copilot-chat/0.26.7"
assert headers["user-agent"] == "GitHubCopilotChat/0.26.7"
assert headers["openai-intent"] == "conversation-panel"
assert headers["x-github-api-version"] == "2025-04-01"
assert "x-request-id" in headers
assert headers["editor-version"] == "vscode/1.115.0"
assert headers["editor-plugin-version"] == "copilot-chat/0.44.0"
assert headers["user-agent"] == "GitHubCopilotChat/0.44.0"
assert "openai-intent" not in headers
assert "x-github-api-version" not in headers
assert "x-request-id" not in headers
assert "x-vscode-user-agent-library-version" not in headers
@patch("litellm.llms.github_copilot.responses.transformation.Authenticator")
def test_validate_environment_headers_from_environment(self, mock_authenticator_class):
mock_auth_instance = MagicMock()
mock_auth_instance.get_api_key.return_value = "test-api-key-123"
mock_authenticator_class.return_value = mock_auth_instance
environment = {
"GITHUB_COPILOT_ACCEPT": "application/vnd.github+json",
"GITHUB_COPILOT_CONTENT_TYPE": "application/custom+json",
"GITHUB_COPILOT_INTEGRATION_ID": "custom-integration",
"GITHUB_COPILOT_EDITOR_VERSION": "custom-editor/1.0",
"GITHUB_COPILOT_EDITOR_PLUGIN_VERSION": "custom-plugin/2.0",
"GITHUB_COPILOT_USER_AGENT": "CustomAgent/2.0",
"GITHUB_COPILOT_OPENAI_INTENT": "custom-intent",
"GITHUB_COPILOT_API_VERSION": "2099-01-01",
"GITHUB_COPILOT_USER_AGENT_LIBRARY_VERSION": "custom-library",
}
with patch.dict(os.environ, environment):
headers = GithubCopilotResponsesAPIConfig().validate_environment(
headers={}, model="gpt-5.1-codex", litellm_params={}
)
assert headers["Authorization"] == "Bearer test-api-key-123"
assert headers["accept"] == "application/vnd.github+json"
assert headers["content-type"] == "application/custom+json"
assert headers["copilot-integration-id"] == "custom-integration"
assert headers["editor-version"] == "custom-editor/1.0"
assert headers["editor-plugin-version"] == "custom-plugin/2.0"
assert headers["user-agent"] == "CustomAgent/2.0"
assert headers["openai-intent"] == "custom-intent"
assert headers["x-github-api-version"] == "2099-01-01"
assert headers["x-vscode-user-agent-library-version"] == "custom-library"
@patch("litellm.llms.github_copilot.responses.transformation.Authenticator")
def test_empty_environment_header_omits_default(self, mock_authenticator_class):
mock_auth_instance = MagicMock()
mock_auth_instance.get_api_key.return_value = "test-api-key-123"
mock_authenticator_class.return_value = mock_auth_instance
with patch.dict(os.environ, {"GITHUB_COPILOT_USER_AGENT": ""}):
headers = GithubCopilotResponsesAPIConfig().validate_environment(
headers={}, model="gpt-5.1-codex", litellm_params={}
)
assert "user-agent" not in headers
@patch("litellm.llms.github_copilot.responses.transformation.Authenticator")
def test_validate_environment_user_headers_override(self, mock_authenticator_class):

View file

@ -58,15 +58,73 @@ class TestGitHubCopilotAuthenticator:
mock_makedirs.assert_called_once_with(auth.token_dir, exist_ok=True)
def test_get_github_headers(self, authenticator):
"""Test that GitHub headers are correctly generated."""
headers = authenticator._get_github_headers()
assert "accept" in headers
assert "editor-version" in headers
assert "user-agent" in headers
assert "content-type" in headers
assert headers == {
"accept": "application/json",
"content-type": "application/json",
"copilot-integration-id": "vscode-chat",
"editor-version": "vscode/1.115.0",
"editor-plugin-version": "copilot-chat/0.44.0",
"user-agent": "GitHubCopilotChat/0.44.0",
}
headers_with_token = authenticator._get_github_headers("test-token")
assert headers_with_token["authorization"] == "token test-token"
assert headers_with_token["Authorization"] == "token test-token"
def test_auth_requests_use_custom_copilot_headers(self, authenticator, mock_http_client):
mock_client, mock_response = mock_http_client
mock_response.json.side_effect = (
{
"device_code": "dc",
"user_code": "UC",
"verification_uri": "https://example.com",
},
{"access_token": "access-token"},
{"token": "api-token", "expires_at": 9999999999},
)
environment = {
"GITHUB_COPILOT_ACCEPT": "application/vnd.github+json",
"GITHUB_COPILOT_CONTENT_TYPE": "application/custom+json",
"GITHUB_COPILOT_INTEGRATION_ID": "custom-integration",
"GITHUB_COPILOT_EDITOR_VERSION": "custom-editor/1.0",
"GITHUB_COPILOT_EDITOR_PLUGIN_VERSION": "custom-plugin/2.0",
"GITHUB_COPILOT_USER_AGENT": "CustomAgent/2.0",
"GITHUB_COPILOT_OPENAI_INTENT": "custom-intent",
"GITHUB_COPILOT_API_VERSION": "2099-01-01",
"GITHUB_COPILOT_USER_AGENT_LIBRARY_VERSION": "custom-library",
}
with (
patch.dict(os.environ, environment),
patch(
"litellm.llms.github_copilot.authenticator._get_httpx_client",
return_value=mock_client,
),
patch.object(authenticator, "get_access_token", return_value="github-token"),
):
authenticator._get_device_code()
authenticator._poll_for_access_token("dc")
authenticator._refresh_api_key()
request_headers = (
mock_client.post.call_args_list[0].kwargs["headers"],
mock_client.post.call_args_list[1].kwargs["headers"],
mock_client.get.call_args.kwargs["headers"],
)
for headers in request_headers:
assert headers["accept"] == "application/vnd.github+json"
assert headers["content-type"] == "application/custom+json"
assert headers["copilot-integration-id"] == "custom-integration"
assert headers["editor-version"] == "custom-editor/1.0"
assert headers["editor-plugin-version"] == "custom-plugin/2.0"
assert headers["user-agent"] == "CustomAgent/2.0"
assert headers["openai-intent"] == "custom-intent"
assert headers["x-github-api-version"] == "2099-01-01"
assert headers["x-vscode-user-agent-library-version"] == "custom-library"
assert "Authorization" not in request_headers[0]
assert "Authorization" not in request_headers[1]
assert request_headers[2]["Authorization"] == "token github-token"
def test_get_access_token_from_file(self, authenticator):
"""Test retrieving an access token from a file."""