mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
Merge 56d338cc70 into 02dcc4d347
This commit is contained in:
commit
aaef46cf95
9 changed files with 389 additions and 442 deletions
|
|
@ -1,8 +1,8 @@
|
|||
import json
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Final
|
||||
from typing import Final
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import httpx
|
||||
|
||||
|
|
@ -10,18 +10,35 @@ 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,
|
||||
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"
|
||||
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"
|
||||
|
||||
|
||||
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:
|
||||
|
|
@ -36,7 +53,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:
|
||||
|
|
@ -67,7 +83,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
|
||||
|
||||
|
|
@ -77,141 +93,36 @@ 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.
|
||||
"""
|
||||
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)
|
||||
return self.get_access_token()
|
||||
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.
|
||||
"""
|
||||
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,
|
||||
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(
|
||||
f"Ignoring {source} because it must be an HTTPS URL without credentials, query, or fragment"
|
||||
)
|
||||
return None
|
||||
|
||||
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]:
|
||||
"""
|
||||
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
|
||||
def _get_github_headers(self) -> dict[str, str]:
|
||||
return get_copilot_auth_headers()
|
||||
|
||||
def _get_device_code(self) -> dict[str, str]:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import json
|
||||
import os
|
||||
from typing import Any, Final
|
||||
|
||||
import httpx
|
||||
|
|
@ -35,12 +34,7 @@ class GithubCopilotConfig(OpenAIConfig):
|
|||
api_key: str | None,
|
||||
custom_llm_provider: str,
|
||||
) -> tuple[str | None, str | None, str]:
|
||||
dynamic_api_base: Final = (
|
||||
api_base
|
||||
or self.authenticator.get_api_base()
|
||||
or os.getenv("GITHUB_COPILOT_API_BASE")
|
||||
or DEFAULT_GITHUB_COPILOT_API_BASE
|
||||
)
|
||||
dynamic_api_base: Final = self.authenticator.get_api_base(api_base) or DEFAULT_GITHUB_COPILOT_API_BASE
|
||||
try:
|
||||
dynamic_api_key: Final = self.authenticator.get_api_key()
|
||||
except GetAPIKeyError as e:
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
Constants for Copilot integration
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Final
|
||||
from uuid import uuid4
|
||||
|
||||
|
|
@ -9,12 +10,33 @@ 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_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),
|
||||
(
|
||||
"x-vscode-user-agent-library-version",
|
||||
"GITHUB_COPILOT_USER_AGENT_LIBRARY_VERSION",
|
||||
None,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class GithubCopilotError(BaseLLMException):
|
||||
|
|
@ -45,33 +67,33 @@ class GetAccessTokenError(GithubCopilotError):
|
|||
pass
|
||||
|
||||
|
||||
class APIKeyExpiredError(GithubCopilotError):
|
||||
pass
|
||||
|
||||
|
||||
class RefreshAPIKeyError(GithubCopilotError):
|
||||
pass
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
def _get_configured_copilot_headers(
|
||||
config: tuple[tuple[str, str, str | None], ...],
|
||||
) -> dict[str, str]:
|
||||
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",
|
||||
header: value
|
||||
for header, environment_variable, default in config
|
||||
if (value := _get_copilot_header_value(environment_variable, default)) is not None
|
||||
}
|
||||
|
||||
|
||||
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}",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, Final
|
||||
|
||||
import httpx
|
||||
|
|
@ -98,13 +97,7 @@ class GithubCopilotEmbeddingConfig(BaseEmbeddingConfig):
|
|||
"""
|
||||
Get the complete URL for GitHub Copilot Embedding API endpoint.
|
||||
"""
|
||||
# Use provided api_base or fall back to authenticator's base or default
|
||||
effective_api_base = (
|
||||
api_base
|
||||
or self.authenticator.get_api_base()
|
||||
or os.getenv("GITHUB_COPILOT_API_BASE")
|
||||
or DEFAULT_GITHUB_COPILOT_API_BASE
|
||||
)
|
||||
effective_api_base = self.authenticator.get_api_base(api_base) or DEFAULT_GITHUB_COPILOT_API_BASE
|
||||
|
||||
# Remove trailing slashes
|
||||
effective_api_base = effective_api_base.rstrip("/")
|
||||
|
|
|
|||
|
|
@ -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, Final
|
||||
|
||||
import litellm
|
||||
|
|
@ -248,13 +247,7 @@ class GithubCopilotResponsesAPIConfig(OpenAIResponsesAPIConfig):
|
|||
"""
|
||||
Get the complete URL for GitHub Copilot Responses API endpoint.
|
||||
"""
|
||||
# Use provided api_base or fall back to authenticator's base or default
|
||||
effective_api_base = (
|
||||
api_base
|
||||
or self.authenticator.get_api_base()
|
||||
or os.getenv("GITHUB_COPILOT_API_BASE")
|
||||
or DEFAULT_GITHUB_COPILOT_API_BASE
|
||||
)
|
||||
effective_api_base = self.authenticator.get_api_base(api_base) or DEFAULT_GITHUB_COPILOT_API_BASE
|
||||
|
||||
# Remove trailing slashes
|
||||
effective_api_base = effective_api_base.rstrip("/")
|
||||
|
|
|
|||
|
|
@ -34,8 +34,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(
|
||||
|
|
@ -73,8 +73,8 @@ 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,
|
||||
|
|
@ -85,16 +85,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://api.business.githubcopilot.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.business.githubcopilot.com/embeddings"
|
||||
|
||||
|
||||
def test_github_copilot_embedding_config_transform_request():
|
||||
|
|
|
|||
|
|
@ -26,9 +26,7 @@ def use_local_model_cost_map(monkeypatch: pytest.MonkeyPatch):
|
|||
"""Pin litellm.model_cost to the bundled local backup so tests don't depend
|
||||
on remote catalog fetches (and don't change behavior across remote refreshes)."""
|
||||
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
|
||||
monkeypatch.setattr(
|
||||
litellm, "model_cost", get_model_cost_map(url=litellm.model_cost_map_url)
|
||||
)
|
||||
monkeypatch.setattr(litellm, "model_cost", get_model_cost_map(url=litellm.model_cost_map_url))
|
||||
litellm.add_known_models(model_cost_map=litellm.model_cost)
|
||||
|
||||
|
||||
|
|
@ -44,23 +42,19 @@ class TestGithubCopilotResponsesAPITransformation:
|
|||
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"
|
||||
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_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
|
||||
|
||||
|
|
@ -68,50 +62,89 @@ class TestGithubCopilotResponsesAPITransformation:
|
|||
|
||||
# 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 url == "https://api.individual.githubcopilot.com/responses", (
|
||||
f"Expected GitHub Copilot responses endpoint, got {url}"
|
||||
)
|
||||
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"
|
||||
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"
|
||||
|
||||
@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={})
|
||||
|
||||
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-version"] == "vscode/1.115.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 "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):
|
||||
|
|
@ -127,9 +160,7 @@ class TestGithubCopilotResponsesAPITransformation:
|
|||
"custom-header": "custom-value",
|
||||
}
|
||||
|
||||
headers = config.validate_environment(
|
||||
headers=custom_headers, model="gpt-5.1-codex", litellm_params={}
|
||||
)
|
||||
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"
|
||||
|
|
@ -182,9 +213,7 @@ class TestGithubCopilotResponsesAPITransformation:
|
|||
"""Test _has_vision_input detects input_image type"""
|
||||
config = GithubCopilotResponsesAPIConfig()
|
||||
|
||||
input_with_vision = [
|
||||
{"role": "user", "content": [{"type": "input_image", "data": "base64..."}]}
|
||||
]
|
||||
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"
|
||||
|
|
@ -246,13 +275,11 @@ class TestGithubCopilotResponsesAPITransformation:
|
|||
}
|
||||
]
|
||||
|
||||
headers = config.validate_environment(
|
||||
headers={}, model="gpt-5.1-codex", litellm_params=mock_litellm_params
|
||||
)
|
||||
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"
|
||||
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):
|
||||
|
|
@ -270,21 +297,15 @@ class TestGithubCopilotResponsesAPITransformation:
|
|||
{"role": "assistant", "content": "Hi"},
|
||||
]
|
||||
|
||||
headers = config.validate_environment(
|
||||
headers={}, model="gpt-5.1-codex", litellm_params=mock_litellm_params
|
||||
)
|
||||
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"
|
||||
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
|
||||
)
|
||||
params = ResponsesAPIOptionalRequestParams(temperature=0.7, max_output_tokens=1000, stream=False)
|
||||
|
||||
result = config.map_openai_params(
|
||||
response_api_optional_params=params,
|
||||
|
|
@ -338,9 +359,9 @@ class TestGithubCopilotResponsesAPITransformation:
|
|||
result = config._handle_reasoning_item(reasoning_item)
|
||||
|
||||
# encrypted_content should be preserved
|
||||
assert (
|
||||
result.get("encrypted_content") == "encrypted-blob-abc123"
|
||||
), "encrypted_content must be preserved for GitHub Copilot multi-turn conversations"
|
||||
assert result.get("encrypted_content") == "encrypted-blob-abc123", (
|
||||
"encrypted_content must be preserved for GitHub Copilot multi-turn conversations"
|
||||
)
|
||||
# status=None should be filtered out
|
||||
assert "status" not in result, "status=None should be filtered out"
|
||||
# content=None should be filtered out
|
||||
|
|
@ -393,9 +414,7 @@ class TestGithubCopilotResponsesAPIRouting:
|
|||
in the (already-merged) model info; otherwise returns None so the dispatcher
|
||||
routes through the chat-completions translation bridge."""
|
||||
|
||||
@patch(
|
||||
"litellm.llms.github_copilot.responses.transformation._cached_get_model_info_helper"
|
||||
)
|
||||
@patch("litellm.llms.github_copilot.responses.transformation._cached_get_model_info_helper")
|
||||
def test_returns_config_when_mode_is_responses(self, mock_get_info):
|
||||
"""``mode=responses`` returns native config."""
|
||||
mock_get_info.return_value = {"mode": "responses"}
|
||||
|
|
@ -405,9 +424,7 @@ class TestGithubCopilotResponsesAPIRouting:
|
|||
)
|
||||
assert isinstance(config, GithubCopilotResponsesAPIConfig)
|
||||
|
||||
@patch(
|
||||
"litellm.llms.github_copilot.responses.transformation._cached_get_model_info_helper"
|
||||
)
|
||||
@patch("litellm.llms.github_copilot.responses.transformation._cached_get_model_info_helper")
|
||||
def test_returns_none_when_mode_is_chat(self, mock_get_info):
|
||||
"""``mode=chat`` returns None so dispatcher uses bridge."""
|
||||
mock_get_info.return_value = {"mode": "chat"}
|
||||
|
|
@ -417,9 +434,7 @@ class TestGithubCopilotResponsesAPIRouting:
|
|||
)
|
||||
assert config is None
|
||||
|
||||
@patch(
|
||||
"litellm.llms.github_copilot.responses.transformation._cached_get_model_info_helper"
|
||||
)
|
||||
@patch("litellm.llms.github_copilot.responses.transformation._cached_get_model_info_helper")
|
||||
def test_returns_none_when_mode_is_unset_and_no_endpoints(self, mock_get_info):
|
||||
"""Entry without ``mode`` and without ``supported_endpoints`` returns None
|
||||
(conservative default)."""
|
||||
|
|
@ -499,9 +514,7 @@ class TestGithubCopilotResponsesAPIRouting:
|
|||
)
|
||||
assert isinstance(config, GithubCopilotResponsesAPIConfig)
|
||||
|
||||
@patch(
|
||||
"litellm.llms.github_copilot.responses.transformation._cached_get_model_info_helper"
|
||||
)
|
||||
@patch("litellm.llms.github_copilot.responses.transformation._cached_get_model_info_helper")
|
||||
def test_returns_none_when_get_model_info_raises(self, mock_get_info):
|
||||
"""Catalog lookup failure (model not registered) returns None
|
||||
(conservative default; bridge handles unknown models safely)."""
|
||||
|
|
@ -512,9 +525,7 @@ class TestGithubCopilotResponsesAPIRouting:
|
|||
)
|
||||
assert config is None
|
||||
|
||||
@patch(
|
||||
"litellm.llms.github_copilot.responses.transformation._cached_get_model_info_helper"
|
||||
)
|
||||
@patch("litellm.llms.github_copilot.responses.transformation._cached_get_model_info_helper")
|
||||
def test_user_override_via_register_model(self, mock_get_info):
|
||||
"""User-supplied per-deployment ``model_info`` flows through
|
||||
``litellm.register_model`` (called by the router) into the merged
|
||||
|
|
@ -528,9 +539,7 @@ class TestGithubCopilotResponsesAPIRouting:
|
|||
)
|
||||
assert isinstance(config, GithubCopilotResponsesAPIConfig)
|
||||
|
||||
@patch(
|
||||
"litellm.llms.github_copilot.responses.transformation._cached_get_model_info_helper"
|
||||
)
|
||||
@patch("litellm.llms.github_copilot.responses.transformation._cached_get_model_info_helper")
|
||||
def test_realistic_chat_only_entry_returns_none(self, mock_get_info):
|
||||
"""Realistic ``model_prices_and_context_window.json`` shape for a
|
||||
chat-only Copilot model (e.g. github_copilot/gemini-3.1-pro-preview)
|
||||
|
|
@ -554,9 +563,7 @@ class TestGithubCopilotResponsesAPIRouting:
|
|||
)
|
||||
assert config is None
|
||||
|
||||
@patch(
|
||||
"litellm.llms.github_copilot.responses.transformation._cached_get_model_info_helper"
|
||||
)
|
||||
@patch("litellm.llms.github_copilot.responses.transformation._cached_get_model_info_helper")
|
||||
def test_realistic_responses_only_entry_returns_config(self, mock_get_info):
|
||||
"""Realistic catalog entry for a Responses-only Copilot model
|
||||
(e.g. github_copilot/gpt-5.5) returns the native config."""
|
||||
|
|
@ -592,9 +599,7 @@ class TestGithubCopilotReasoningStreamItemIdNormalization:
|
|||
output_index group to the id from its output_item.added."""
|
||||
|
||||
def _config(self):
|
||||
with patch(
|
||||
"litellm.llms.github_copilot.responses.transformation.Authenticator"
|
||||
):
|
||||
with patch("litellm.llms.github_copilot.responses.transformation.Authenticator"):
|
||||
return GithubCopilotResponsesAPIConfig()
|
||||
|
||||
def _transform(self, config, chunk):
|
||||
|
|
|
|||
|
|
@ -1,18 +1,14 @@
|
|||
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,
|
||||
get_copilot_default_headers,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -45,7 +41,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):
|
||||
|
|
@ -57,16 +52,134 @@ class TestGitHubCopilotAuthenticator:
|
|||
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
|
||||
def test_get_api_base_prefers_environment(self, authenticator):
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"GITHUB_COPILOT_API_BASE": "https://configured.example.com"},
|
||||
clear=True,
|
||||
):
|
||||
assert authenticator.get_api_base() == "https://configured.example.com"
|
||||
|
||||
headers_with_token = authenticator._get_github_headers("test-token")
|
||||
assert headers_with_token["authorization"] == "token test-token"
|
||||
@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, {"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
|
||||
|
||||
mock_warning.assert_called_once_with(
|
||||
"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_prefers_trusted_deployment_endpoint(self, authenticator):
|
||||
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"
|
||||
|
||||
def test_get_api_base_falls_back_from_untrusted_deployment_endpoint(self, authenticator):
|
||||
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,
|
||||
):
|
||||
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 must be an HTTPS URL without credentials, query, or fragment"
|
||||
)
|
||||
|
||||
def test_get_github_headers(self, authenticator):
|
||||
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",
|
||||
}
|
||||
|
||||
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://github.com/login/device",
|
||||
},
|
||||
{"access_token": "opencode-oauth-token"},
|
||||
)
|
||||
environment = {
|
||||
"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_API_VERSION": "2026-06-01",
|
||||
"GITHUB_COPILOT_OPENAI_INTENT": "conversation-edits",
|
||||
"GITHUB_COPILOT_API_BASE": "https://api.githubcopilot.com",
|
||||
}
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, environment, clear=True),
|
||||
patch(
|
||||
"litellm.llms.github_copilot.authenticator._get_httpx_client",
|
||||
return_value=mock_client,
|
||||
),
|
||||
patch.object(authenticator, "get_access_token", return_value="opencode-oauth-token"),
|
||||
):
|
||||
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 = {
|
||||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
"user-agent": "opencode/1.18.7",
|
||||
}
|
||||
assert mock_client.post.call_args_list[0].kwargs == {
|
||||
"headers": expected_auth_headers,
|
||||
"json": {
|
||||
"client_id": "Ov23li8tweQw6odWQebz",
|
||||
"scope": "read:user",
|
||||
},
|
||||
}
|
||||
assert mock_client.post.call_args_list[1].kwargs == {
|
||||
"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):
|
||||
"""Test retrieving an access token from a file."""
|
||||
|
|
@ -77,17 +190,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")
|
||||
|
||||
def test_get_access_token_failure(self, authenticator):
|
||||
"""Test that an exception is raised after multiple login failures."""
|
||||
|
|
@ -103,72 +231,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."""
|
||||
|
|
@ -217,35 +287,16 @@ 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):
|
||||
"""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
|
||||
|
|
@ -255,8 +306,10 @@ class TestGitHubCopilotAuthenticator:
|
|||
"user_code": "UC",
|
||||
"verification_uri": "https://example.com",
|
||||
}
|
||||
with patch.dict(os.environ, {"GITHUB_COPILOT_DEVICE_CODE_URL": custom_url}), \
|
||||
patch("litellm.llms.github_copilot.authenticator._get_httpx_client", return_value=mock_client):
|
||||
with (
|
||||
patch.dict(os.environ, {"GITHUB_COPILOT_DEVICE_CODE_URL": custom_url}),
|
||||
patch("litellm.llms.github_copilot.authenticator._get_httpx_client", return_value=mock_client),
|
||||
):
|
||||
authenticator._get_device_code()
|
||||
assert mock_client.post.call_args[0][0] == custom_url
|
||||
|
||||
|
|
@ -269,8 +322,10 @@ class TestGitHubCopilotAuthenticator:
|
|||
"user_code": "UC",
|
||||
"verification_uri": "https://example.com",
|
||||
}
|
||||
with patch.dict(os.environ, {"GITHUB_COPILOT_CLIENT_ID": custom_id}), \
|
||||
patch("litellm.llms.github_copilot.authenticator._get_httpx_client", return_value=mock_client):
|
||||
with (
|
||||
patch.dict(os.environ, {"GITHUB_COPILOT_CLIENT_ID": custom_id}),
|
||||
patch("litellm.llms.github_copilot.authenticator._get_httpx_client", return_value=mock_client),
|
||||
):
|
||||
authenticator._get_device_code()
|
||||
assert mock_client.post.call_args[1]["json"]["client_id"] == custom_id
|
||||
|
||||
|
|
@ -279,9 +334,11 @@ class TestGitHubCopilotAuthenticator:
|
|||
mock_client, mock_response = mock_http_client
|
||||
custom_url = "https://custom.example.com/token"
|
||||
mock_response.json.return_value = {"access_token": "tok"}
|
||||
with patch.dict(os.environ, {"GITHUB_COPILOT_ACCESS_TOKEN_URL": custom_url}), \
|
||||
patch("litellm.llms.github_copilot.authenticator._get_httpx_client", return_value=mock_client), \
|
||||
patch("time.sleep"):
|
||||
with (
|
||||
patch.dict(os.environ, {"GITHUB_COPILOT_ACCESS_TOKEN_URL": custom_url}),
|
||||
patch("litellm.llms.github_copilot.authenticator._get_httpx_client", return_value=mock_client),
|
||||
patch("time.sleep"),
|
||||
):
|
||||
authenticator._poll_for_access_token("dc")
|
||||
assert mock_client.post.call_args[0][0] == custom_url
|
||||
|
||||
|
|
@ -290,20 +347,10 @@ class TestGitHubCopilotAuthenticator:
|
|||
mock_client, mock_response = mock_http_client
|
||||
custom_id = "custom_client_id"
|
||||
mock_response.json.return_value = {"access_token": "tok"}
|
||||
with patch.dict(os.environ, {"GITHUB_COPILOT_CLIENT_ID": custom_id}), \
|
||||
patch("litellm.llms.github_copilot.authenticator._get_httpx_client", return_value=mock_client), \
|
||||
patch("time.sleep"):
|
||||
with (
|
||||
patch.dict(os.environ, {"GITHUB_COPILOT_CLIENT_ID": custom_id}),
|
||||
patch("litellm.llms.github_copilot.authenticator._get_httpx_client", return_value=mock_client),
|
||||
patch("time.sleep"),
|
||||
):
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,8 +35,8 @@ 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
|
||||
|
|
@ -57,9 +55,16 @@ 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://api.business.githubcopilot.com",
|
||||
api_key=None,
|
||||
custom_llm_provider="github_copilot",
|
||||
)
|
||||
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,
|
||||
|
|
@ -158,25 +163,19 @@ def test_transform_messages_disable_copilot_system_to_assistant(monkeypatch):
|
|||
{"role": "system", "content": "System message."},
|
||||
{"role": "user", "content": "User message."},
|
||||
]
|
||||
out = config._transform_messages(
|
||||
[m.copy() for m in messages], model="github_copilot/gpt-4"
|
||||
)
|
||||
out = config._transform_messages([m.copy() for m in messages], model="github_copilot/gpt-4")
|
||||
assert out[0]["role"] == "assistant"
|
||||
assert out[1]["role"] == "user"
|
||||
|
||||
# Case 2: Flag is True (conversion does not happen)
|
||||
litellm.disable_copilot_system_to_assistant = True
|
||||
out = config._transform_messages(
|
||||
[m.copy() for m in messages], model="github_copilot/gpt-4"
|
||||
)
|
||||
out = config._transform_messages([m.copy() for m in messages], model="github_copilot/gpt-4")
|
||||
assert out[0]["role"] == "system"
|
||||
assert out[1]["role"] == "user"
|
||||
|
||||
# Case 3: Flag is False again (conversion happens)
|
||||
litellm.disable_copilot_system_to_assistant = False
|
||||
out = config._transform_messages(
|
||||
[m.copy() for m in messages], model="github_copilot/gpt-4"
|
||||
)
|
||||
out = config._transform_messages([m.copy() for m in messages], model="github_copilot/gpt-4")
|
||||
assert out[0]["role"] == "assistant"
|
||||
assert out[1]["role"] == "user"
|
||||
finally:
|
||||
|
|
@ -381,9 +380,7 @@ def test_get_supported_openai_params_claude_model():
|
|||
assert "reasoning_effort" in supported_params
|
||||
|
||||
# Test Claude 3-7 model supports thinking and reasoning_effort parameters
|
||||
supported_params_claude37 = config.get_supported_openai_params(
|
||||
"claude-3-7-sonnet-20250219"
|
||||
)
|
||||
supported_params_claude37 = config.get_supported_openai_params("claude-3-7-sonnet-20250219")
|
||||
assert "thinking" in supported_params_claude37
|
||||
assert "reasoning_effort" in supported_params_claude37
|
||||
|
||||
|
|
@ -410,16 +407,12 @@ def test_get_supported_openai_params_case_insensitive():
|
|||
config = GithubCopilotConfig()
|
||||
|
||||
# Test uppercase Claude 4 model with full model name
|
||||
supported_params_upper = config.get_supported_openai_params(
|
||||
"CLAUDE-SONNET-4-20250514"
|
||||
)
|
||||
supported_params_upper = config.get_supported_openai_params("CLAUDE-SONNET-4-20250514")
|
||||
assert "thinking" in supported_params_upper
|
||||
assert "reasoning_effort" in supported_params_upper
|
||||
|
||||
# Test mixed case Claude 3-7 model (has extended thinking) with full model name
|
||||
supported_params_mixed = config.get_supported_openai_params(
|
||||
"Claude-3-7-Sonnet-20250219"
|
||||
)
|
||||
supported_params_mixed = config.get_supported_openai_params("Claude-3-7-Sonnet-20250219")
|
||||
assert "thinking" in supported_params_mixed
|
||||
assert "reasoning_effort" in supported_params_mixed
|
||||
|
||||
|
|
@ -753,13 +746,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."""
|
||||
|
|
@ -927,12 +915,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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue