mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-14 09:03:16 +00:00
Merge 7b46da28e8 into 91f039f4e5
This commit is contained in:
commit
e309629249
44 changed files with 2233 additions and 2033 deletions
|
|
@ -28,7 +28,6 @@ GITHUB_COPILOT_PER_USER_AUTH_TYPE: Final = "per_user_oauth"
|
|||
GITHUB_COPILOT_AUTH_TYPE_KEY: Final = "github_copilot_auth_type"
|
||||
GITHUB_COPILOT_HOST_KEY: Final = "github_copilot_host"
|
||||
GITHUB_COPILOT_DEFAULT_HOST: Final = "github.com"
|
||||
GITHUB_COPILOT_USER_TOKEN_SAFETY_MARGIN_SECONDS: Final = 60
|
||||
GITHUB_COPILOT_USER_CREDENTIAL_CACHE_TTL_SECONDS: Final = 60
|
||||
GITHUB_COPILOT_DEVICE_FLOW_CACHE_PREFIX: Final = "github_copilot_device_flow"
|
||||
USER_PROVIDER_CREDENTIAL_CACHE_PREFIX: Final = "user_provider_credential"
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ If given, generate a unique model_id for the deployment.
|
|||
Ensures cooldowns are applied correctly.
|
||||
"""
|
||||
|
||||
from collections.abc import MutableMapping
|
||||
from typing import Final, cast # noqa: TID251 # narrows the untyped request dict for mutation
|
||||
from collections.abc import Mapping, MutableMapping
|
||||
from typing import Final, Protocol, cast # noqa: TID251 # narrows the untyped request dict for mutation
|
||||
|
||||
from litellm.exceptions import BadRequestError
|
||||
from litellm.litellm_core_utils.credential_accessor import sends_api_key_secret_reference
|
||||
|
|
@ -84,6 +84,20 @@ def _admin_config_fields_to_clear_on_base_override() -> list[str]:
|
|||
_ADMIN_CONFIG_FIELDS_TO_CLEAR_ON_BASE_OVERRIDE: Final = _admin_config_fields_to_clear_on_base_override()
|
||||
|
||||
|
||||
class DeploymentParamLookup(Protocol):
|
||||
def get(self, key: str, /) -> object: ...
|
||||
|
||||
|
||||
def github_copilot_caller_endpoint_keys(
|
||||
deployment_litellm_params: DeploymentParamLookup, request_kwargs: Mapping[str, object]
|
||||
) -> tuple[str, ...]:
|
||||
model: Final = deployment_litellm_params.get("model")
|
||||
is_github_copilot: Final = deployment_litellm_params.get("custom_llm_provider") == "github_copilot" or (
|
||||
isinstance(model, str) and model.startswith("github_copilot/")
|
||||
)
|
||||
return tuple(key for key in ("api_base", "base_url") if is_github_copilot and request_kwargs.get(key))
|
||||
|
||||
|
||||
def is_clientside_credential(request_kwargs: dict) -> bool:
|
||||
"""
|
||||
Check if the credential is a clientside credential.
|
||||
|
|
@ -130,8 +144,8 @@ def get_dynamic_litellm_params(litellm_params: dict, request_kwargs: dict) -> di
|
|||
# A per-user OAuth credential must survive the clear: dropping
|
||||
# litellm_credential_name / github_copilot_auth_type would flip the call to
|
||||
# shared mode and send the admin's Copilot token to the redirected host.
|
||||
# Per-user mode ignores the caller's api_base anyway (the session's
|
||||
# validated host wins), so keeping the mode is fail-closed.
|
||||
# Per-user mode ignores the caller's api_base anyway (only the
|
||||
# operator-configured Copilot base is used), so keeping the mode is fail-closed.
|
||||
credential_name_value: Final[object] = litellm_params.get( # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] # litellm_params is the untyped request dict
|
||||
"litellm_credential_name"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2548,6 +2548,15 @@ def exception_type(
|
|||
return original_exception
|
||||
if _is_guardrail_block(original_exception):
|
||||
return original_exception
|
||||
if isinstance(original_exception, Exception) and getattr(original_exception, "status_code", None) == 401:
|
||||
from litellm.llms.github_copilot.per_user_auth import github_copilot_caller_rejection
|
||||
|
||||
github_copilot_rejection: Final = github_copilot_caller_rejection(
|
||||
original_exception,
|
||||
cast(object, extra_kwargs), # cast-ok: extra_kwargs arrives as an untyped request dict
|
||||
)
|
||||
if github_copilot_rejection is not None:
|
||||
raise github_copilot_rejection from original_exception
|
||||
if isinstance(original_exception, ImportError) and (
|
||||
original_exception.name in ("boto3", "botocore") or custom_llm_provider in ("bedrock", "bedrock_mantle")
|
||||
):
|
||||
|
|
|
|||
|
|
@ -835,7 +835,7 @@ def _get_openai_compatible_provider_info(
|
|||
api_base = api_base or get_secret("GALADRIEL_API_BASE") or "https://api.galadriel.com/v1"
|
||||
dynamic_api_key = api_key or get_secret_str("GALADRIEL_API_KEY")
|
||||
elif custom_llm_provider == "github_copilot":
|
||||
from litellm.llms.github_copilot.common_utils import DEFAULT_GITHUB_COPILOT_API_BASE
|
||||
from litellm.llms.github_copilot.common_utils import DEFAULT_GITHUB_COPILOT_API_BASE, get_copilot_api_base
|
||||
from litellm.llms.github_copilot.per_user_auth import (
|
||||
github_copilot_per_user_credential_name,
|
||||
github_copilot_user_session_from,
|
||||
|
|
@ -845,7 +845,9 @@ def _get_openai_compatible_provider_info(
|
|||
cast("dict[str, object]", litellm_params) # cast-ok: litellm_params arrives as an untyped request dict
|
||||
)
|
||||
if user_session is not None:
|
||||
api_base = user_session.api_base # rebind-ok: resolves provider args in place
|
||||
api_base = get_copilot_api_base( # rebind-ok: resolves provider args in place
|
||||
None, lambda: user_session.token, user_session.endpoints
|
||||
)
|
||||
dynamic_api_key = user_session.token # rebind-ok: resolves provider args in place
|
||||
elif (
|
||||
github_copilot_per_user_credential_name(
|
||||
|
|
@ -855,7 +857,7 @@ def _get_openai_compatible_provider_info(
|
|||
):
|
||||
# per-user deployments resolve no token at provider-info time; the
|
||||
# caller's session supplies base + key per request
|
||||
api_base = api_base or DEFAULT_GITHUB_COPILOT_API_BASE # rebind-ok: resolves provider args in place
|
||||
api_base = DEFAULT_GITHUB_COPILOT_API_BASE # rebind-ok: resolves provider args in place
|
||||
dynamic_api_key = None # rebind-ok: resolves provider args in place
|
||||
else:
|
||||
(
|
||||
|
|
|
|||
|
|
@ -10,9 +10,11 @@ from ...litellm_core_utils.get_llm_provider_logic import (
|
|||
from ...types.router import LiteLLM_Params
|
||||
|
||||
|
||||
def _api_base_without_login(provider: str) -> str | None:
|
||||
def _api_base_without_login(provider: str, params: LiteLLM_Params) -> str | None:
|
||||
if provider == "github_copilot":
|
||||
return litellm.GithubCopilotConfig().api_base_without_login()
|
||||
from litellm.llms.github_copilot.per_user_auth import github_copilot_user_session_from
|
||||
|
||||
return litellm.GithubCopilotConfig().api_base_without_login(github_copilot_user_session_from(params))
|
||||
if provider == "chatgpt":
|
||||
return litellm.ChatGPTConfig().api_base_without_login()
|
||||
return None
|
||||
|
|
@ -68,7 +70,7 @@ def get_api_base(model: str, optional_params: dict | LiteLLM_Params) -> str | No
|
|||
model = litellm.model_alias_map[model]
|
||||
declared: Final = declared_authenticating_provider(model, _optional_params.custom_llm_provider)
|
||||
if declared is not None:
|
||||
return _api_base_without_login(declared)
|
||||
return _api_base_without_login(declared, _optional_params)
|
||||
try:
|
||||
(
|
||||
model,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
import json
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, Final, TypeVar
|
||||
from typing import Final
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel, ConfigDict, TypeAdapter, with_config
|
||||
from pydantic import ConfigDict, TypeAdapter, with_config
|
||||
from typing_extensions import NotRequired, ReadOnly, TypedDict
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
|
|
@ -17,46 +15,9 @@ from .common_utils import (
|
|||
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"
|
||||
|
||||
|
||||
class CopilotEndpoints(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, strict=True, extra="ignore", hide_input_in_errors=True)
|
||||
|
||||
api: str | None = None
|
||||
|
||||
|
||||
class CopilotCachedKey(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, strict=True, extra="ignore", hide_input_in_errors=True)
|
||||
|
||||
token: str
|
||||
expires_at: float
|
||||
endpoints: CopilotEndpoints | None = None
|
||||
|
||||
|
||||
class CopilotCachedEndpoints(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, strict=True, extra="ignore", hide_input_in_errors=True)
|
||||
|
||||
endpoints: CopilotEndpoints | None = None
|
||||
|
||||
|
||||
ApiKeyFileView = TypeVar("ApiKeyFileView", CopilotCachedKey, CopilotCachedEndpoints)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _UnsavedApiKey:
|
||||
access_token: str
|
||||
key: CopilotCachedKey
|
||||
|
||||
|
||||
_UNSAVED_API_KEYS: Final[dict[str, _UnsavedApiKey]] = {} # mutable-ok: refreshed keys api-key.json could not hold
|
||||
from .github_host import DEFAULT_GITHUB_ACCESS_TOKEN_URL, DEFAULT_GITHUB_CLIENT_ID, DEFAULT_GITHUB_DEVICE_CODE_URL
|
||||
|
||||
|
||||
def _read_token_file(path: str) -> str:
|
||||
|
|
@ -83,22 +44,6 @@ _DEVICE_CODE: Final = TypeAdapter(_DeviceCode)
|
|||
_ACCESS_TOKEN_POLL: Final = TypeAdapter(_AccessTokenPoll)
|
||||
|
||||
|
||||
def github_api_headers(
|
||||
access_token: str | None = None,
|
||||
) -> dict[str, str]: # mutable-ok: returned straight into httpx handlers whose headers params require dict
|
||||
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",
|
||||
"content-type": "application/json",
|
||||
}
|
||||
if access_token:
|
||||
headers["authorization"] = f"token {access_token}"
|
||||
return headers
|
||||
|
||||
|
||||
class Authenticator:
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the GitHub Copilot authenticator with configurable token paths."""
|
||||
|
|
@ -111,9 +56,17 @@ 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 stored_access_token(self) -> str | None:
|
||||
try:
|
||||
return _read_token_file(self.access_token_file).strip() or None
|
||||
except OSError:
|
||||
return None
|
||||
except ValueError as e:
|
||||
verbose_logger.warning("Ignoring %s, not a text access token: %s", self.access_token_file, e)
|
||||
return None
|
||||
|
||||
def get_access_token(self) -> str:
|
||||
"""
|
||||
Login to Copilot with retry 3 times.
|
||||
|
|
@ -124,14 +77,10 @@ class Authenticator:
|
|||
Raises:
|
||||
GetAccessTokenError: If unable to obtain an access token after retries.
|
||||
"""
|
||||
try:
|
||||
saved_token: Final = _read_token_file(self.access_token_file).strip()
|
||||
if saved_token:
|
||||
return saved_token
|
||||
except OSError:
|
||||
verbose_logger.warning("No existing access token found or error reading file")
|
||||
except ValueError as e:
|
||||
verbose_logger.warning("Ignoring %s, not a text access token: %s", self.access_token_file, e)
|
||||
stored: Final = self.stored_access_token()
|
||||
if stored is not None:
|
||||
return stored
|
||||
verbose_logger.warning("No existing access token found or error reading file")
|
||||
|
||||
if not can_block_current_thread():
|
||||
raise GetAccessTokenError(
|
||||
|
|
@ -155,7 +104,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
|
||||
|
||||
|
|
@ -165,146 +114,21 @@ 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.
|
||||
"""
|
||||
unsaved: Final = self._unsaved_api_key()
|
||||
if unsaved is not None:
|
||||
return unsaved.token
|
||||
cached: Final = self._read_api_key_file(CopilotCachedKey)
|
||||
if cached is not None and cached.expires_at > datetime.now().timestamp():
|
||||
return cached.token
|
||||
if cached is not None:
|
||||
verbose_logger.warning("API key expired, refreshing")
|
||||
|
||||
try:
|
||||
api_key_info: Final = self._refresh_api_key()
|
||||
except RefreshAPIKeyError as e:
|
||||
return self.get_access_token()
|
||||
except GetAccessTokenError as e:
|
||||
raise GetAPIKeyError(
|
||||
message=f"Failed to refresh API key: {e}",
|
||||
message=f"Failed to get OAuth access token: {e!s}",
|
||||
status_code=401,
|
||||
) from e
|
||||
serialized: Final = json.dumps(api_key_info)
|
||||
try:
|
||||
with open(self.api_key_file, "w") as f:
|
||||
f.write(serialized)
|
||||
except OSError as e:
|
||||
verbose_logger.warning(
|
||||
"Keeping the refreshed API key in memory, it could not be saved to %s: %s", self.api_key_file, e
|
||||
)
|
||||
self._keep_unsaved_api_key(serialized)
|
||||
token: Final = api_key_info.get("token")
|
||||
if not token:
|
||||
raise GetAPIKeyError(
|
||||
message="API key response missing token",
|
||||
status_code=401,
|
||||
)
|
||||
return token
|
||||
|
||||
def get_api_base(self) -> str | None:
|
||||
"""
|
||||
Get the API endpoint of the current API key.
|
||||
|
||||
Returns:
|
||||
Optional[str]: The GitHub Copilot API endpoint, or None if not found.
|
||||
"""
|
||||
cached: Final = self._unsaved_api_key() or self._read_api_key_file(CopilotCachedEndpoints)
|
||||
if cached is None or cached.endpoints is None:
|
||||
return None
|
||||
return cached.endpoints.api
|
||||
|
||||
def _read_api_key_file(self, view: type[ApiKeyFileView]) -> ApiKeyFileView | None:
|
||||
try:
|
||||
return view.model_validate_json(_read_token_file(self.api_key_file))
|
||||
except OSError:
|
||||
verbose_logger.warning("No API key file found or error opening file")
|
||||
return None
|
||||
except ValueError as e:
|
||||
verbose_logger.warning("Ignoring %s, not a Copilot API key file: %s", self.api_key_file, e)
|
||||
return None
|
||||
|
||||
def _stored_access_token(self) -> str | None:
|
||||
try:
|
||||
access_token: Final = _read_token_file(self.access_token_file).strip()
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
return access_token or None
|
||||
|
||||
def _unsaved_api_key(self) -> CopilotCachedKey | None:
|
||||
unsaved: Final = _UNSAVED_API_KEYS.get(self.api_key_file)
|
||||
if unsaved is None or unsaved.key.expires_at <= time.time():
|
||||
return None
|
||||
return unsaved.key if unsaved.access_token == self._stored_access_token() else None
|
||||
|
||||
def _keep_unsaved_api_key(self, serialized: str) -> None:
|
||||
access_token: Final = self._stored_access_token()
|
||||
if access_token is None:
|
||||
return
|
||||
try:
|
||||
key: Final = CopilotCachedKey.model_validate_json(serialized)
|
||||
except ValueError:
|
||||
return
|
||||
_UNSAVED_API_KEYS[self.api_key_file] = _UnsavedApiKey(access_token=access_token, key=key)
|
||||
|
||||
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 _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.
|
||||
"""
|
||||
return github_api_headers(access_token)
|
||||
def _get_github_headers(self) -> dict[str, str]:
|
||||
return get_copilot_auth_headers()
|
||||
|
||||
def _get_device_code(self) -> _DeviceCode:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import json
|
||||
import os
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import TYPE_CHECKING, Final, cast # noqa: TID251 # spread dict loses the role literal without a cast
|
||||
|
||||
|
|
@ -15,11 +14,12 @@ from litellm.types.utils import ModelResponse
|
|||
|
||||
from ..authenticator import Authenticator
|
||||
from ..common_utils import (
|
||||
DEFAULT_GITHUB_COPILOT_API_BASE,
|
||||
GetAPIKeyError,
|
||||
InvalidAPIBaseError,
|
||||
get_copilot_api_base,
|
||||
get_copilot_default_headers,
|
||||
)
|
||||
from ..per_user_auth import require_github_copilot_user_session
|
||||
from ..per_user_auth import GithubCopilotUserSession, require_github_copilot_user_session
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
|
|
@ -38,13 +38,13 @@ class GithubCopilotConfig(OpenAIConfig):
|
|||
super().__init__()
|
||||
self.authenticator = Authenticator()
|
||||
|
||||
def api_base_without_login(self, api_base: str | None = None) -> str:
|
||||
return (
|
||||
api_base
|
||||
or self.authenticator.get_api_base()
|
||||
or os.getenv("GITHUB_COPILOT_API_BASE")
|
||||
or DEFAULT_GITHUB_COPILOT_API_BASE
|
||||
)
|
||||
def api_base_without_login(self, user_session: GithubCopilotUserSession | None = None) -> str | None:
|
||||
try:
|
||||
if user_session is not None:
|
||||
return get_copilot_api_base(None, lambda: user_session.token, user_session.endpoints)
|
||||
return get_copilot_api_base(None, self.authenticator.stored_access_token)
|
||||
except InvalidAPIBaseError:
|
||||
return None
|
||||
|
||||
def _get_openai_compatible_provider_info(
|
||||
self,
|
||||
|
|
@ -53,7 +53,6 @@ class GithubCopilotConfig(OpenAIConfig):
|
|||
api_key: str | None,
|
||||
custom_llm_provider: str,
|
||||
) -> tuple[str | None, str | None, str]:
|
||||
dynamic_api_base: Final = self.api_base_without_login(api_base)
|
||||
try:
|
||||
dynamic_api_key: Final = self.authenticator.get_api_key()
|
||||
except GetAPIKeyError as e:
|
||||
|
|
@ -62,7 +61,7 @@ class GithubCopilotConfig(OpenAIConfig):
|
|||
llm_provider=custom_llm_provider,
|
||||
message=str(e),
|
||||
)
|
||||
return dynamic_api_base, dynamic_api_key, custom_llm_provider
|
||||
return get_copilot_api_base(api_base, lambda: dynamic_api_key), dynamic_api_key, custom_llm_provider
|
||||
|
||||
def get_openai_compatible_provider_info(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -2,20 +2,101 @@
|
|||
Constants for Copilot integration
|
||||
"""
|
||||
|
||||
from collections.abc import MutableMapping
|
||||
from typing import Final
|
||||
import asyncio
|
||||
import hashlib
|
||||
import os
|
||||
from collections.abc import Callable, Mapping, MutableMapping
|
||||
from functools import partial
|
||||
from types import MappingProxyType
|
||||
from typing import (
|
||||
Final,
|
||||
Protocol,
|
||||
cast, # noqa: TID251 # casts pin the untyped litellm clients and cache to the local Protocols
|
||||
)
|
||||
from urllib.parse import urlsplit
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
from pydantic import ConfigDict, TypeAdapter, with_config
|
||||
from typing_extensions import NotRequired, ReadOnly, TypedDict
|
||||
|
||||
from litellm.caching.in_memory_cache import InMemoryCache
|
||||
from litellm.constants import GITHUB_COPILOT_DEFAULT_HOST
|
||||
from litellm.llms.base_llm.chat.transformation import BaseLLMException
|
||||
|
||||
# Constants
|
||||
from .github_host import GithubHostEndpoints, github_host_endpoints
|
||||
|
||||
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"
|
||||
_GITHUB_API_HOST: Final = "api.github.com"
|
||||
_PLAN_API_BASE_TTL_SECONDS: Final = 3600
|
||||
_PLAN_API_BASE_RETRY_SECONDS: Final = 60
|
||||
_PLAN_API_BASE_TIMEOUT_SECONDS: Final = 10.0
|
||||
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: Final = (
|
||||
("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: Final = _COPILOT_AUTH_HEADER_CONFIG + (
|
||||
("openai-intent", "GITHUB_COPILOT_OPENAI_INTENT", "conversation-panel"),
|
||||
("x-github-api-version", "GITHUB_COPILOT_API_VERSION", API_VERSION),
|
||||
(
|
||||
"x-vscode-user-agent-library-version",
|
||||
"GITHUB_COPILOT_USER_AGENT_LIBRARY_VERSION",
|
||||
"electron-fetch",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class _PlanApiBaseCache(Protocol):
|
||||
def get_cache(self, key: str) -> object: ...
|
||||
|
||||
def set_cache(self, key: str, value: object, ttl: float | None = None) -> object: ...
|
||||
|
||||
def flush_cache(self) -> None: ...
|
||||
|
||||
|
||||
class _SyncGetClient(Protocol):
|
||||
def get(
|
||||
self, url: str, headers: Mapping[str, str] | None = None, timeout: float | None = None
|
||||
) -> httpx.Response: ...
|
||||
|
||||
|
||||
class _AsyncGetClient(Protocol):
|
||||
async def get(
|
||||
self, url: str, headers: Mapping[str, str] | None = None, timeout: float | None = None
|
||||
) -> httpx.Response: ...
|
||||
|
||||
|
||||
@with_config(ConfigDict(extra="allow"))
|
||||
class _CopilotUserEndpoints(TypedDict):
|
||||
api: ReadOnly[NotRequired[object]]
|
||||
|
||||
|
||||
@with_config(ConfigDict(extra="allow"))
|
||||
class _CopilotUserPayload(TypedDict):
|
||||
endpoints: ReadOnly[NotRequired[_CopilotUserEndpoints]]
|
||||
|
||||
|
||||
_COPILOT_USER: Final = TypeAdapter(_CopilotUserPayload)
|
||||
_PLAN_API_BASES: Final = cast( # cast-ok: pins the untyped module cache to _PlanApiBaseCache
|
||||
_PlanApiBaseCache,
|
||||
InMemoryCache(max_size_in_memory=1000, default_ttl=_PLAN_API_BASE_TTL_SECONDS),
|
||||
)
|
||||
_PLAN_API_BASE_LOOKUPS: Final[ # mutable-ok: single-flight registry of in-progress plan host lookups per token
|
||||
dict[str, "asyncio.Task[None]"]
|
||||
] = {}
|
||||
|
||||
|
||||
class GithubCopilotError(BaseLLMException):
|
||||
|
|
@ -46,40 +127,254 @@ class GetAccessTokenError(GithubCopilotError):
|
|||
pass
|
||||
|
||||
|
||||
class APIKeyExpiredError(GithubCopilotError):
|
||||
pass
|
||||
|
||||
|
||||
class RefreshAPIKeyError(GithubCopilotError):
|
||||
pass
|
||||
|
||||
|
||||
class GetAPIKeyError(GithubCopilotError):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidAPIBaseError(GithubCopilotError):
|
||||
pass
|
||||
|
||||
|
||||
def _get_copilot_header_value(environment_variable: str, default: str | None) -> str | None:
|
||||
value: Final = os.getenv(environment_variable)
|
||||
if value is None:
|
||||
return default
|
||||
return value or None
|
||||
|
||||
|
||||
def _get_configured_copilot_headers(
|
||||
config: tuple[tuple[str, str, str | None], ...],
|
||||
) -> dict[str, str]:
|
||||
return {
|
||||
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(
|
||||
access_token: str | None = None,
|
||||
) -> dict[str, str]: # mutable-ok: returned straight into httpx handlers whose headers params require dict
|
||||
headers: Final = _get_configured_copilot_headers(_COPILOT_AUTH_HEADER_CONFIG)
|
||||
return {**headers, "authorization": f"token {access_token}"} if access_token else headers
|
||||
|
||||
|
||||
def get_copilot_default_headers(
|
||||
api_key: str,
|
||||
) -> dict[str, str]: # mutable-ok: callers merge caller headers into the returned dict
|
||||
"""
|
||||
Get default headers for GitHub Copilot Responses API.
|
||||
|
||||
Based on copilot-api's header configuration.
|
||||
"""
|
||||
return {
|
||||
**_get_configured_copilot_headers(_COPILOT_REQUEST_HEADER_CONFIG),
|
||||
"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",
|
||||
}
|
||||
|
||||
|
||||
_OPENAI_SDK_HEADER_NAMES: Final = MappingProxyType(
|
||||
{"accept": "Accept", "content-type": "Content-Type", "user-agent": "User-Agent"}
|
||||
)
|
||||
|
||||
|
||||
def with_openai_sdk_header_names(
|
||||
headers: Mapping[str, str],
|
||||
) -> dict[str, str]: # mutable-ok: becomes the chat request's extra_headers, which the SDK merges as a dict
|
||||
return {_OPENAI_SDK_HEADER_NAMES.get(name.lower(), name): value for name, value in headers.items()}
|
||||
|
||||
|
||||
def _hostname(url: str) -> str | None:
|
||||
try:
|
||||
return urlsplit(url).hostname
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _is_valid_api_base(api_base: str) -> bool:
|
||||
try:
|
||||
parsed_api_base: Final = urlsplit(api_base)
|
||||
except ValueError:
|
||||
return False
|
||||
scheme: Final = parsed_api_base.scheme.lower()
|
||||
hostname: Final = parsed_api_base.hostname
|
||||
return (
|
||||
hostname is not None
|
||||
and scheme in ("http", "https")
|
||||
and parsed_api_base.username is None
|
||||
and parsed_api_base.password is None
|
||||
and not parsed_api_base.query
|
||||
and not parsed_api_base.fragment
|
||||
)
|
||||
|
||||
|
||||
def _reject_enterprise_token_exchange_url(endpoints: GithubHostEndpoints) -> None:
|
||||
api_key_url: Final = os.getenv("GITHUB_COPILOT_API_KEY_URL")
|
||||
if endpoints.host == GITHUB_COPILOT_DEFAULT_HOST and api_key_url and _hostname(api_key_url) != _GITHUB_API_HOST:
|
||||
raise InvalidAPIBaseError(
|
||||
status_code=500,
|
||||
message=(
|
||||
"GITHUB_COPILOT_API_KEY_URL is no longer used. Set GITHUB_COPILOT_API_BASE to your Copilot API host, "
|
||||
"such as https://copilot-api.<subdomain>.ghe.com"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _configured_copilot_api_base_source(api_base: str | None, endpoints: GithubHostEndpoints) -> tuple[str, str | None]:
|
||||
return ("api_base", api_base) if api_base else ("GITHUB_COPILOT_API_BASE", endpoints.configured_copilot_api_base)
|
||||
|
||||
|
||||
def _configured_copilot_api_base(api_base: str | None, endpoints: GithubHostEndpoints) -> str | None:
|
||||
source, configured_api_base = _configured_copilot_api_base_source(api_base, endpoints)
|
||||
if not configured_api_base:
|
||||
_reject_enterprise_token_exchange_url(endpoints)
|
||||
return None
|
||||
if not _is_valid_api_base(configured_api_base):
|
||||
raise InvalidAPIBaseError(
|
||||
status_code=500,
|
||||
message=(f"GitHub Copilot {source} must be an HTTP or HTTPS URL without credentials, query, or fragment"),
|
||||
)
|
||||
return configured_api_base
|
||||
|
||||
|
||||
def _trusted_plan_api_base(endpoints_api: object, endpoints: GithubHostEndpoints) -> str | None:
|
||||
if not isinstance(endpoints_api, str) or not _is_valid_api_base(endpoints_api):
|
||||
return None
|
||||
try:
|
||||
parsed: Final = urlsplit(endpoints_api)
|
||||
port: Final = parsed.port
|
||||
except ValueError:
|
||||
return None
|
||||
hostname: Final = (parsed.hostname or "").lower()
|
||||
is_copilot_host: Final = parsed.scheme.lower() == "https" and endpoints.is_copilot_api_host(hostname)
|
||||
return endpoints_api.rstrip("/") if is_copilot_host and port in (None, 443) else None
|
||||
|
||||
|
||||
def _plan_api_base_from(response: httpx.Response, endpoints: GithubHostEndpoints) -> tuple[str, int]:
|
||||
if not response.is_success:
|
||||
return endpoints.default_copilot_api_base, _PLAN_API_BASE_RETRY_SECONDS
|
||||
try:
|
||||
plan_endpoints: Final = _COPILOT_USER.validate_python(response.json()).get("endpoints")
|
||||
except ValueError:
|
||||
return endpoints.default_copilot_api_base, _PLAN_API_BASE_RETRY_SECONDS
|
||||
endpoints_api: Final = plan_endpoints.get("api") if plan_endpoints is not None else None
|
||||
trusted_api_base: Final = _trusted_plan_api_base(endpoints_api, endpoints)
|
||||
return trusted_api_base or endpoints.default_copilot_api_base, _PLAN_API_BASE_TTL_SECONDS
|
||||
|
||||
|
||||
def _plan_api_base_key(github_token: str, endpoints: GithubHostEndpoints) -> str:
|
||||
return hashlib.sha256(f"{endpoints.host}\x00{github_token}".encode()).hexdigest()
|
||||
|
||||
|
||||
def _cached_plan_api_base(key: str) -> str | None:
|
||||
cached: Final = _PLAN_API_BASES.get_cache(key)
|
||||
return cached if isinstance(cached, str) else None
|
||||
|
||||
|
||||
def _remember_plan_api_base(key: str, resolved: tuple[str, int]) -> None:
|
||||
api_base, ttl = resolved
|
||||
_PLAN_API_BASES.set_cache(key, api_base, ttl=ttl)
|
||||
|
||||
|
||||
def _plan_api_base_is_needed(github_token: str, api_base: str | None, endpoints: GithubHostEndpoints) -> bool:
|
||||
return (
|
||||
_configured_copilot_api_base(api_base, endpoints) is None
|
||||
and _cached_plan_api_base(_plan_api_base_key(github_token, endpoints)) is None
|
||||
)
|
||||
|
||||
|
||||
def _lookup_plan_api_base(github_token: str, endpoints: GithubHostEndpoints) -> tuple[str, int]:
|
||||
import litellm
|
||||
|
||||
client: Final = cast(_SyncGetClient, litellm.module_level_client) # cast-ok: pins the untyped client
|
||||
try:
|
||||
response: Final = client.get(
|
||||
endpoints.copilot_user_url,
|
||||
headers=get_copilot_auth_headers(github_token),
|
||||
timeout=_PLAN_API_BASE_TIMEOUT_SECONDS,
|
||||
)
|
||||
except httpx.HTTPError:
|
||||
return endpoints.default_copilot_api_base, _PLAN_API_BASE_RETRY_SECONDS
|
||||
return _plan_api_base_from(response, endpoints)
|
||||
|
||||
|
||||
async def _alookup_plan_api_base(github_token: str, endpoints: GithubHostEndpoints) -> tuple[str, int]:
|
||||
from litellm.llms.custom_httpx import http_handler
|
||||
|
||||
client: Final = cast( # cast-ok: pins the untyped client factory to the local Protocol
|
||||
Callable[[str], _AsyncGetClient], http_handler.get_async_httpx_client
|
||||
)("github_copilot")
|
||||
try:
|
||||
response: Final = await client.get(
|
||||
endpoints.copilot_user_url,
|
||||
headers=get_copilot_auth_headers(github_token),
|
||||
timeout=_PLAN_API_BASE_TIMEOUT_SECONDS,
|
||||
)
|
||||
except httpx.HTTPError:
|
||||
return endpoints.default_copilot_api_base, _PLAN_API_BASE_RETRY_SECONDS
|
||||
return _plan_api_base_from(response, endpoints)
|
||||
|
||||
|
||||
def _default_host_endpoints() -> GithubHostEndpoints:
|
||||
return github_host_endpoints(GITHUB_COPILOT_DEFAULT_HOST)
|
||||
|
||||
|
||||
def prefetch_copilot_plan_api_base(
|
||||
github_token: str, api_base: str | None = None, endpoints: GithubHostEndpoints | None = None
|
||||
) -> None:
|
||||
host_endpoints: Final = endpoints or _default_host_endpoints()
|
||||
if _plan_api_base_is_needed(github_token, api_base, host_endpoints):
|
||||
_remember_plan_api_base(
|
||||
_plan_api_base_key(github_token, host_endpoints), _lookup_plan_api_base(github_token, host_endpoints)
|
||||
)
|
||||
|
||||
|
||||
async def _alookup_and_remember_plan_api_base(key: str, github_token: str, endpoints: GithubHostEndpoints) -> None:
|
||||
_remember_plan_api_base(key, await _alookup_plan_api_base(github_token, endpoints))
|
||||
|
||||
|
||||
def _forget_plan_api_base_lookup(key: str, lookup: "asyncio.Task[None]") -> None:
|
||||
if _PLAN_API_BASE_LOOKUPS.get(key) is lookup:
|
||||
_PLAN_API_BASE_LOOKUPS.pop(key, None)
|
||||
|
||||
|
||||
def _plan_api_base_lookup(key: str, github_token: str, endpoints: GithubHostEndpoints) -> "asyncio.Task[None]":
|
||||
loop: Final = asyncio.get_running_loop()
|
||||
in_flight: Final = _PLAN_API_BASE_LOOKUPS.get(key)
|
||||
if in_flight is not None and in_flight.get_loop() is loop:
|
||||
return in_flight
|
||||
lookup: Final = loop.create_task(_alookup_and_remember_plan_api_base(key, github_token, endpoints))
|
||||
_PLAN_API_BASE_LOOKUPS[key] = lookup
|
||||
lookup.add_done_callback(partial(_forget_plan_api_base_lookup, key))
|
||||
return lookup
|
||||
|
||||
|
||||
async def aprefetch_copilot_plan_api_base(
|
||||
github_token: str, api_base: str | None = None, endpoints: GithubHostEndpoints | None = None
|
||||
) -> None:
|
||||
host_endpoints: Final = endpoints or _default_host_endpoints()
|
||||
if _plan_api_base_is_needed(github_token, api_base, host_endpoints):
|
||||
key: Final = _plan_api_base_key(github_token, host_endpoints)
|
||||
await asyncio.shield(_plan_api_base_lookup(key, github_token, host_endpoints))
|
||||
|
||||
|
||||
def get_copilot_api_base(
|
||||
api_base: str | None = None,
|
||||
github_token: Callable[[], str | None] | None = None,
|
||||
endpoints: GithubHostEndpoints | None = None,
|
||||
) -> str:
|
||||
host_endpoints: Final = endpoints or _default_host_endpoints()
|
||||
configured_api_base: Final = _configured_copilot_api_base(api_base, host_endpoints)
|
||||
if configured_api_base is not None:
|
||||
return configured_api_base
|
||||
token: Final = github_token() if github_token is not None else None
|
||||
plan_api_base: Final = _cached_plan_api_base(_plan_api_base_key(token, host_endpoints)) if token else None
|
||||
return plan_api_base or host_endpoints.default_copilot_api_base
|
||||
|
||||
|
||||
async def aget_copilot_api_base(
|
||||
github_token: str, api_base: str | None = None, endpoints: GithubHostEndpoints | None = None
|
||||
) -> str:
|
||||
await aprefetch_copilot_plan_api_base(github_token, api_base, endpoints)
|
||||
return get_copilot_api_base(api_base, lambda: github_token, endpoints)
|
||||
|
||||
|
||||
def pin_session_authorization(
|
||||
headers: MutableMapping[str, str], # mutable-ok: the helper fixes caller-owned headers in place
|
||||
session_token: str,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -29,12 +28,11 @@ from litellm.utils import convert_to_model_response_object
|
|||
|
||||
from ..authenticator import Authenticator
|
||||
from ..common_utils import (
|
||||
DEFAULT_GITHUB_COPILOT_API_BASE,
|
||||
GetAPIKeyError,
|
||||
get_copilot_default_headers,
|
||||
pin_session_authorization,
|
||||
)
|
||||
from ..per_user_auth import require_github_copilot_user_session
|
||||
from ..per_user_auth import require_github_copilot_user_session, resolve_copilot_api_base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj
|
||||
|
|
@ -122,15 +120,8 @@ class GithubCopilotEmbeddingConfig(BaseEmbeddingConfig):
|
|||
user_session: Final = require_github_copilot_user_session(
|
||||
cast("dict[str, object]", litellm_params) # cast-ok: litellm_params arrives as an untyped request dict
|
||||
)
|
||||
effective_api_base: Final = (
|
||||
(user_session.api_base if user_session is not None else None)
|
||||
or api_base
|
||||
or self.authenticator.get_api_base()
|
||||
or os.getenv("GITHUB_COPILOT_API_BASE")
|
||||
or DEFAULT_GITHUB_COPILOT_API_BASE
|
||||
)
|
||||
|
||||
return join_api_base(effective_api_base, "embeddings")
|
||||
resolved: Final = resolve_copilot_api_base(api_base, user_session, self.authenticator.get_api_key)
|
||||
return join_api_base(resolved, "embeddings")
|
||||
|
||||
def transform_embedding_request(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -5,12 +5,9 @@ from typing import Final
|
|||
|
||||
from litellm.constants import GITHUB_COPILOT_DEFAULT_HOST
|
||||
|
||||
from .authenticator import (
|
||||
DEFAULT_GITHUB_ACCESS_TOKEN_URL,
|
||||
DEFAULT_GITHUB_API_KEY_URL,
|
||||
DEFAULT_GITHUB_CLIENT_ID,
|
||||
DEFAULT_GITHUB_DEVICE_CODE_URL,
|
||||
)
|
||||
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"
|
||||
|
||||
_GHE_SUBDOMAIN_PATTERN: Final = re.compile(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?")
|
||||
|
||||
|
|
@ -20,14 +17,18 @@ class GithubHostEndpoints:
|
|||
host: str
|
||||
device_code_url: str
|
||||
access_token_url: str
|
||||
api_key_url: str
|
||||
copilot_user_url: str
|
||||
user_url: str
|
||||
client_id: str
|
||||
exchanges_copilot_token: bool
|
||||
copilot_api_hosts: frozenset[str]
|
||||
copilot_api_host_suffix: str | None
|
||||
default_copilot_api_base: str
|
||||
configured_copilot_api_base: str | None
|
||||
|
||||
def is_copilot_api_host(self, hostname: str) -> bool:
|
||||
return hostname in self.copilot_api_hosts or (
|
||||
self.copilot_api_host_suffix is not None and hostname.endswith(self.copilot_api_host_suffix)
|
||||
)
|
||||
|
||||
|
||||
def normalized_github_host(raw: object) -> str | None:
|
||||
|
|
@ -51,20 +52,18 @@ def github_host_endpoints(host: str) -> GithubHostEndpoints:
|
|||
if normalized is None:
|
||||
raise ValueError("host must be github.com or a single-subdomain GHE.com hostname")
|
||||
client_id: Final = os.getenv("GITHUB_COPILOT_CLIENT_ID", DEFAULT_GITHUB_CLIENT_ID)
|
||||
exchanges_copilot_token: Final = client_id == DEFAULT_GITHUB_CLIENT_ID
|
||||
if normalized == GITHUB_COPILOT_DEFAULT_HOST:
|
||||
return GithubHostEndpoints(
|
||||
host=normalized,
|
||||
device_code_url=os.getenv("GITHUB_COPILOT_DEVICE_CODE_URL", DEFAULT_GITHUB_DEVICE_CODE_URL),
|
||||
access_token_url=os.getenv("GITHUB_COPILOT_ACCESS_TOKEN_URL", DEFAULT_GITHUB_ACCESS_TOKEN_URL),
|
||||
api_key_url=os.getenv("GITHUB_COPILOT_API_KEY_URL", DEFAULT_GITHUB_API_KEY_URL),
|
||||
copilot_user_url="https://api.github.com/copilot_internal/user",
|
||||
user_url="https://api.github.com/user",
|
||||
client_id=client_id,
|
||||
exchanges_copilot_token=exchanges_copilot_token,
|
||||
copilot_api_hosts=frozenset({"githubcopilot.com"}),
|
||||
copilot_api_host_suffix=".githubcopilot.com",
|
||||
default_copilot_api_base="https://api.githubcopilot.com",
|
||||
configured_copilot_api_base=os.getenv("GITHUB_COPILOT_API_BASE"),
|
||||
)
|
||||
|
||||
subdomain: Final = normalized.removesuffix(".ghe.com")
|
||||
|
|
@ -74,12 +73,11 @@ def github_host_endpoints(host: str) -> GithubHostEndpoints:
|
|||
host=normalized,
|
||||
device_code_url=f"https://{normalized}/login/device/code",
|
||||
access_token_url=f"https://{normalized}/login/oauth/access_token",
|
||||
api_key_url=f"https://{api_host}/copilot_internal/v2/token",
|
||||
copilot_user_url=f"https://{api_host}/copilot_internal/user",
|
||||
user_url=f"https://{api_host}/user",
|
||||
client_id=client_id,
|
||||
exchanges_copilot_token=exchanges_copilot_token,
|
||||
copilot_api_hosts=frozenset({copilot_api_host}),
|
||||
copilot_api_host_suffix=None,
|
||||
default_copilot_api_base=f"https://{copilot_api_host}",
|
||||
configured_copilot_api_base=None,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -9,12 +9,11 @@ from litellm.llms.anthropic.messages.native.transformation import (
|
|||
|
||||
from ..authenticator import Authenticator
|
||||
from ..common_utils import (
|
||||
DEFAULT_GITHUB_COPILOT_API_BASE,
|
||||
GetAPIKeyError,
|
||||
get_copilot_default_headers,
|
||||
pin_session_authorization,
|
||||
)
|
||||
from ..per_user_auth import require_github_copilot_user_session
|
||||
from ..per_user_auth import require_github_copilot_user_session, resolve_copilot_api_base
|
||||
|
||||
_MESSAGES_PROXY_API_VERSION: Final = "2026-06-01"
|
||||
|
||||
|
|
@ -63,23 +62,10 @@ class GithubCopilotAnthropicMessagesConfig(AnthropicMessagesConfig):
|
|||
) -> tuple[dict, str | None]:
|
||||
"""
|
||||
Validate environment for GitHub Copilot and add Copilot-specific headers.
|
||||
|
||||
The caller-supplied ``api_base`` is intentionally ignored. Routing this
|
||||
request anywhere other than the authenticated Copilot endpoint would
|
||||
leak the Copilot bearer token to a caller-controlled URL.
|
||||
"""
|
||||
# Always use the Copilot endpoint resolved from the authenticated
|
||||
# session, never the caller-supplied api_base. rstrip so a
|
||||
# tenant-specific base with a trailing slash does not yield a
|
||||
# double-slash URL once "/v1/messages" is appended downstream.
|
||||
user_session: Final = require_github_copilot_user_session(
|
||||
cast("dict[str, object]", litellm_params) # cast-ok: litellm_params arrives as an untyped request dict
|
||||
)
|
||||
dynamic_api_base: Final = (
|
||||
user_session.api_base
|
||||
if user_session is not None
|
||||
else (self.authenticator.get_api_base() or DEFAULT_GITHUB_COPILOT_API_BASE)
|
||||
).rstrip("/")
|
||||
try:
|
||||
dynamic_api_key: Final = (
|
||||
user_session.token if user_session is not None else self.authenticator.get_api_key()
|
||||
|
|
@ -90,6 +76,7 @@ class GithubCopilotAnthropicMessagesConfig(AnthropicMessagesConfig):
|
|||
llm_provider="github_copilot",
|
||||
message=str(e),
|
||||
)
|
||||
dynamic_api_base: Final = resolve_copilot_api_base(api_base, user_session, lambda: dynamic_api_key).rstrip("/")
|
||||
|
||||
# Merge Copilot headers with provided headers
|
||||
copilot_headers: Final = get_copilot_default_headers(dynamic_api_key)
|
||||
|
|
@ -126,21 +113,9 @@ class GithubCopilotAnthropicMessagesConfig(AnthropicMessagesConfig):
|
|||
) -> str:
|
||||
"""
|
||||
Return the complete URL for GitHub Copilot /v1/messages endpoint.
|
||||
|
||||
``api_base`` here is the value already resolved by
|
||||
``validate_anthropic_messages_environment`` (the authenticated Copilot
|
||||
host), not the raw caller-supplied base — that one is discarded there to
|
||||
avoid leaking the Copilot bearer token to a caller-controlled URL. We
|
||||
reuse it to avoid a second authenticator read, falling back to a fresh
|
||||
resolution only if it was not provided.
|
||||
"""
|
||||
user_session: Final = require_github_copilot_user_session(
|
||||
cast("dict[str, object]", litellm_params) # cast-ok: litellm_params arrives as an untyped request dict
|
||||
)
|
||||
resolved: Final = (
|
||||
(user_session.api_base if user_session is not None else None)
|
||||
or api_base
|
||||
or self.authenticator.get_api_base()
|
||||
or DEFAULT_GITHUB_COPILOT_API_BASE
|
||||
)
|
||||
resolved: Final = resolve_copilot_api_base(api_base, user_session, self.authenticator.get_api_key)
|
||||
return ensure_api_route(resolved, "v1/messages")
|
||||
|
|
|
|||
|
|
@ -1,13 +1,9 @@
|
|||
"""Per-user GitHub Copilot OAuth: exchange a caller's stored GitHub token for a
|
||||
short-lived Copilot token, plus the device-flow helpers the proxy endpoints use
|
||||
to establish that connection. The shared device login (Authenticator) is never
|
||||
"""Per-user GitHub Copilot OAuth: send a caller's stored GitHub OAuth token
|
||||
directly to Copilot, plus the device-flow helpers the proxy endpoints use to
|
||||
establish that connection. The shared device login (Authenticator) is never
|
||||
touched on the per-user path.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hmac
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from typing import (
|
||||
|
|
@ -17,18 +13,15 @@ from typing import (
|
|||
TypeAlias,
|
||||
cast, # noqa: TID251 # casts pin the untyped litellm clients/caches to the local Protocols
|
||||
)
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from pydantic import ConfigDict, TypeAdapter, ValidationError, with_config
|
||||
from pydantic import ConfigDict, TypeAdapter, with_config
|
||||
from typing_extensions import NotRequired, ReadOnly, TypedDict
|
||||
|
||||
from litellm.caching.in_memory_cache import InMemoryCache
|
||||
from litellm.constants import (
|
||||
GITHUB_COPILOT_AUTH_TYPE_KEY,
|
||||
GITHUB_COPILOT_HOST_KEY,
|
||||
GITHUB_COPILOT_PER_USER_AUTH_TYPE,
|
||||
GITHUB_COPILOT_USER_TOKEN_SAFETY_MARGIN_SECONDS,
|
||||
)
|
||||
from litellm.exceptions import (
|
||||
APIConnectionError,
|
||||
|
|
@ -38,32 +31,20 @@ from litellm.exceptions import (
|
|||
ServiceUnavailableError,
|
||||
)
|
||||
|
||||
from .authenticator import github_api_headers
|
||||
from .authenticator import Authenticator
|
||||
from .common_utils import (
|
||||
aget_copilot_api_base,
|
||||
aprefetch_copilot_plan_api_base,
|
||||
get_copilot_api_base,
|
||||
get_copilot_auth_headers,
|
||||
get_copilot_default_headers,
|
||||
prefetch_copilot_plan_api_base,
|
||||
)
|
||||
from .github_host import GithubHostEndpoints, github_host_endpoints, normalized_github_host
|
||||
|
||||
GITHUB_COPILOT_USER_SESSION_KWARG_KEY: Final = "github_copilot_user_session"
|
||||
_LLM_PROVIDER: Final = "github_copilot"
|
||||
_DEVICE_FLOW_SCOPE: Final = "read:user"
|
||||
_SESSION_CACHE_MAX_SIZE: Final = 1000
|
||||
_SYNC_LOCK_COUNT: Final = 64
|
||||
_ACCOUNT_LOOKUP_TTL_SECONDS: Final = 1800
|
||||
|
||||
|
||||
class _SessionCache(Protocol):
|
||||
def get_cache(self, key: str) -> object: ...
|
||||
|
||||
def set_cache(self, key: str, value: object, ttl: float | None = None) -> object: ...
|
||||
|
||||
def delete_cache(self, key: str) -> object: ...
|
||||
|
||||
|
||||
class _SyncGetClient(Protocol):
|
||||
def get(
|
||||
self,
|
||||
url: str,
|
||||
params: dict[str, str] | None = None, # mutable-ok: mirrors the untyped client's dict parameters
|
||||
headers: dict[str, str] | None = None, # mutable-ok: mirrors the untyped client's dict parameters
|
||||
) -> httpx.Response: ...
|
||||
|
||||
|
||||
class _AsyncGitHubClient(Protocol):
|
||||
|
|
@ -84,26 +65,6 @@ class _AsyncGitHubClient(Protocol):
|
|||
) -> httpx.Response: ...
|
||||
|
||||
|
||||
_SESSION_CACHE: Final = cast( # cast-ok: pins the untyped module cache to _SessionCache
|
||||
_SessionCache,
|
||||
InMemoryCache(
|
||||
max_size_in_memory=_SESSION_CACHE_MAX_SIZE,
|
||||
default_ttl=GITHUB_COPILOT_USER_TOKEN_SAFETY_MARGIN_SECONDS,
|
||||
),
|
||||
)
|
||||
_EXCHANGE_LOCKS: Final = tuple(threading.Lock() for _ in range(_SYNC_LOCK_COUNT))
|
||||
_IN_FLIGHT: Final[ # mutable-ok: single-flight dedup registry mutated under its own locking
|
||||
dict[str, tuple[str, "asyncio.Future[GithubCopilotUserSession]"]]
|
||||
] = {}
|
||||
|
||||
|
||||
@with_config(ConfigDict(extra="allow", strict=True))
|
||||
class _CopilotTokenPayload(TypedDict):
|
||||
token: ReadOnly[str]
|
||||
expires_at: ReadOnly[NotRequired[object]]
|
||||
endpoints: ReadOnly[NotRequired[object]]
|
||||
|
||||
|
||||
@with_config(ConfigDict(extra="allow", strict=True))
|
||||
class _DeviceCodePayload(TypedDict):
|
||||
device_code: ReadOnly[str]
|
||||
|
|
@ -121,43 +82,27 @@ class _AccessTokenPollPayload(TypedDict):
|
|||
interval: ReadOnly[NotRequired[object]]
|
||||
|
||||
|
||||
@with_config(ConfigDict(extra="allow", strict=True))
|
||||
class _CopilotEndpointsPayload(TypedDict):
|
||||
api: ReadOnly[NotRequired[object]]
|
||||
|
||||
|
||||
@with_config(ConfigDict(extra="allow", strict=True))
|
||||
class _CopilotAccountPayload(TypedDict):
|
||||
access_type_sku: ReadOnly[NotRequired[object]]
|
||||
chat_enabled: ReadOnly[NotRequired[object]]
|
||||
endpoints: ReadOnly[NotRequired[object]]
|
||||
|
||||
|
||||
@with_config(ConfigDict(extra="allow", strict=True))
|
||||
class _GithubUserPayload(TypedDict):
|
||||
login: ReadOnly[str]
|
||||
|
||||
|
||||
_COPILOT_TOKEN: Final = TypeAdapter(_CopilotTokenPayload)
|
||||
_COPILOT_ACCOUNT: Final = TypeAdapter(_CopilotAccountPayload)
|
||||
_DEVICE_CODE: Final = TypeAdapter(_DeviceCodePayload)
|
||||
_ACCESS_TOKEN_POLL: Final = TypeAdapter(_AccessTokenPollPayload)
|
||||
_GITHUB_USER: Final = TypeAdapter(_GithubUserPayload)
|
||||
_COPILOT_ENDPOINTS: Final = TypeAdapter(_CopilotEndpointsPayload)
|
||||
_CACHED_SESSION_ENTRY: Final = TypeAdapter(tuple[str, str, str, float])
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, repr=False)
|
||||
class GithubCopilotUserSession:
|
||||
"""A resolved per-user Copilot token plus the validated host it may be sent
|
||||
to. Only this module constructs it, so ``isinstance`` checks downstream are
|
||||
unforgeable by request JSON."""
|
||||
"""A caller's stored GitHub OAuth token plus the GitHub host it belongs to,
|
||||
resolved for this request. Only this module constructs it, so ``isinstance``
|
||||
checks downstream are unforgeable by request JSON."""
|
||||
|
||||
token: str = field(repr=False)
|
||||
api_base: str
|
||||
endpoints: GithubHostEndpoints
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"GithubCopilotUserSession(api_base={self.api_base!r}, token=REDACTED)"
|
||||
return f"GithubCopilotUserSession(github_host={self.endpoints.host!r}, token=REDACTED)"
|
||||
|
||||
__str__ = __repr__
|
||||
|
||||
|
|
@ -181,27 +126,39 @@ class GithubCopilotDeviceFlowPoll:
|
|||
access_token: str | None = field(default=None, repr=False)
|
||||
|
||||
|
||||
def validated_copilot_api_base(endpoints_api: object, endpoints: GithubHostEndpoints) -> str:
|
||||
if not isinstance(endpoints_api, str):
|
||||
return endpoints.default_copilot_api_base
|
||||
try:
|
||||
parsed: Final = urlparse(endpoints_api)
|
||||
port: Final = parsed.port
|
||||
except ValueError:
|
||||
return endpoints.default_copilot_api_base
|
||||
hostname: Final = (parsed.hostname or "").lower()
|
||||
if (
|
||||
parsed.scheme.lower() != "https"
|
||||
or not (
|
||||
hostname in endpoints.copilot_api_hosts
|
||||
or (endpoints.copilot_api_host_suffix is not None and hostname.endswith(endpoints.copilot_api_host_suffix))
|
||||
)
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or port not in (None, 443)
|
||||
):
|
||||
return endpoints.default_copilot_api_base
|
||||
return endpoints_api.rstrip("/")
|
||||
def _reject_connection(credential_name: str) -> CallerCredentialAuthenticationError:
|
||||
return CallerCredentialAuthenticationError(
|
||||
message=(
|
||||
f"GitHub rejected the GitHub Copilot connection for credential '{credential_name}'. "
|
||||
f"Reconnect GitHub Copilot for credential '{credential_name}' in the LiteLLM UI (LLM Credentials)"
|
||||
),
|
||||
llm_provider=_LLM_PROVIDER,
|
||||
model="",
|
||||
)
|
||||
|
||||
|
||||
def _to_int(raw: object) -> int:
|
||||
if isinstance(raw, bool):
|
||||
return 0
|
||||
if isinstance(raw, int):
|
||||
return raw
|
||||
if isinstance(raw, float):
|
||||
return int(raw)
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
return int(raw)
|
||||
except ValueError:
|
||||
return 0
|
||||
return 0
|
||||
|
||||
|
||||
def _new_async_github_client() -> _AsyncGitHubClient:
|
||||
from litellm.llms.custom_httpx import http_handler
|
||||
|
||||
return cast( # cast-ok: pins the untyped litellm client factory to the local Protocol
|
||||
Callable[[str], _AsyncGitHubClient],
|
||||
http_handler.get_async_httpx_client,
|
||||
)(_LLM_PROVIDER)
|
||||
|
||||
|
||||
def github_copilot_host_endpoints(credential_name: str) -> GithubHostEndpoints:
|
||||
|
|
@ -221,265 +178,12 @@ def github_copilot_host_endpoints(credential_name: str) -> GithubHostEndpoints:
|
|||
return github_host_endpoints(host)
|
||||
|
||||
|
||||
def _session_cache_key(user_id: str, credential_name: str, github_host: str) -> str:
|
||||
return f"{user_id}\x00{credential_name}\x00{github_host}"
|
||||
|
||||
|
||||
def evict_copilot_user_session(user_id: str, credential_name: str, github_host: str) -> None:
|
||||
_SESSION_CACHE.delete_cache(_session_cache_key(user_id, credential_name, github_host))
|
||||
|
||||
|
||||
def _cached_session(cache_key: str, github_token: str) -> GithubCopilotUserSession | None:
|
||||
cached: Final[object] = _SESSION_CACHE.get_cache(cache_key)
|
||||
try:
|
||||
cached_github_token, token, api_base, expires_at = _CACHED_SESSION_ENTRY.validate_python(cached)
|
||||
except ValidationError:
|
||||
return None
|
||||
if not hmac.compare_digest(cached_github_token, github_token):
|
||||
return None
|
||||
if expires_at - GITHUB_COPILOT_USER_TOKEN_SAFETY_MARGIN_SECONDS <= time.time():
|
||||
return None
|
||||
return GithubCopilotUserSession(token=token, api_base=api_base)
|
||||
|
||||
|
||||
def _cache_session(cache_key: str, github_token: str, session: GithubCopilotUserSession, expires_at: float) -> None:
|
||||
ttl: Final = expires_at - GITHUB_COPILOT_USER_TOKEN_SAFETY_MARGIN_SECONDS - time.time()
|
||||
if ttl <= 0:
|
||||
return
|
||||
_SESSION_CACHE.set_cache(cache_key, (github_token, session.token, session.api_base, expires_at), ttl=ttl)
|
||||
|
||||
|
||||
def _reject_connection(credential_name: str) -> CallerCredentialAuthenticationError:
|
||||
return CallerCredentialAuthenticationError(
|
||||
message=(
|
||||
f"GitHub rejected the GitHub Copilot connection for credential '{credential_name}'. "
|
||||
f"Reconnect GitHub Copilot for credential '{credential_name}' in the LiteLLM UI (LLM Credentials)"
|
||||
),
|
||||
llm_provider=_LLM_PROVIDER,
|
||||
model="",
|
||||
)
|
||||
|
||||
|
||||
def _session_from_response(
|
||||
response: httpx.Response,
|
||||
github_token: str,
|
||||
credential_name: str,
|
||||
cache_key: str,
|
||||
endpoints: GithubHostEndpoints,
|
||||
) -> GithubCopilotUserSession:
|
||||
if response.status_code in (401, 403, 404):
|
||||
_SESSION_CACHE.delete_cache(cache_key)
|
||||
raise _reject_connection(credential_name)
|
||||
if response.status_code == 429:
|
||||
raise CallerCredentialRateLimitError(
|
||||
message="GitHub rate limited the GitHub Copilot token exchange",
|
||||
llm_provider=_LLM_PROVIDER,
|
||||
model="",
|
||||
)
|
||||
if not response.is_success:
|
||||
raise ServiceUnavailableError(
|
||||
message=f"GitHub Copilot token exchange failed with status {response.status_code}",
|
||||
llm_provider=_LLM_PROVIDER,
|
||||
model="",
|
||||
)
|
||||
if not endpoints.exchanges_copilot_token:
|
||||
return _session_from_account(response, github_token, credential_name, cache_key, endpoints)
|
||||
try:
|
||||
payload: Final = _COPILOT_TOKEN.validate_python(response.json())
|
||||
except Exception as e:
|
||||
raise ServiceUnavailableError(
|
||||
message="GitHub Copilot token exchange returned an invalid response",
|
||||
llm_provider=_LLM_PROVIDER,
|
||||
model="",
|
||||
) from e
|
||||
|
||||
api_base: Final = validated_copilot_api_base(_endpoints_api(payload.get("endpoints")), endpoints)
|
||||
session: Final = GithubCopilotUserSession(token=payload["token"], api_base=api_base)
|
||||
expires_at: Final = _expires_at_seconds(payload.get("expires_at"))
|
||||
if expires_at > 0:
|
||||
_cache_session(cache_key, github_token, session, expires_at)
|
||||
return session
|
||||
|
||||
|
||||
def _session_from_account(
|
||||
response: httpx.Response,
|
||||
github_token: str,
|
||||
credential_name: str,
|
||||
cache_key: str,
|
||||
endpoints: GithubHostEndpoints,
|
||||
) -> GithubCopilotUserSession:
|
||||
try:
|
||||
payload: Final = _COPILOT_ACCOUNT.validate_python(response.json())
|
||||
except Exception as e:
|
||||
raise ServiceUnavailableError(
|
||||
message="GitHub Copilot account lookup returned an invalid response",
|
||||
llm_provider=_LLM_PROVIDER,
|
||||
model="",
|
||||
) from e
|
||||
if payload.get("chat_enabled") is False or payload.get("access_type_sku") == "no_access":
|
||||
_SESSION_CACHE.delete_cache(cache_key)
|
||||
raise _reject_connection(credential_name)
|
||||
|
||||
api_base: Final = validated_copilot_api_base(_endpoints_api(payload.get("endpoints")), endpoints)
|
||||
session: Final = GithubCopilotUserSession(token=github_token, api_base=api_base)
|
||||
_cache_session(cache_key, github_token, session, time.time() + _ACCOUNT_LOOKUP_TTL_SECONDS)
|
||||
return session
|
||||
|
||||
|
||||
def _endpoints_api(payload_endpoints: object) -> object:
|
||||
if not isinstance(payload_endpoints, Mapping):
|
||||
return None
|
||||
return _COPILOT_ENDPOINTS.validate_python(payload_endpoints).get("api")
|
||||
|
||||
|
||||
def _session_lookup_url(endpoints: GithubHostEndpoints) -> str:
|
||||
return endpoints.api_key_url if endpoints.exchanges_copilot_token else endpoints.copilot_user_url
|
||||
|
||||
|
||||
def _to_int(raw: object) -> int:
|
||||
if isinstance(raw, bool):
|
||||
return 0
|
||||
if isinstance(raw, int):
|
||||
return raw
|
||||
if isinstance(raw, float):
|
||||
return int(raw)
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
return int(raw)
|
||||
except ValueError:
|
||||
return 0
|
||||
return 0
|
||||
|
||||
|
||||
def _expires_at_seconds(raw: object) -> float:
|
||||
if isinstance(raw, bool):
|
||||
return 0.0
|
||||
if isinstance(raw, (int, float)):
|
||||
return float(raw)
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
return float(raw)
|
||||
except ValueError:
|
||||
return 0.0
|
||||
return 0.0
|
||||
|
||||
|
||||
def _do_exchange(
|
||||
github_token: str,
|
||||
credential_name: str,
|
||||
cache_key: str,
|
||||
endpoints: GithubHostEndpoints,
|
||||
) -> GithubCopilotUserSession:
|
||||
import litellm
|
||||
|
||||
try:
|
||||
client: Final = cast( # cast-ok: pins the untyped litellm client to the local Protocol
|
||||
_SyncGetClient, litellm.module_level_client
|
||||
)
|
||||
response: Final = client.get(
|
||||
_session_lookup_url(endpoints),
|
||||
headers=github_api_headers(github_token),
|
||||
)
|
||||
except Exception as e:
|
||||
raise APIConnectionError(
|
||||
message="GitHub Copilot token exchange request failed",
|
||||
llm_provider=_LLM_PROVIDER,
|
||||
model="",
|
||||
) from e
|
||||
return _session_from_response(response, github_token, credential_name, cache_key, endpoints)
|
||||
|
||||
|
||||
def _new_async_github_client() -> _AsyncGitHubClient:
|
||||
from litellm.llms.custom_httpx import http_handler
|
||||
|
||||
return cast( # cast-ok: pins the untyped litellm client factory to the local Protocol
|
||||
Callable[[str], _AsyncGitHubClient],
|
||||
http_handler.get_async_httpx_client,
|
||||
)(_LLM_PROVIDER)
|
||||
|
||||
|
||||
async def _do_aexchange(
|
||||
github_token: str,
|
||||
credential_name: str,
|
||||
cache_key: str,
|
||||
endpoints: GithubHostEndpoints,
|
||||
) -> GithubCopilotUserSession:
|
||||
client: Final = _new_async_github_client()
|
||||
try:
|
||||
response: Final = await client.get(
|
||||
_session_lookup_url(endpoints),
|
||||
headers=github_api_headers(github_token),
|
||||
)
|
||||
except Exception as e:
|
||||
raise APIConnectionError(
|
||||
message="GitHub Copilot token exchange request failed",
|
||||
llm_provider=_LLM_PROVIDER,
|
||||
model="",
|
||||
) from e
|
||||
return _session_from_response(response, github_token, credential_name, cache_key, endpoints)
|
||||
|
||||
|
||||
def exchange_github_token(
|
||||
user_id: str,
|
||||
github_token: str,
|
||||
credential_name: str,
|
||||
endpoints: GithubHostEndpoints,
|
||||
) -> GithubCopilotUserSession:
|
||||
cache_key: Final = _session_cache_key(user_id, credential_name, endpoints.host)
|
||||
cached: Final = _cached_session(cache_key, github_token)
|
||||
if cached is not None:
|
||||
return cached
|
||||
lock: Final = _EXCHANGE_LOCKS[hash(cache_key) % _SYNC_LOCK_COUNT]
|
||||
with lock:
|
||||
rechecked: Final = _cached_session(cache_key, github_token)
|
||||
if rechecked is not None:
|
||||
return rechecked
|
||||
return _do_exchange(github_token, credential_name, cache_key, endpoints)
|
||||
|
||||
|
||||
async def aexchange_github_token(
|
||||
user_id: str,
|
||||
github_token: str,
|
||||
credential_name: str,
|
||||
endpoints: GithubHostEndpoints,
|
||||
) -> GithubCopilotUserSession:
|
||||
cache_key: Final = _session_cache_key(user_id, credential_name, endpoints.host)
|
||||
cached: Final = _cached_session(cache_key, github_token)
|
||||
if cached is not None:
|
||||
return cached
|
||||
loop: Final = asyncio.get_running_loop()
|
||||
in_flight_entry: Final = _IN_FLIGHT.get(cache_key)
|
||||
if in_flight_entry is not None:
|
||||
in_flight_token, in_flight = in_flight_entry
|
||||
if not hmac.compare_digest(in_flight_token, github_token):
|
||||
return await _do_aexchange(github_token, credential_name, cache_key, endpoints)
|
||||
if not in_flight.done() and in_flight.get_loop() is loop:
|
||||
return await asyncio.shield(in_flight)
|
||||
future: Final = loop.create_future()
|
||||
entry: Final = (github_token, future)
|
||||
_IN_FLIGHT[cache_key] = entry
|
||||
try:
|
||||
session: Final = await _do_aexchange(github_token, credential_name, cache_key, endpoints)
|
||||
except BaseException as e:
|
||||
if not future.done():
|
||||
future.set_exception(e)
|
||||
future.exception() # consume so unawaited waiters do not log "exception was never retrieved"
|
||||
raise
|
||||
else:
|
||||
if not future.done():
|
||||
future.set_result(session)
|
||||
return session
|
||||
finally:
|
||||
if _IN_FLIGHT.get(cache_key) is entry:
|
||||
_IN_FLIGHT.pop(cache_key, None)
|
||||
|
||||
|
||||
async def astart_device_flow(endpoints: GithubHostEndpoints) -> GithubCopilotDeviceFlowStart:
|
||||
client: Final = _new_async_github_client()
|
||||
try:
|
||||
response: Final = await client.post(
|
||||
endpoints.device_code_url,
|
||||
headers=github_api_headers(),
|
||||
headers=get_copilot_auth_headers(),
|
||||
json={"client_id": endpoints.client_id, "scope": _DEVICE_FLOW_SCOPE},
|
||||
)
|
||||
except httpx.HTTPStatusError as e:
|
||||
|
|
@ -516,7 +220,7 @@ async def apoll_device_flow(device_code: str, endpoints: GithubHostEndpoints) ->
|
|||
try:
|
||||
response: Final = await client.post(
|
||||
endpoints.access_token_url,
|
||||
headers=github_api_headers(),
|
||||
headers=get_copilot_auth_headers(),
|
||||
json={
|
||||
"client_id": endpoints.client_id,
|
||||
"device_code": device_code,
|
||||
|
|
@ -579,7 +283,7 @@ async def afetch_github_login(github_token: str, endpoints: GithubHostEndpoints)
|
|||
try:
|
||||
response: Final = await client.get(
|
||||
endpoints.user_url,
|
||||
headers=github_api_headers(github_token),
|
||||
headers=get_copilot_auth_headers(github_token),
|
||||
)
|
||||
except Exception as e:
|
||||
raise APIConnectionError(
|
||||
|
|
@ -604,18 +308,34 @@ async def afetch_github_login(github_token: str, endpoints: GithubHostEndpoints)
|
|||
return payload["login"]
|
||||
|
||||
|
||||
async def acheck_copilot_seat(
|
||||
user_id: str,
|
||||
github_token: str,
|
||||
credential_name: str,
|
||||
endpoints: GithubHostEndpoints,
|
||||
) -> GithubCopilotUserSession:
|
||||
return await aexchange_github_token(
|
||||
user_id=user_id,
|
||||
github_token=github_token,
|
||||
credential_name=credential_name,
|
||||
endpoints=endpoints,
|
||||
)
|
||||
async def acheck_copilot_seat(github_token: str, credential_name: str, endpoints: GithubHostEndpoints) -> None:
|
||||
client: Final = _new_async_github_client()
|
||||
api_base: Final = await aget_copilot_api_base(github_token, endpoints=endpoints)
|
||||
try:
|
||||
response: Final = await client.get(
|
||||
f"{api_base.rstrip('/')}/models",
|
||||
headers=get_copilot_default_headers(github_token),
|
||||
)
|
||||
except Exception as e:
|
||||
raise APIConnectionError(
|
||||
message="GitHub Copilot seat check request failed",
|
||||
llm_provider=_LLM_PROVIDER,
|
||||
model="",
|
||||
) from e
|
||||
if response.status_code in (401, 403, 404):
|
||||
raise _reject_connection(credential_name)
|
||||
if response.status_code == 429:
|
||||
raise CallerCredentialRateLimitError(
|
||||
message="GitHub Copilot rate limited the seat check",
|
||||
llm_provider=_LLM_PROVIDER,
|
||||
model="",
|
||||
)
|
||||
if not response.is_success:
|
||||
raise ServiceUnavailableError(
|
||||
message=f"GitHub Copilot seat check failed with status {response.status_code}",
|
||||
llm_provider=_LLM_PROVIDER,
|
||||
model="",
|
||||
)
|
||||
|
||||
|
||||
def github_copilot_auth_mode(litellm_credential_name: object, auth_type_value: object) -> bool:
|
||||
|
|
@ -729,6 +449,25 @@ def require_github_copilot_user_session(litellm_params: object) -> GithubCopilot
|
|||
return None
|
||||
|
||||
|
||||
def resolve_copilot_api_base(
|
||||
api_base: str | None, user_session: GithubCopilotUserSession | None, shared_token: Callable[[], str | None]
|
||||
) -> str:
|
||||
if user_session is None:
|
||||
return get_copilot_api_base(api_base, shared_token)
|
||||
return get_copilot_api_base(None, lambda: user_session.token, user_session.endpoints)
|
||||
|
||||
|
||||
def github_copilot_caller_rejection(
|
||||
original_exception: object, request_kwargs: object
|
||||
) -> CallerCredentialAuthenticationError | None:
|
||||
if (
|
||||
getattr(original_exception, "status_code", None) != 401
|
||||
or github_copilot_user_session_from(request_kwargs) is None
|
||||
):
|
||||
return None
|
||||
return _reject_connection(github_copilot_per_user_credential_name(request_kwargs) or "")
|
||||
|
||||
|
||||
def is_github_copilot_per_user_request(kwargs: Mapping[str, object]) -> bool:
|
||||
"""True when this call carries a resolved per-user Copilot session, meaning it
|
||||
must bypass the shared response cache (cache keys ignore caller identity).
|
||||
|
|
@ -803,34 +542,45 @@ def attach_github_copilot_user_session(
|
|||
return
|
||||
credential_name: Final = credential_name_obj if isinstance(credential_name_obj, str) else ""
|
||||
endpoints: Final = github_copilot_host_endpoints(credential_name)
|
||||
user_id, github_token = _caller_connection(kwargs, credential_name, endpoints)
|
||||
session: Final = exchange_github_token(
|
||||
user_id=user_id,
|
||||
github_token=github_token,
|
||||
credential_name=credential_name,
|
||||
endpoints=endpoints,
|
||||
)
|
||||
_, github_token = _caller_connection(kwargs, credential_name, endpoints)
|
||||
session: Final = GithubCopilotUserSession(token=github_token, endpoints=endpoints)
|
||||
kwargs[GITHUB_COPILOT_USER_SESSION_KWARG_KEY] = session # rebind-ok: kwargs is the request mutation channel
|
||||
_strip_caller_authorization(kwargs)
|
||||
|
||||
|
||||
async def aattach_github_copilot_user_session(
|
||||
def _plan_api_base_request(
|
||||
kwargs: Mapping[str, object], model: object
|
||||
) -> tuple[str, str | None, GithubHostEndpoints | None] | None:
|
||||
api_base_obj: Final = kwargs.get("api_base")
|
||||
api_base: Final = api_base_obj if isinstance(api_base_obj, str) else None
|
||||
session: Final = kwargs.get(GITHUB_COPILOT_USER_SESSION_KWARG_KEY)
|
||||
if isinstance(session, GithubCopilotUserSession):
|
||||
return session.token, None, session.endpoints
|
||||
import litellm
|
||||
|
||||
requested_model: Final = litellm.model_alias_map.get(model, model) if isinstance(model, str) else model
|
||||
targets_github_copilot: Final = kwargs.get("custom_llm_provider") == _LLM_PROVIDER or (
|
||||
isinstance(requested_model, str) and requested_model.startswith(f"{_LLM_PROVIDER}/")
|
||||
)
|
||||
shared_token: Final = Authenticator().stored_access_token() if targets_github_copilot else None
|
||||
return (shared_token, api_base, None) if shared_token is not None else None
|
||||
|
||||
|
||||
def prepare_github_copilot_request(
|
||||
kwargs: dict[str, object], # mutable-ok: kwargs is the request mutation channel
|
||||
model: object,
|
||||
) -> None:
|
||||
credential_name_obj: Final[object] = kwargs.get("litellm_credential_name")
|
||||
auth_type_obj: Final[object] = kwargs.get(GITHUB_COPILOT_AUTH_TYPE_KEY)
|
||||
if not github_copilot_auth_mode(credential_name_obj, auth_type_obj):
|
||||
return
|
||||
if isinstance(kwargs.get(GITHUB_COPILOT_USER_SESSION_KWARG_KEY), GithubCopilotUserSession):
|
||||
return
|
||||
credential_name: Final = credential_name_obj if isinstance(credential_name_obj, str) else ""
|
||||
endpoints: Final = github_copilot_host_endpoints(credential_name)
|
||||
user_id, github_token = _caller_connection(kwargs, credential_name, endpoints)
|
||||
session: Final = await aexchange_github_token(
|
||||
user_id=user_id,
|
||||
github_token=github_token,
|
||||
credential_name=credential_name,
|
||||
endpoints=endpoints,
|
||||
)
|
||||
kwargs[GITHUB_COPILOT_USER_SESSION_KWARG_KEY] = session # rebind-ok: kwargs is the request mutation channel
|
||||
_strip_caller_authorization(kwargs)
|
||||
attach_github_copilot_user_session(kwargs)
|
||||
plan_request: Final = _plan_api_base_request(kwargs, model)
|
||||
if plan_request is not None:
|
||||
prefetch_copilot_plan_api_base(*plan_request)
|
||||
|
||||
|
||||
async def aprepare_github_copilot_request(
|
||||
kwargs: dict[str, object], # mutable-ok: kwargs is the request mutation channel
|
||||
model: object,
|
||||
) -> None:
|
||||
attach_github_copilot_user_session(kwargs)
|
||||
plan_request: Final = _plan_api_base_request(kwargs, model)
|
||||
if plan_request is not None:
|
||||
await aprefetch_copilot_plan_api_base(*plan_request)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ Implementation based on analysis of the copilot-api project by caozhiyuan:
|
|||
https://github.com/caozhiyuan/copilot-api
|
||||
"""
|
||||
|
||||
import os
|
||||
from collections.abc import Mapping
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
|
|
@ -37,12 +36,11 @@ from litellm.utils import cached_get_model_info_helper
|
|||
|
||||
from ..authenticator import Authenticator
|
||||
from ..common_utils import (
|
||||
DEFAULT_GITHUB_COPILOT_API_BASE,
|
||||
GetAPIKeyError,
|
||||
get_copilot_default_headers,
|
||||
pin_session_authorization,
|
||||
)
|
||||
from ..per_user_auth import require_github_copilot_user_session
|
||||
from ..per_user_auth import require_github_copilot_user_session, resolve_copilot_api_base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj
|
||||
|
|
@ -274,15 +272,8 @@ class GithubCopilotResponsesAPIConfig(OpenAIResponsesAPIConfig):
|
|||
user_session: Final = require_github_copilot_user_session(
|
||||
cast("dict[str, object]", litellm_params) # cast-ok: litellm_params arrives as an untyped request dict
|
||||
)
|
||||
effective_api_base: Final = (
|
||||
(user_session.api_base if user_session is not None else None)
|
||||
or api_base
|
||||
or self.authenticator.get_api_base()
|
||||
or os.getenv("GITHUB_COPILOT_API_BASE")
|
||||
or DEFAULT_GITHUB_COPILOT_API_BASE
|
||||
)
|
||||
|
||||
return join_api_base(effective_api_base, "responses")
|
||||
resolved: Final = resolve_copilot_api_base(api_base, user_session, self.authenticator.get_api_key)
|
||||
return join_api_base(resolved, "responses")
|
||||
|
||||
def _handle_reasoning_item(self, item: dict[str, object]) -> dict[str, object]:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -2819,6 +2819,7 @@ def _complete_custom_openai(
|
|||
from litellm.llms.github_copilot.common_utils import (
|
||||
get_copilot_default_headers,
|
||||
pin_session_authorization,
|
||||
with_openai_sdk_header_names,
|
||||
)
|
||||
from litellm.llms.github_copilot.per_user_auth import (
|
||||
require_github_copilot_user_session,
|
||||
|
|
@ -2834,7 +2835,7 @@ def _complete_custom_openai(
|
|||
)
|
||||
if user_session is not None:
|
||||
pin_session_authorization(copilot_headers, user_session.token)
|
||||
extra_headers = copilot_headers
|
||||
extra_headers = with_openai_sdk_header_names(copilot_headers)
|
||||
|
||||
use_base_llm_http_handler: Final = get_secret_bool("EXPERIMENTAL_OPENAI_BASE_LLM_HTTP_HANDLER")
|
||||
|
||||
|
|
|
|||
|
|
@ -74,7 +74,6 @@ from .user_provider_credentials import (
|
|||
delete_user_provider_credentials_for_credential,
|
||||
invalidate_user_provider_credential_cache,
|
||||
list_user_provider_credentials,
|
||||
list_user_provider_credentials_for_credential,
|
||||
set_user_provider_credential_cache,
|
||||
upsert_user_provider_credential,
|
||||
)
|
||||
|
|
@ -741,12 +740,7 @@ async def poll_user_connection(
|
|||
if not github_token:
|
||||
raise HTTPException(status_code=502, detail="GitHub device flow completed without a token")
|
||||
try:
|
||||
await acheck_copilot_seat(
|
||||
user_id=user_id,
|
||||
github_token=github_token,
|
||||
credential_name=credential_name,
|
||||
endpoints=endpoints,
|
||||
)
|
||||
await acheck_copilot_seat(github_token=github_token, credential_name=credential_name, endpoints=endpoints)
|
||||
except litellm.CallerCredentialAuthenticationError:
|
||||
return UserConnectionPollResponse(status="no_copilot_seat")
|
||||
github_login: Final = await afetch_github_login(github_token, endpoints)
|
||||
|
|
@ -791,7 +785,6 @@ async def delete_user_connection(
|
|||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), # noqa: B008 # FastAPI resolves the dependency from the default
|
||||
) -> UserConnectionDeleteResponse:
|
||||
"""Disconnect the calling user's stored GitHub token for a per-user credential. Idempotent."""
|
||||
from litellm.llms.github_copilot.per_user_auth import evict_copilot_user_session
|
||||
from litellm.proxy.proxy_server import prisma_client, user_api_key_cache
|
||||
|
||||
try:
|
||||
|
|
@ -813,9 +806,7 @@ async def delete_user_connection(
|
|||
status_code=503,
|
||||
detail={"error": "could not revoke cached connection, retry"},
|
||||
)
|
||||
prior: Final = await delete_user_provider_credential(prisma_client, user_id, credential_name)
|
||||
if prior is not None:
|
||||
evict_copilot_user_session(user_id, credential_name, prior.github_host)
|
||||
await delete_user_provider_credential(prisma_client, user_id, credential_name)
|
||||
return UserConnectionDeleteResponse(status="disconnected")
|
||||
except Exception as e: # noqa: BLE001 # endpoint boundary: every failure becomes the proxy error contract
|
||||
raise handle_exception_on_proxy(e)
|
||||
|
|
@ -827,28 +818,19 @@ async def _purge_user_connections_for_credential(credential_name: str) -> None:
|
|||
|
||||
Shared cleanup for DELETE (credential removed) and PATCH (renamed or no
|
||||
longer per-user)."""
|
||||
from litellm.llms.github_copilot.per_user_auth import evict_copilot_user_session
|
||||
from litellm.proxy.proxy_server import prisma_client, user_api_key_cache
|
||||
|
||||
if prisma_client is None:
|
||||
return
|
||||
try:
|
||||
prior_rows: Final = await list_user_provider_credentials_for_credential(prisma_client, credential_name)
|
||||
user_ids: Final = await delete_user_provider_credentials_for_credential(prisma_client, credential_name)
|
||||
except Exception: # noqa: BLE001 # best-effort purge must not break credential deletion
|
||||
verbose_proxy_logger.exception(
|
||||
"_purge_user_connections_for_credential: failed to drop user connections for %s", credential_name
|
||||
)
|
||||
return
|
||||
github_hosts: Final = {
|
||||
row.user_id: decoded.github_host
|
||||
for row in prior_rows
|
||||
if (decoded := decode_user_provider_credential(row.credential_b64)) is not None
|
||||
}
|
||||
for user_id in user_ids:
|
||||
await invalidate_user_provider_credential_cache(user_api_key_cache, user_id, credential_name)
|
||||
if user_id in github_hosts:
|
||||
evict_copilot_user_session(user_id, credential_name, github_hosts[user_id])
|
||||
|
||||
|
||||
@router.delete(
|
||||
|
|
|
|||
|
|
@ -151,13 +151,6 @@ async def list_user_provider_credentials(
|
|||
return await _table(prisma_client).find_many(where={"user_id": user_id})
|
||||
|
||||
|
||||
async def list_user_provider_credentials_for_credential(
|
||||
prisma_client: "PrismaClient",
|
||||
credential_name: str,
|
||||
) -> Sequence["prisma_models.LiteLLM_UserProviderCredentials"]:
|
||||
return await _table(prisma_client).find_many(where={"credential_name": credential_name})
|
||||
|
||||
|
||||
class _StringKeyCache(Protocol):
|
||||
async def async_get_cache(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -122,12 +122,15 @@ from litellm.litellm_core_utils.batch_utils import (
|
|||
without_deployments_that_cannot_batch,
|
||||
)
|
||||
from litellm.litellm_core_utils.clientside_credential_handler import (
|
||||
DeploymentParamLookup,
|
||||
get_dynamic_litellm_params,
|
||||
github_copilot_caller_endpoint_keys,
|
||||
is_clientside_credential,
|
||||
)
|
||||
from litellm.litellm_core_utils.completion_timeout import CompletionTimeout
|
||||
from litellm.litellm_core_utils.core_helpers import (
|
||||
coerce_token_limit,
|
||||
drops_unsupported_params,
|
||||
get_litellm_metadata_from_kwargs,
|
||||
get_metadata_variable_name_from_kwargs,
|
||||
get_or_create_metadata_bucket,
|
||||
|
|
@ -5358,8 +5361,18 @@ class PythonCore(DeprecatedRouterMembers):
|
|||
- On a fallback hop, drops the encrypted reasoning this deployment cannot decrypt, keeping its summary.
|
||||
- On a Responses continuation, sends the partial response as prefill where this deployment takes one.
|
||||
"""
|
||||
for key in self._forwarded_alias_marker_keys_the_deployment_sets(
|
||||
deployment=deployment, forwarded_keys=kwargs.pop(_ALIAS_MARKER_FORWARDED_PARAMS_KWARG, ())
|
||||
caller_endpoint_keys: Final = self._github_copilot_caller_endpoint_keys_to_drop(
|
||||
deployment_param_lookup=cast( # cast-ok: a dict or LiteLLM_Params, both answer get
|
||||
"DeploymentParamLookup", deployment["litellm_params"]
|
||||
),
|
||||
caller_kwargs=cast("Mapping[str, object]", kwargs), # cast-ok: router call kwargs are a str-keyed dict
|
||||
model_name=cast(str, deployment["model_name"]), # cast-ok: every deployment carries a str model_name
|
||||
)
|
||||
for key in (
|
||||
*self._forwarded_alias_marker_keys_the_deployment_sets(
|
||||
deployment=deployment, forwarded_keys=kwargs.pop(_ALIAS_MARKER_FORWARDED_PARAMS_KWARG, ())
|
||||
),
|
||||
*caller_endpoint_keys,
|
||||
):
|
||||
kwargs.pop(key, None)
|
||||
kwargs.pop("daily_reset_time", None) # pyright: ignore[reportUnknownMemberType] # kwargs is an untyped dict
|
||||
|
|
@ -17054,6 +17067,36 @@ class PythonCore(DeprecatedRouterMembers):
|
|||
and value is not None
|
||||
)
|
||||
|
||||
def _github_copilot_caller_endpoint_keys_to_drop(
|
||||
self, deployment_param_lookup: "DeploymentParamLookup", caller_kwargs: Mapping[str, object], model_name: str
|
||||
) -> tuple[str, ...]:
|
||||
caller_endpoint_keys: Final = github_copilot_caller_endpoint_keys(deployment_param_lookup, caller_kwargs)
|
||||
router_defaults: Final = cast( # cast-ok: router default params are a str-keyed dict
|
||||
"Mapping[str, object]", self._core_default_litellm_params
|
||||
)
|
||||
call_drop_params: Final = next(
|
||||
(
|
||||
value
|
||||
for value in (
|
||||
caller_kwargs.get("drop_params"),
|
||||
deployment_param_lookup.get("drop_params"),
|
||||
router_defaults.get("drop_params"),
|
||||
)
|
||||
if value is not None
|
||||
),
|
||||
None,
|
||||
)
|
||||
if caller_endpoint_keys and not drops_unsupported_params(call_drop_params):
|
||||
raise litellm_exceptions.BadRequestError(
|
||||
message=(
|
||||
f"GitHub Copilot deployments do not accept {' or '.join(caller_endpoint_keys)} from the caller. "
|
||||
"Set api_base on the deployment, or set drop_params to drop it"
|
||||
),
|
||||
model=model_name,
|
||||
llm_provider="github_copilot",
|
||||
)
|
||||
return caller_endpoint_keys
|
||||
|
||||
@staticmethod
|
||||
def _forwarded_alias_marker_keys_the_deployment_sets(
|
||||
deployment: Mapping[str, object], forwarded_keys: object
|
||||
|
|
|
|||
|
|
@ -2106,9 +2106,9 @@ def client(original_function):
|
|||
|
||||
## LOAD CREDENTIALS
|
||||
load_credentials_from_list(kwargs, call_type)
|
||||
from litellm.llms.github_copilot.per_user_auth import attach_github_copilot_user_session
|
||||
from litellm.llms.github_copilot.per_user_auth import prepare_github_copilot_request
|
||||
|
||||
attach_github_copilot_user_session(kwargs) # pyright: ignore[reportUnknownArgumentType] # kwargs is the untyped request dict
|
||||
prepare_github_copilot_request(kwargs, model) # pyright: ignore[reportUnknownArgumentType] # kwargs is the untyped request dict
|
||||
kwargs["litellm_logging_obj"] = logging_obj
|
||||
LLMCachingHandler: Final = _get_cached_llm_caching_handler()
|
||||
_llm_caching_handler: Final[LLMCachingHandler] = LLMCachingHandler(
|
||||
|
|
@ -2410,9 +2410,9 @@ def client(original_function):
|
|||
kwargs["litellm_logging_obj"] = logging_obj
|
||||
## LOAD CREDENTIALS
|
||||
kwargs.update(await acredential_updates(kwargs, call_type)) # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # kwargs is the untyped request dict
|
||||
from litellm.llms.github_copilot.per_user_auth import aattach_github_copilot_user_session
|
||||
from litellm.llms.github_copilot.per_user_auth import aprepare_github_copilot_request
|
||||
|
||||
await aattach_github_copilot_user_session(kwargs) # pyright: ignore[reportUnknownArgumentType] # kwargs is the untyped request dict
|
||||
await aprepare_github_copilot_request(kwargs, model) # pyright: ignore[reportUnknownArgumentType] # kwargs is the untyped request dict
|
||||
logging_obj.llm_caching_handler = _llm_caching_handler
|
||||
# [OPTIONAL] CHECK BUDGET
|
||||
if litellm.max_budget:
|
||||
|
|
|
|||
|
|
@ -7,19 +7,22 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from itertools import chain
|
||||
from dataclasses import dataclass
|
||||
from itertools import chain
|
||||
from typing import Final
|
||||
|
||||
import litellm
|
||||
import openai
|
||||
from litellm.integrations.prometheus import PrometheusLogger
|
||||
from litellm.llms.github_copilot.per_user_auth import GithubCopilotUserSession
|
||||
from prometheus_client import REGISTRY
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
import litellm
|
||||
from litellm.integrations.prometheus import PrometheusLogger
|
||||
from litellm.llms.github_copilot.github_host import github_host_endpoints
|
||||
from litellm.llms.github_copilot.per_user_auth import GithubCopilotUserSession
|
||||
|
||||
COPILOT_DEPLOYMENT: Final = "copilot-per-user-deployment"
|
||||
OPERATOR_DEPLOYMENT: Final = "operator-key-deployment"
|
||||
CALLER_SESSION_TOKEN: Final = "caller-copilot-session-token"
|
||||
|
|
@ -77,6 +80,7 @@ _REPORT: Final = TypeAdapter(WorkerReport)
|
|||
def run_copilot_session_worker(api_base: str) -> WorkerReport:
|
||||
completed: Final = subprocess.run(
|
||||
(sys.executable, "-P", __file__, api_base),
|
||||
env={**os.environ, "GITHUB_COPILOT_API_BASE": api_base},
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=WORKER_TIMEOUT_SECONDS,
|
||||
|
|
@ -146,7 +150,7 @@ async def _direct_attempt(api_base: str) -> Outcome:
|
|||
|
||||
async def main(api_base: str) -> None:
|
||||
litellm.callbacks = [PrometheusLogger()] # test-quality-ok: this worker process exits right after the report
|
||||
session: Final = GithubCopilotUserSession(token=CALLER_SESSION_TOKEN, api_base=api_base)
|
||||
session: Final = GithubCopilotUserSession(token=CALLER_SESSION_TOKEN, endpoints=github_host_endpoints("github.com"))
|
||||
router: Final = _router(api_base)
|
||||
outcomes: Final = (
|
||||
await _attempt(router, COPILOT_DEPLOYMENT, github_copilot_user_session=session),
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@ CHATGPT_AUTH_HOST: Final = "auth.openai.com"
|
|||
GITHUB_HOST: Final = "github.com"
|
||||
GITHUB_API_HOST: Final = "api.github.com"
|
||||
AUTH_HOSTS: Final = (CHATGPT_AUTH_HOST, GITHUB_HOST, GITHUB_API_HOST)
|
||||
GHE_HOST: Final = "octo.ghe.com"
|
||||
GHE_API_HOST: Final = "api.octo.ghe.com"
|
||||
GHE_COPILOT_HOST: Final = "copilot-api.octo.ghe.com"
|
||||
TUNNELED_HOSTS: Final = (*AUTH_HOSTS, GHE_HOST, GHE_API_HOST, GHE_COPILOT_HOST)
|
||||
CHATGPT_ACCOUNT: Final = "acct-device-login"
|
||||
_FAR_FUTURE: Final = 4102444800
|
||||
_LONG_AGO: Final = 946684800
|
||||
|
|
@ -73,13 +77,11 @@ COPILOT_USER_CODE: Final = "COPI-LEAK"
|
|||
COPILOT_ACCESS: Final = "device-login-github-access"
|
||||
COPILOT_REJECTED_ACCESS: Final = "device-login-github-access-rejected"
|
||||
COPILOT_FIRST_LOGIN_ACCESS: Final = "device-login-github-access-first-login"
|
||||
COPILOT_UNSAVED_KEY_ACCESS: Final = "device-login-github-access-unsaved-key"
|
||||
COPILOT_STORED_KEY: Final = "tid=device-login;stored"
|
||||
COPILOT_MINTED_KEY: Final = "tid=device-login;minted"
|
||||
COPILOT_REJECTED_KEY: Final = "tid=device-login;rejected"
|
||||
GHE_ACCESS: Final = "device-login-ghe-access"
|
||||
GHE_LOGIN: Final = "octo-user"
|
||||
CONTROL_KEY: Final = "device-login-control-key"
|
||||
ACCEPTED_BEARERS: Final = frozenset(
|
||||
{CHATGPT_STORED, CHATGPT_REFRESHED, CHATGPT_FIRST_LOGIN, COPILOT_STORED_KEY, COPILOT_MINTED_KEY, CONTROL_KEY}
|
||||
{CHATGPT_STORED, CHATGPT_REFRESHED, CHATGPT_FIRST_LOGIN, COPILOT_ACCESS, COPILOT_FIRST_LOGIN_ACCESS, CONTROL_KEY}
|
||||
)
|
||||
REJECTED_DETAIL: Final = "the scripted provider rejected this bearer token"
|
||||
_DEVICE_AUTH_ID: Final = "device-auth-synthetic"
|
||||
|
|
@ -132,26 +134,19 @@ def _oauth_token(request: Request, grant: threading.Event) -> Reply:
|
|||
return _json(400, {"error": "invalid_grant", "error_description": "refresh token was revoked"})
|
||||
|
||||
|
||||
def _copilot_api_key(request: Request, api_url: str) -> Reply:
|
||||
if request.headers.get("authorization") not in (
|
||||
f"token {COPILOT_ACCESS}",
|
||||
f"token {COPILOT_FIRST_LOGIN_ACCESS}",
|
||||
f"token {COPILOT_UNSAVED_KEY_ACCESS}",
|
||||
):
|
||||
return _json(401, {"message": "Bad credentials"})
|
||||
return _json(
|
||||
200,
|
||||
{"token": COPILOT_MINTED_KEY, "expires_at": int(time.time()) + 1800, "endpoints": {"api": api_url}},
|
||||
)
|
||||
|
||||
|
||||
def _switched_off() -> Reply:
|
||||
return _json(503, {"error": "device login is switched off in this scenario"})
|
||||
|
||||
|
||||
def _auth_hosts(api_url: str, grant: threading.Event) -> Callable[[Request], Reply]:
|
||||
def _ghe_token(request: Request) -> str:
|
||||
scheme, _, token = request.headers.get("authorization", "").partition(" ")
|
||||
return token if scheme in ("token", "Bearer") else ""
|
||||
|
||||
|
||||
def _auth_hosts(grant: threading.Event, vendor: rv.ResponsesVendor) -> Callable[[Request], Reply]:
|
||||
def respond(request: Request) -> Reply:
|
||||
match (request.headers.get("host", ""), request.method, urlsplit(request.target).path):
|
||||
host: Final = request.headers.get("host", "")
|
||||
match (host, request.method, urlsplit(request.target).path):
|
||||
case ("auth.openai.com", "POST", "/oauth/token"):
|
||||
return _oauth_token(request, grant)
|
||||
case ("auth.openai.com", "POST", "/api/accounts/deviceauth/usercode") if grant.is_set():
|
||||
|
|
@ -178,8 +173,27 @@ def _auth_hosts(api_url: str, grant: threading.Event) -> Callable[[Request], Rep
|
|||
)
|
||||
case ("github.com", "POST", "/login/oauth/access_token") if grant.is_set():
|
||||
return _json(200, {"access_token": COPILOT_FIRST_LOGIN_ACCESS, "token_type": "bearer"})
|
||||
case ("api.github.com", "GET", "/copilot_internal/v2/token"):
|
||||
return _copilot_api_key(request, api_url)
|
||||
case ("octo.ghe.com", "POST", "/login/device/code") if grant.is_set():
|
||||
return _json(
|
||||
200,
|
||||
{
|
||||
"device_code": _GITHUB_DEVICE_CODE,
|
||||
"user_code": COPILOT_USER_CODE,
|
||||
"verification_uri": f"https://{GHE_HOST}/login/device",
|
||||
"expires_in": 900,
|
||||
"interval": 5,
|
||||
},
|
||||
)
|
||||
case ("octo.ghe.com", "POST", "/login/oauth/access_token") if grant.is_set():
|
||||
return _json(200, {"access_token": GHE_ACCESS, "token_type": "bearer"})
|
||||
case _ if host in (GHE_API_HOST, GHE_COPILOT_HOST) and _ghe_token(request) != GHE_ACCESS:
|
||||
return _json(401, {"message": "Bad credentials"})
|
||||
case ("api.octo.ghe.com", "GET", "/copilot_internal/user"):
|
||||
return _json(200, {"endpoints": {"api": f"https://{GHE_COPILOT_HOST}"}})
|
||||
case ("api.octo.ghe.com", "GET", "/user"):
|
||||
return _json(200, {"login": GHE_LOGIN})
|
||||
case ("copilot-api.octo.ghe.com", _, _):
|
||||
return vendor.respond(request)
|
||||
case _:
|
||||
return _switched_off()
|
||||
|
||||
|
|
@ -306,7 +320,7 @@ def _tunnel(
|
|||
if len(request_line) < 2:
|
||||
return
|
||||
authority: Final = request_line[1]
|
||||
if authority.rsplit(":", 1)[0] not in AUTH_HOSTS:
|
||||
if authority.rsplit(":", 1)[0] not in TUNNELED_HOSTS:
|
||||
self.wfile.write(b"HTTP/1.1 403 Forbidden\r\ncontent-length: 0\r\n\r\n")
|
||||
return
|
||||
authorities.put(authority)
|
||||
|
|
@ -340,13 +354,14 @@ def _tunnel(
|
|||
|
||||
@contextmanager
|
||||
def device_login_peers(directory: Path) -> Generator[Peers]:
|
||||
cert, key = write_self_signed_cert(directory, AUTH_HOSTS)
|
||||
cert, key = write_self_signed_cert(directory, TUNNELED_HOSTS)
|
||||
switches: Final = Switches(threading.Event(), threading.Event(), threading.Event(), threading.Event())
|
||||
authorities: Final[SimpleQueue[str]] = SimpleQueue()
|
||||
hangups: Final[SimpleQueue[Hangup]] = SimpleQueue()
|
||||
vendor: Final = rv.ResponsesVendor()
|
||||
with (
|
||||
wire_server(_provider_api(rv.ResponsesVendor())) as api,
|
||||
wire_server(_auth_hosts(api.url, switches.grant), tls=server_context(cert, key)) as auth,
|
||||
wire_server(_provider_api(vendor)) as api,
|
||||
wire_server(_auth_hosts(switches.grant, vendor), tls=server_context(cert, key)) as auth,
|
||||
_tunnel(auth, switches, authorities, hangups) as tunnel,
|
||||
):
|
||||
yield Peers(api, auth, tunnel, cert, authorities, hangups, switches)
|
||||
|
|
@ -372,12 +387,6 @@ def write_chatgpt(directory: Path, record: Mapping[str, JsonValue]) -> None:
|
|||
(directory / "auth.json").write_text(json.dumps(dict(record)))
|
||||
|
||||
|
||||
def write_copilot_key(directory: Path, token: str, api_url: str, expires_in: float = 3600) -> None:
|
||||
(directory / "api-key.json").write_text(
|
||||
json.dumps({"token": token, "expires_at": time.time() + expires_in, "endpoints": {"api": api_url}})
|
||||
)
|
||||
|
||||
|
||||
def write_copilot_access(directory: Path, access_token: str) -> None:
|
||||
(directory / "access-token").write_text(access_token)
|
||||
|
||||
|
|
|
|||
|
|
@ -666,11 +666,11 @@ def test_github_copilot_messages_forward_safeguards_without_deriving_the_beta(
|
|||
token_dir: Final = tmp_path / "copilot"
|
||||
token_dir.mkdir()
|
||||
with wire_server(anthropic_peer) as wire:
|
||||
(token_dir / "api-key.json").write_text(
|
||||
json.dumps({"token": "synthetic-copilot-token", "expires_at": 4102444800, "endpoints": {"api": wire.url}})
|
||||
)
|
||||
(token_dir / "access-token").write_text("synthetic-copilot-token")
|
||||
with (
|
||||
owned_proxy(gateway, tmp_path, {"GITHUB_COPILOT_TOKEN_DIR": str(token_dir)}) as owned,
|
||||
owned_proxy(
|
||||
gateway, tmp_path, {"GITHUB_COPILOT_TOKEN_DIR": str(token_dir), "GITHUB_COPILOT_API_BASE": wire.url}
|
||||
) as owned,
|
||||
owned.scenario() as scenario,
|
||||
):
|
||||
model: Final = scenario.model(model="github_copilot/claude-sonnet-4-5", api_base=wire.url)
|
||||
|
|
|
|||
|
|
@ -67,9 +67,9 @@ class _TokenDirs:
|
|||
def environment(self) -> Mapping[str, str]:
|
||||
return MappingProxyType({"CHATGPT_TOKEN_DIR": str(self.chatgpt), "GITHUB_COPILOT_TOKEN_DIR": str(self.copilot)})
|
||||
|
||||
def store_valid_tokens(self, api_url: str) -> None:
|
||||
def store_valid_tokens(self) -> None:
|
||||
dl.write_chatgpt(self.chatgpt, dl.chatgpt_record(dl.CHATGPT_STORED))
|
||||
dl.write_copilot_key(self.copilot, dl.COPILOT_STORED_KEY, api_url)
|
||||
dl.write_copilot_access(self.copilot, dl.COPILOT_ACCESS)
|
||||
|
||||
|
||||
def _token_dirs(directory: Path) -> _TokenDirs:
|
||||
|
|
@ -242,7 +242,7 @@ def test_booting_without_tokens_lists_login_deployments_and_serves_only_after_to
|
|||
assert peers.auth_connections() == ()
|
||||
assert dl.bearer(peers.api.drain()[-1]) == dl.CONTROL_KEY
|
||||
|
||||
dirs.store_valid_tokens(peers.api.url)
|
||||
dirs.store_valid_tokens()
|
||||
_reload(owned)
|
||||
_every_worker_lists(owned, workers)
|
||||
for served_logins, name in enumerate(_LOGIN_NAMES, start=1):
|
||||
|
|
@ -251,7 +251,7 @@ def test_booting_without_tokens_lists_login_deployments_and_serves_only_after_to
|
|||
_successes_logged(key, at_least=_CONTROL_CALLS_BEFORE_THE_RELOAD + served_logins)
|
||||
assert peers.auth_connections() == ()
|
||||
forwarded: Final = peers.api.drain()
|
||||
assert {dl.bearer(request) for request in forwarded} == {dl.CHATGPT_STORED, dl.COPILOT_STORED_KEY}
|
||||
assert {dl.bearer(request) for request in forwarded} == {dl.CHATGPT_STORED, dl.COPILOT_ACCESS}
|
||||
|
||||
dl.write_chatgpt(dirs.chatgpt, dl.chatgpt_record(dl.CHATGPT_EXPIRED, refresh_token=dl.GOOD_REFRESH))
|
||||
peers.switches.hold_connect.set()
|
||||
|
|
@ -287,7 +287,7 @@ def test_a_token_removed_after_boot_turns_every_request_into_the_refusal(
|
|||
) -> None:
|
||||
dirs: Final = _token_dirs(tmp_path)
|
||||
with dl.device_login_peers(tmp_path) as peers:
|
||||
dirs.store_valid_tokens(peers.api.url)
|
||||
dirs.store_valid_tokens()
|
||||
with owned_proxy_process(
|
||||
gateway,
|
||||
tmp_path,
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ _REFUSALS: Final[Mapping[Provider, str]] = MappingProxyType(
|
|||
{"chatgpt": dl.CHATGPT_REFUSAL, "copilot": dl.COPILOT_REFUSAL}
|
||||
)
|
||||
_STORED_BEARERS: Final[Mapping[Provider, str]] = MappingProxyType(
|
||||
{"chatgpt": dl.CHATGPT_STORED, "copilot": dl.COPILOT_STORED_KEY}
|
||||
{"chatgpt": dl.CHATGPT_STORED, "copilot": dl.COPILOT_ACCESS}
|
||||
)
|
||||
_CONTROL_MODEL: Final = "device-login-control"
|
||||
_EXTRA: Final[Mapping[str, JsonValue]] = MappingProxyType({"num_retries": 0, "cache": {"no-cache": True}})
|
||||
|
|
@ -154,7 +154,7 @@ class _Scene:
|
|||
if provider == "chatgpt":
|
||||
dl.write_chatgpt(self.rig.chatgpt_dir, dl.chatgpt_record(dl.CHATGPT_STORED))
|
||||
return
|
||||
dl.write_copilot_key(self.rig.copilot_dir, dl.COPILOT_STORED_KEY, self.rig.peers.api.url)
|
||||
dl.write_copilot_access(self.rig.copilot_dir, dl.COPILOT_ACCESS)
|
||||
|
||||
|
||||
def _config(peers: dl.Peers, directory: Path) -> Path:
|
||||
|
|
@ -487,7 +487,7 @@ def test_missing_token_is_refused_on_the_other_model_routes(scene: _Scene, route
|
|||
assert _describe(scene.rig.peers.api.drain()) == ()
|
||||
|
||||
|
||||
def test_copilot_embeddings_are_served_with_the_stored_api_key(scene: _Scene) -> None:
|
||||
def test_copilot_embeddings_are_served_with_the_stored_access_token(scene: _Scene) -> None:
|
||||
scene.store_valid_token("copilot")
|
||||
served: Final = scene.post(
|
||||
"/v1/embeddings", {"model": "github_copilot/text-embedding-3-small", "input": "login", **_EXTRA}
|
||||
|
|
@ -496,7 +496,7 @@ def test_copilot_embeddings_are_served_with_the_stored_api_key(scene: _Scene) ->
|
|||
(item,) = rv.ITEMS.validate_python(rv.JSON_OBJECT.validate_json(served.text)["data"])
|
||||
assert item["embedding"] == [0.25, 0.5, 0.75], served.text
|
||||
(forwarded,) = scene.rig.peers.api.drain()
|
||||
assert (forwarded.method, dl.bearer(forwarded)) == ("POST", dl.COPILOT_STORED_KEY), forwarded.target
|
||||
assert (forwarded.method, dl.bearer(forwarded)) == ("POST", dl.COPILOT_ACCESS), forwarded.target
|
||||
assert forwarded.target.endswith("/embeddings"), forwarded.target
|
||||
assert scene.rig.peers.auth_connections() == ()
|
||||
|
||||
|
|
@ -562,82 +562,18 @@ def test_stored_token_the_provider_rejects_surfaces_its_401_without_a_login(
|
|||
if provider == "chatgpt":
|
||||
dl.write_chatgpt(scene.rig.chatgpt_dir, dl.chatgpt_record(dl.CHATGPT_REJECTED))
|
||||
else:
|
||||
dl.write_copilot_key(scene.rig.copilot_dir, dl.COPILOT_REJECTED_KEY, scene.rig.peers.api.url)
|
||||
dl.write_copilot_access(scene.rig.copilot_dir, dl.COPILOT_REJECTED_ACCESS)
|
||||
call: Final = scene.call(_Cell(provider, endpoint, False, "httpx"))
|
||||
served: Final = _serve(call)
|
||||
assert served.status == 401, served.text
|
||||
assert dl.REJECTED_DETAIL in served.text, served.text
|
||||
received: Final = scene.rig.peers.api.drain()
|
||||
assert {dl.bearer(request) for request in _carrying(received, call.marker)} == {
|
||||
dl.CHATGPT_REJECTED if provider == "chatgpt" else dl.COPILOT_REJECTED_KEY
|
||||
dl.CHATGPT_REJECTED if provider == "chatgpt" else dl.COPILOT_REJECTED_ACCESS
|
||||
}, _describe(received)
|
||||
assert scene.rig.peers.auth_connections() == ()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stale_key", (False, True), ids=("missing-api-key", "expired-api-key"))
|
||||
def test_copilot_api_key_is_minted_once_from_the_stored_access_token(scene: _Scene, stale_key: bool) -> None:
|
||||
dl.write_copilot_access(scene.rig.copilot_dir, dl.COPILOT_ACCESS)
|
||||
if stale_key:
|
||||
dl.write_copilot_key(scene.rig.copilot_dir, dl.COPILOT_REJECTED_KEY, scene.rig.peers.api.url, -3600)
|
||||
call: Final = scene.call(_Cell("copilot", "chat", False, "httpx"))
|
||||
served: Final = _serve(call)
|
||||
assert served.status == 200, served.text
|
||||
assert set(rv.MARKER.findall(served.text)) == {call.marker}, served.text
|
||||
(minted,) = scene.rig.peers.auth.drain()
|
||||
assert (minted.method, minted.target, minted.headers.get("host"), minted.headers.get("authorization")) == (
|
||||
"GET",
|
||||
"/copilot_internal/v2/token",
|
||||
dl.GITHUB_API_HOST,
|
||||
f"token {dl.COPILOT_ACCESS}",
|
||||
)
|
||||
(forwarded,) = _carrying(scene.rig.peers.api.drain(), call.marker)
|
||||
assert dl.bearer(forwarded) == dl.COPILOT_MINTED_KEY
|
||||
assert json.loads((scene.rig.copilot_dir / "api-key.json").read_text())["token"] == dl.COPILOT_MINTED_KEY
|
||||
|
||||
|
||||
def test_copilot_api_key_that_cannot_be_saved_is_minted_at_most_once_per_worker(scene: _Scene) -> None:
|
||||
dl.write_copilot_access(scene.rig.copilot_dir, dl.COPILOT_UNSAVED_KEY_ACCESS)
|
||||
unwritable_key_file: Final = scene.rig.copilot_dir / "api-key.json"
|
||||
unwritable_key_file.mkdir()
|
||||
first: Final = scene.call(_Cell("copilot", "chat", False, "httpx"))
|
||||
first_served: Final = _serve(first)
|
||||
assert first_served.status == 200, first_served.text
|
||||
(minted,) = scene.rig.peers.auth.drain()
|
||||
assert minted.headers.get("authorization") == f"token {dl.COPILOT_UNSAVED_KEY_ACCESS}"
|
||||
later: Final = tuple(scene.call(_Cell("copilot", "chat", False, "httpx")) for _ in range(3))
|
||||
later_served: Final = tuple(_serve(call) for call in later)
|
||||
assert [served.status for served in later_served] == [200, 200, 200], [served.text for served in later_served]
|
||||
assert len(scene.rig.peers.auth.drain()) <= 1
|
||||
received: Final = scene.rig.peers.api.drain()
|
||||
forwarded: Final = tuple(_carrying(received, call.marker) for call in (first, *later))
|
||||
assert [tuple(dl.bearer(request) for request in requests) for requests in forwarded] == [
|
||||
(dl.COPILOT_MINTED_KEY,)
|
||||
] * 4, _describe(received)
|
||||
assert unwritable_key_file.is_dir()
|
||||
|
||||
|
||||
def test_copilot_access_token_github_rejects_fails_the_request_without_a_device_login(scene: _Scene) -> None:
|
||||
dl.write_copilot_access(scene.rig.copilot_dir, dl.COPILOT_REJECTED_ACCESS)
|
||||
served: Final = _serve(scene.call(_Cell("copilot", "chat", False, "httpx")))
|
||||
assert served.status == 400, served.text
|
||||
assert "Failed to refresh API key" in _error_message(served.text), served.text
|
||||
_failure_logged(served)
|
||||
reached: Final = scene.rig.peers.auth.drain()
|
||||
assert {(request.method, request.target) for request in reached} == {("GET", "/copilot_internal/v2/token")}, (
|
||||
_describe(reached)
|
||||
)
|
||||
assert _describe(scene.rig.peers.api.drain()) == ()
|
||||
|
||||
|
||||
def test_expired_copilot_api_key_with_no_access_token_is_refused_before_any_auth_host_call(scene: _Scene) -> None:
|
||||
dl.write_copilot_key(scene.rig.copilot_dir, dl.COPILOT_STORED_KEY, scene.rig.peers.api.url, -3600)
|
||||
served: Final = _serve(scene.call(_Cell("copilot", "responses", False, "httpx")))
|
||||
assert served.status == 400, served.text
|
||||
assert dl.COPILOT_REFUSAL in _error_message(served.text), served.text
|
||||
assert scene.rig.peers.auth_connections() == ()
|
||||
assert _describe(scene.rig.peers.api.drain()) == ()
|
||||
|
||||
|
||||
_UNUSABLE_CHATGPT_FILES: Final[Mapping[str, str]] = MappingProxyType(
|
||||
{
|
||||
"invalid-json": "{not json",
|
||||
|
|
|
|||
154
tests/integration/providers/test_github_copilot_ghe_per_user.py
Normal file
154
tests/integration/providers/test_github_copilot_ghe_per_user.py
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import uuid
|
||||
from collections.abc import Iterator, Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import yaml
|
||||
from integration._support import device_login as dl
|
||||
from integration._support import responses_vendor as rv
|
||||
from integration._support.client import gateway_from_environment
|
||||
from integration._support.process import OwnedProxy, owned_proxy_process
|
||||
from integration._support.wire import Request
|
||||
from pydantic import JsonValue
|
||||
|
||||
pytestmark: Final = [pytest.mark.timeout(300), pytest.mark.shares_worker_state]
|
||||
|
||||
_CREDENTIAL: Final = "copilot-ghe"
|
||||
_MODEL: Final = "copilot-ghe-per-user"
|
||||
_CLIENT_SECONDS: Final = 60
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Rig:
|
||||
owned: OwnedProxy
|
||||
peers: dl.Peers
|
||||
|
||||
def post(self, path: str, key: str, body: Mapping[str, JsonValue]) -> httpx.Response:
|
||||
with httpx.Client(
|
||||
base_url=str(self.owned.gateway.client.base_url), timeout=_CLIENT_SECONDS, trust_env=False
|
||||
) as client:
|
||||
return client.post(path, json=dict(body), headers={"Authorization": f"Bearer {key}"})
|
||||
|
||||
def disconnect(self, key: str) -> None:
|
||||
with httpx.Client(
|
||||
base_url=str(self.owned.gateway.client.base_url), timeout=_CLIENT_SECONDS, trust_env=False
|
||||
) as client:
|
||||
response: Final = client.delete(
|
||||
f"/credentials/{_CREDENTIAL}/user_connection", headers={"Authorization": f"Bearer {key}"}
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
|
||||
|
||||
def _config(directory: Path) -> Path:
|
||||
config: Final = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())
|
||||
config["credential_list"] = [
|
||||
{
|
||||
"credential_name": _CREDENTIAL,
|
||||
"credential_values": {"github_copilot_auth_type": "per_user_oauth", "github_copilot_host": dl.GHE_HOST},
|
||||
"credential_info": {"custom_llm_provider": "github_copilot"},
|
||||
}
|
||||
]
|
||||
config["model_list"] = [
|
||||
{
|
||||
"model_name": _MODEL,
|
||||
"litellm_params": {
|
||||
"model": "github_copilot/gpt-5.4",
|
||||
"litellm_credential_name": _CREDENTIAL,
|
||||
"github_copilot_auth_type": "per_user_oauth",
|
||||
},
|
||||
}
|
||||
]
|
||||
path: Final = directory / "copilot-ghe-per-user.yaml"
|
||||
path.write_text(yaml.safe_dump(config))
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def rig(tmp_path_factory: pytest.TempPathFactory) -> Iterator[_Rig]:
|
||||
directory: Final = tmp_path_factory.mktemp("copilot-ghe-per-user")
|
||||
copilot_dir: Final = directory / "copilot"
|
||||
copilot_dir.mkdir()
|
||||
with (
|
||||
gateway_from_environment() as gateway,
|
||||
dl.device_login_peers(directory) as peers,
|
||||
owned_proxy_process(
|
||||
gateway,
|
||||
directory,
|
||||
{**peers.environment(), "GITHUB_COPILOT_TOKEN_DIR": str(copilot_dir)},
|
||||
config=_config(directory),
|
||||
workers=2,
|
||||
) as owned,
|
||||
):
|
||||
yield _Rig(owned, peers)
|
||||
|
||||
|
||||
def _outbound(request: Request) -> tuple[str, str, str, str]:
|
||||
return (
|
||||
request.headers.get("host", ""),
|
||||
request.method,
|
||||
urlsplit(request.target).path,
|
||||
request.headers.get("authorization", ""),
|
||||
)
|
||||
|
||||
|
||||
def test_a_ghe_user_connects_and_chats_with_their_own_token_on_the_tenant_hosts(rig: _Rig) -> None:
|
||||
rig.peers.reset()
|
||||
rig.peers.switches.grant.set()
|
||||
with rig.owned.gateway.scenario() as scenario:
|
||||
user_key: Final = scenario.key(user_id=scenario.user(user_role="internal_user"))
|
||||
scenario.cleanups.callback(rig.disconnect, user_key)
|
||||
|
||||
started: Final = rig.post(f"/credentials/{_CREDENTIAL}/user_connection/start", user_key, {})
|
||||
assert started.status_code == 200, started.text
|
||||
flow: Final = rv.JSON_OBJECT.validate_json(started.text)
|
||||
assert flow["verification_uri"] == f"https://{dl.GHE_HOST}/login/device", started.text
|
||||
|
||||
polled: Final = rig.post(
|
||||
f"/credentials/{_CREDENTIAL}/user_connection/poll", user_key, {"flow_handle": flow["flow_handle"]}
|
||||
)
|
||||
assert polled.status_code == 200, polled.text
|
||||
connection: Final = rv.JSON_OBJECT.validate_json(polled.text)
|
||||
assert (connection["status"], connection["github_login"]) == ("connected", dl.GHE_LOGIN), polled.text
|
||||
|
||||
marker: Final = uuid.uuid4().hex
|
||||
chat: Final = rig.post(
|
||||
"/v1/chat/completions",
|
||||
user_key,
|
||||
{
|
||||
"model": _MODEL,
|
||||
"messages": [{"role": "user", "content": f"Reply to marker-{marker}"}],
|
||||
"num_retries": 0,
|
||||
"cache": {"no-cache": True},
|
||||
},
|
||||
)
|
||||
assert chat.status_code == 200, chat.text
|
||||
assert set(rv.MARKER.findall(chat.text)) == {marker}, chat.text
|
||||
|
||||
received: Final = rig.peers.auth.drain()
|
||||
outbound: Final = tuple(_outbound(request) for request in received)
|
||||
(forwarded,) = (request for request in received if marker.encode() in request.body)
|
||||
assert _outbound(forwarded) == (
|
||||
dl.GHE_COPILOT_HOST,
|
||||
"POST",
|
||||
"/chat/completions",
|
||||
f"Bearer {dl.GHE_ACCESS}",
|
||||
), outbound
|
||||
assert {(host, method, path) for host, method, path, _ in outbound} == {
|
||||
(dl.GHE_HOST, "POST", "/login/device/code"),
|
||||
(dl.GHE_HOST, "POST", "/login/oauth/access_token"),
|
||||
(dl.GHE_API_HOST, "GET", "/copilot_internal/user"),
|
||||
(dl.GHE_COPILOT_HOST, "GET", "/models"),
|
||||
(dl.GHE_API_HOST, "GET", "/user"),
|
||||
(dl.GHE_COPILOT_HOST, "POST", "/chat/completions"),
|
||||
}, outbound
|
||||
assert {authorization for host, _, _, authorization in outbound if host == dl.GHE_API_HOST} == {
|
||||
f"token {dl.GHE_ACCESS}"
|
||||
}, outbound
|
||||
assert {authorization for host, _, _, authorization in outbound if host == dl.GHE_COPILOT_HOST} == {
|
||||
f"Bearer {dl.GHE_ACCESS}"
|
||||
}, outbound
|
||||
assert rig.peers.api.drain() == (), "the github.com GITHUB_COPILOT_API_BASE took a ghe.com call"
|
||||
|
|
@ -379,11 +379,7 @@ def rig(tmp_path_factory: pytest.TempPathFactory) -> Iterator[_Rig]:
|
|||
copilot.mkdir()
|
||||
chatgpt.mkdir()
|
||||
with gateway_from_environment() as gateway, wire_server(rv.ResponsesVendor().respond) as wire:
|
||||
(copilot / "api-key.json").write_text(
|
||||
json.dumps(
|
||||
{"token": "synthetic-copilot-token", "expires_at": time.time() + 3600, "endpoints": {"api": wire.url}}
|
||||
)
|
||||
)
|
||||
(copilot / "access-token").write_text("synthetic-copilot-token")
|
||||
(chatgpt / "auth.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
|
|
@ -395,6 +391,7 @@ def rig(tmp_path_factory: pytest.TempPathFactory) -> Iterator[_Rig]:
|
|||
)
|
||||
overrides: Final = {
|
||||
"GITHUB_COPILOT_TOKEN_DIR": str(copilot),
|
||||
"GITHUB_COPILOT_API_BASE": wire.url,
|
||||
"CHATGPT_TOKEN_DIR": str(chatgpt),
|
||||
"CHATGPT_API_BASE": wire.url,
|
||||
"SSL_CERT_FILE": str(cert),
|
||||
|
|
|
|||
|
|
@ -184,7 +184,7 @@ def clean(bench: _Bench) -> Iterator[_Bench]:
|
|||
|
||||
|
||||
_FIRST_LOGIN_BEARERS: Final[Mapping[Provider, str]] = MappingProxyType(
|
||||
{"chatgpt": dl.CHATGPT_FIRST_LOGIN, "copilot": dl.COPILOT_MINTED_KEY}
|
||||
{"chatgpt": dl.CHATGPT_FIRST_LOGIN, "copilot": dl.COPILOT_FIRST_LOGIN_ACCESS}
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -205,7 +205,7 @@ def test_sync_call_on_the_main_thread_keeps_the_interactive_first_login(
|
|||
assert {dl.bearer(request) for request in forwarded} == {_FIRST_LOGIN_BEARERS[provider]}, [
|
||||
(request.method, request.target) for request in forwarded
|
||||
]
|
||||
expected_files: Final = ("auth.json",) if provider == "chatgpt" else ("access-token", "api-key.json")
|
||||
expected_files: Final = ("auth.json",) if provider == "chatgpt" else ("access-token",)
|
||||
assert clean.token_files() == expected_files
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ from litellm.litellm_core_utils.prompt_templates import ( # noqa: E402 # same
|
|||
image_handling as image_handling_module,
|
||||
)
|
||||
from litellm.llms.gemini.chat import transformation as gemini_chat_transformation_module # noqa: E402 # same import-time dependency
|
||||
from litellm.llms.github_copilot import common_utils as github_copilot_common_utils_module # noqa: E402 # same import-time dependency
|
||||
from litellm.llms.custom_httpx.async_client_cleanup import ( # noqa: E402 # same import-time dependency
|
||||
close_litellm_async_clients,
|
||||
)
|
||||
|
|
@ -217,6 +218,8 @@ def _flush_client_caches() -> None:
|
|||
litellm.in_memory_llm_clients_cache.flush_cache()
|
||||
image_handling_module.in_memory_cache.flush_cache()
|
||||
image_handling_module.pdf_probe_verdicts.flush_cache()
|
||||
github_copilot_common_utils_module._PLAN_API_BASES.flush_cache()
|
||||
litellm_utils_module.ProviderConfigManager._get_provider_anthropic_messages_config_cached.cache_clear()
|
||||
_reset_aws_auth_caches()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ from prometheus_client.samples import Sample
|
|||
import litellm
|
||||
from litellm.constants import LITELLM_LOGGING_NO_UPSTREAM_LLM_CALL
|
||||
from litellm.integrations.prometheus import PrometheusLogger
|
||||
from litellm.llms.github_copilot.github_host import github_host_endpoints
|
||||
from litellm.llms.github_copilot.per_user_auth import GithubCopilotUserSession
|
||||
from litellm.proxy._types import ProxyErrorTypes, ProxyException, UserAPIKeyAuth
|
||||
|
||||
|
|
@ -223,7 +224,7 @@ class TestSkipMetricsValidation:
|
|||
{
|
||||
"litellm_params": {
|
||||
"github_copilot_user_session": GithubCopilotUserSession(
|
||||
token="caller-session-token", api_base="https://api.githubcopilot.com"
|
||||
token="caller-session-token", endpoints=github_host_endpoints("github.com")
|
||||
)
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
import litellm
|
||||
|
|
@ -53,17 +51,13 @@ class TestDeclaredAuthenticatingProvider:
|
|||
assert resolution_lookups == []
|
||||
assert api_base == expected
|
||||
|
||||
def test_copilot_keeps_the_enterprise_endpoint_from_disk(self, isolated_token_dirs, resolution_lookups):
|
||||
token_dir = isolated_token_dirs / "github_copilot"
|
||||
token_dir.mkdir()
|
||||
(token_dir / "api-key.json").write_text(
|
||||
json.dumps({"endpoints": {"api": "https://api.enterprise.githubcopilot.com"}})
|
||||
)
|
||||
def test_copilot_reports_configured_environment_base(self, isolated_token_dirs, resolution_lookups, monkeypatch):
|
||||
monkeypatch.setenv("GITHUB_COPILOT_API_BASE", "https://configured.example.com/v1")
|
||||
|
||||
api_base = litellm.get_api_base(model="github_copilot/gpt-4o", optional_params={})
|
||||
|
||||
assert resolution_lookups == []
|
||||
assert api_base == "https://api.enterprise.githubcopilot.com"
|
||||
assert api_base == "https://configured.example.com/v1"
|
||||
|
||||
def test_explicit_api_base_still_wins(self, isolated_token_dirs, resolution_lookups):
|
||||
api_base = litellm.get_api_base(
|
||||
|
|
@ -82,7 +76,10 @@ class TestDeclaredAuthenticatingProvider:
|
|||
@pytest.mark.parametrize(
|
||||
"model, expected",
|
||||
[
|
||||
("gemini/gemini-2.5-pro", "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent"),
|
||||
(
|
||||
"gemini/gemini-2.5-pro",
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent",
|
||||
),
|
||||
("openai/gpt-4o", "https://api.openai.com"),
|
||||
],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
from unittest.mock import MagicMock, patch
|
||||
import uuid
|
||||
from typing import Final
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
|
||||
from litellm.exceptions import AuthenticationError
|
||||
from litellm.llms.github_copilot.common_utils import DEFAULT_GITHUB_COPILOT_API_BASE, GetAPIKeyError
|
||||
from litellm.llms.github_copilot.embedding.transformation import (
|
||||
GithubCopilotEmbeddingConfig,
|
||||
)
|
||||
from litellm.llms.github_copilot.common_utils import GetAPIKeyError
|
||||
from litellm.types.utils import EmbeddingResponse
|
||||
|
||||
|
||||
|
|
@ -37,8 +38,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 str(uuid.UUID(validated_headers["x-request-id"])) == validated_headers["x-request-id"]
|
||||
|
||||
# Test with authentication failure
|
||||
config.authenticator.get_api_key.side_effect = GetAPIKeyError(
|
||||
|
|
@ -59,43 +60,57 @@ def test_github_copilot_embedding_config_validate_environment():
|
|||
assert "Failed to get API key" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_github_copilot_embedding_config_get_complete_url():
|
||||
"""Test the GitHub Copilot embedding configuration URL generation."""
|
||||
config = GithubCopilotEmbeddingConfig()
|
||||
@pytest.mark.parametrize(
|
||||
("deployment_base", "environment_base", "expected_base"),
|
||||
[
|
||||
(None, "https://copilot.example.test/", "https://copilot.example.test"),
|
||||
(None, "http://localhost:8080/", "http://localhost:8080"),
|
||||
("https://deployment.example.test/", "https://env.example.test", "https://deployment.example.test"),
|
||||
("http://localhost:9090/", "https://env.example.test", "http://localhost:9090"),
|
||||
],
|
||||
)
|
||||
def test_github_copilot_embedding_config_get_complete_url(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
deployment_base: str | None,
|
||||
environment_base: str | None,
|
||||
expected_base: str,
|
||||
) -> None:
|
||||
if environment_base is None:
|
||||
monkeypatch.delenv("GITHUB_COPILOT_API_BASE", raising=False)
|
||||
else:
|
||||
monkeypatch.setenv("GITHUB_COPILOT_API_BASE", environment_base)
|
||||
config: Final = GithubCopilotEmbeddingConfig()
|
||||
config.authenticator = MagicMock()
|
||||
|
||||
# Test with default API base
|
||||
config.authenticator.get_api_base.return_value = None
|
||||
url = config.get_complete_url(
|
||||
url: Final = config.get_complete_url(
|
||||
api_base=deployment_base,
|
||||
api_key=None,
|
||||
model="github_copilot/text-embedding-3-small",
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
)
|
||||
|
||||
assert url == f"{expected_base}/embeddings"
|
||||
assert config.authenticator.mock_calls == []
|
||||
|
||||
|
||||
def test_github_copilot_embedding_url_falls_back_to_the_default_base_without_a_known_plan_host(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("GITHUB_COPILOT_API_BASE", raising=False)
|
||||
config: Final = GithubCopilotEmbeddingConfig()
|
||||
config.authenticator = MagicMock()
|
||||
config.authenticator.get_api_key.return_value = "shared-github-token"
|
||||
|
||||
url: Final = config.get_complete_url(
|
||||
api_base=None,
|
||||
api_key=None,
|
||||
model="github_copilot/text-embedding-3-small",
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
)
|
||||
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"
|
||||
url = config.get_complete_url(
|
||||
api_base=None,
|
||||
api_key=None,
|
||||
model="github_copilot/text-embedding-3-small",
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
)
|
||||
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_key=None,
|
||||
model="github_copilot/text-embedding-3-small",
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
)
|
||||
assert url == "https://custom.api.com/embeddings"
|
||||
assert url == f"{DEFAULT_GITHUB_COPILOT_API_BASE}/embeddings"
|
||||
|
||||
|
||||
def test_github_copilot_embedding_config_transform_request():
|
||||
|
|
@ -262,12 +277,13 @@ def test_validate_environment_uses_per_user_session_and_skips_authenticator():
|
|||
"""With a per-user session the Authorization header carries the caller's Copilot token
|
||||
and the shared Authenticator is never consulted."""
|
||||
from litellm.llms.github_copilot.per_user_auth import GithubCopilotUserSession
|
||||
from litellm.llms.github_copilot.github_host import github_host_endpoints
|
||||
|
||||
config = GithubCopilotEmbeddingConfig()
|
||||
config.authenticator = MagicMock()
|
||||
config.authenticator.get_api_key.side_effect = AssertionError("shared authenticator must not run")
|
||||
|
||||
session = GithubCopilotUserSession(token="user-copilot-token", api_base="https://api.githubcopilot.com")
|
||||
session = GithubCopilotUserSession(token="user-copilot-token", endpoints=github_host_endpoints("github.com"))
|
||||
headers = config.validate_environment(
|
||||
headers={},
|
||||
model="github_copilot/text-embedding-3-small",
|
||||
|
|
@ -279,33 +295,47 @@ def test_validate_environment_uses_per_user_session_and_skips_authenticator():
|
|||
config.authenticator.get_api_key.assert_not_called()
|
||||
|
||||
|
||||
def test_per_user_session_token_wins_over_caller_authorization():
|
||||
@pytest.mark.parametrize("authorization_header", ["Authorization", "authorization", "AUTHORIZATION"])
|
||||
def test_per_user_session_token_wins_over_caller_authorization(authorization_header: str):
|
||||
from litellm.llms.github_copilot.per_user_auth import GithubCopilotUserSession
|
||||
from litellm.llms.github_copilot.github_host import github_host_endpoints
|
||||
|
||||
config = GithubCopilotEmbeddingConfig()
|
||||
config.authenticator = MagicMock()
|
||||
session = GithubCopilotUserSession(token="user-copilot-token", api_base="https://api.githubcopilot.com")
|
||||
session = GithubCopilotUserSession(token="user-copilot-token", endpoints=github_host_endpoints("github.com"))
|
||||
headers = config.validate_environment(
|
||||
headers={"Authorization": "Bearer caller-token"},
|
||||
headers={authorization_header: "Bearer caller-token", "x-custom": "preserved"},
|
||||
model="github_copilot/text-embedding-3-small",
|
||||
messages=[],
|
||||
optional_params={},
|
||||
litellm_params={"github_copilot_user_session": session},
|
||||
)
|
||||
assert headers["Authorization"] == "Bearer user-copilot-token"
|
||||
assert {key: value for key, value in headers.items() if key.lower() == "authorization"} == {
|
||||
"Authorization": "Bearer user-copilot-token"
|
||||
}
|
||||
assert headers["x-custom"] == "preserved"
|
||||
assert config.authenticator.mock_calls == []
|
||||
|
||||
|
||||
def test_get_complete_url_prefers_per_user_session_api_base():
|
||||
@pytest.mark.parametrize("deployment_base", [None, "https://deployment.example.test/"])
|
||||
def test_get_complete_url_with_per_user_session_ignores_the_deployment_base(
|
||||
monkeypatch: pytest.MonkeyPatch, deployment_base: str | None
|
||||
) -> None:
|
||||
from litellm.llms.github_copilot.per_user_auth import GithubCopilotUserSession
|
||||
from litellm.llms.github_copilot.github_host import github_host_endpoints
|
||||
|
||||
config = GithubCopilotEmbeddingConfig()
|
||||
monkeypatch.setenv("GITHUB_COPILOT_API_BASE", "http://localhost:8080/")
|
||||
config: Final = GithubCopilotEmbeddingConfig()
|
||||
config.authenticator = MagicMock()
|
||||
session = GithubCopilotUserSession(token="t", api_base="https://tenant.githubcopilot.com")
|
||||
url = config.get_complete_url(
|
||||
api_base="https://attacker.example",
|
||||
session: Final = GithubCopilotUserSession(token="user-oauth-token", endpoints=github_host_endpoints("github.com"))
|
||||
url: Final = config.get_complete_url(
|
||||
api_base=deployment_base,
|
||||
api_key=None,
|
||||
model="m",
|
||||
model="github_copilot/text-embedding-3-small",
|
||||
optional_params={},
|
||||
litellm_params={"github_copilot_user_session": session},
|
||||
)
|
||||
assert url == "https://tenant.githubcopilot.com/embeddings"
|
||||
|
||||
assert url == "http://localhost:8080/embeddings"
|
||||
assert config.authenticator.mock_calls == []
|
||||
|
|
|
|||
|
|
@ -4,102 +4,82 @@ from unittest.mock import MagicMock
|
|||
import httpx
|
||||
import pytest
|
||||
|
||||
|
||||
from litellm.exceptions import AuthenticationError
|
||||
from litellm.llms.github_copilot.common_utils import GetAPIKeyError
|
||||
from litellm.llms.github_copilot.common_utils import DEFAULT_GITHUB_COPILOT_API_BASE, GetAPIKeyError
|
||||
from litellm.llms.github_copilot.messages.transformation import (
|
||||
GithubCopilotAnthropicMessagesConfig,
|
||||
)
|
||||
from litellm.llms.github_copilot.github_host import github_host_endpoints
|
||||
from litellm.llms.github_copilot.per_user_auth import (
|
||||
GITHUB_COPILOT_USER_SESSION_KWARG_KEY,
|
||||
GithubCopilotUserSession,
|
||||
)
|
||||
|
||||
|
||||
def test_github_copilot_anthropic_messages_get_complete_url():
|
||||
"""get_complete_url builds the /v1/messages URL from the base it is handed.
|
||||
|
||||
In the request flow that ``api_base`` is the value already resolved by
|
||||
validate_anthropic_messages_environment (the authenticated Copilot host); the
|
||||
caller-supplied base is discarded there, not here (see the validate tests).
|
||||
"""
|
||||
config = GithubCopilotAnthropicMessagesConfig()
|
||||
@pytest.mark.parametrize(
|
||||
("resolved_base", "environment_base", "expected_base"),
|
||||
[
|
||||
(None, "https://copilot.example.test/", "https://copilot.example.test"),
|
||||
(None, "http://localhost:8080/", "http://localhost:8080"),
|
||||
("https://resolved.example.test", "https://env.example.test", "https://resolved.example.test"),
|
||||
("https://resolved.example.test/", "https://env.example.test", "https://resolved.example.test"),
|
||||
("https://resolved.example.test/v1/messages", None, "https://resolved.example.test"),
|
||||
],
|
||||
)
|
||||
def test_github_copilot_anthropic_messages_get_complete_url(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
resolved_base: str | None,
|
||||
environment_base: str | None,
|
||||
expected_base: str,
|
||||
) -> None:
|
||||
if environment_base is None:
|
||||
monkeypatch.delenv("GITHUB_COPILOT_API_BASE", raising=False)
|
||||
else:
|
||||
monkeypatch.setenv("GITHUB_COPILOT_API_BASE", environment_base)
|
||||
config: Final = GithubCopilotAnthropicMessagesConfig()
|
||||
config.authenticator = MagicMock()
|
||||
config.authenticator.get_api_base.return_value = None
|
||||
|
||||
# No api_base supplied and no authenticator base -> default Copilot endpoint.
|
||||
url = config.get_complete_url(
|
||||
url: Final = config.get_complete_url(
|
||||
api_base=resolved_base,
|
||||
api_key=None,
|
||||
model="github_copilot/claude-haiku-4.5",
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
)
|
||||
|
||||
assert url == f"{expected_base}/v1/messages"
|
||||
assert config.authenticator.mock_calls == []
|
||||
|
||||
|
||||
def test_github_copilot_anthropic_messages_url_falls_back_to_the_default_base_without_a_known_plan_host(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("GITHUB_COPILOT_API_BASE", raising=False)
|
||||
config: Final = GithubCopilotAnthropicMessagesConfig()
|
||||
config.authenticator = MagicMock()
|
||||
config.authenticator.get_api_key.return_value = "shared-github-token"
|
||||
|
||||
url: Final = config.get_complete_url(
|
||||
api_base=None,
|
||||
api_key=None,
|
||||
model="github_copilot/claude-haiku-4.5",
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
)
|
||||
assert url == "https://api.githubcopilot.com/v1/messages"
|
||||
# Falls back to a single authenticator read, not a hard-coded second one.
|
||||
config.authenticator.get_api_base.assert_called()
|
||||
|
||||
# The resolved (validated) base passed in is reused verbatim; no extra read.
|
||||
config.authenticator.get_api_base.reset_mock()
|
||||
url = config.get_complete_url(
|
||||
api_base="https://api.business.githubcopilot.com",
|
||||
api_key=None,
|
||||
model="github_copilot/claude-haiku-4.5",
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
)
|
||||
assert url == "https://api.business.githubcopilot.com/v1/messages"
|
||||
config.authenticator.get_api_base.assert_not_called()
|
||||
|
||||
# A trailing slash on the base must not produce a double-slash URL.
|
||||
url = config.get_complete_url(
|
||||
api_base="https://api.business.githubcopilot.com/",
|
||||
api_key=None,
|
||||
model="github_copilot/claude-haiku-4.5",
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
)
|
||||
assert url == "https://api.business.githubcopilot.com/v1/messages"
|
||||
|
||||
# An already-complete /v1/messages base is left untouched.
|
||||
url = config.get_complete_url(
|
||||
api_base="https://api.githubcopilot.com/v1/messages",
|
||||
api_key=None,
|
||||
model="github_copilot/claude-haiku-4.5",
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
)
|
||||
assert url == "https://api.githubcopilot.com/v1/messages"
|
||||
assert url == f"{DEFAULT_GITHUB_COPILOT_API_BASE}/v1/messages"
|
||||
|
||||
|
||||
def test_github_copilot_anthropic_messages_get_complete_url_normalizes_authenticator_trailing_slash():
|
||||
"""A tenant base with a trailing slash from the authenticator fallback must
|
||||
not yield a double-slash URL."""
|
||||
config = GithubCopilotAnthropicMessagesConfig()
|
||||
config.authenticator = MagicMock()
|
||||
config.authenticator.get_api_base.return_value = "https://api.business.githubcopilot.com/"
|
||||
|
||||
url = config.get_complete_url(
|
||||
api_base=None,
|
||||
api_key=None,
|
||||
model="github_copilot/claude-haiku-4.5",
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
)
|
||||
assert url == "https://api.business.githubcopilot.com/v1/messages"
|
||||
|
||||
|
||||
def test_github_copilot_anthropic_messages_validate_environment():
|
||||
def test_github_copilot_anthropic_messages_validate_environment(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Test environment validation and header injection."""
|
||||
monkeypatch.delenv("GITHUB_COPILOT_API_BASE", raising=False)
|
||||
config = GithubCopilotAnthropicMessagesConfig()
|
||||
|
||||
# Mock the authenticator
|
||||
config.authenticator = MagicMock()
|
||||
config.authenticator.get_api_key.return_value = "gh.test-key-123"
|
||||
config.authenticator.get_api_base.return_value = None
|
||||
|
||||
headers = {}
|
||||
# Pass a hostile api_base to confirm it is ignored.
|
||||
validated_headers, api_base = config.validate_anthropic_messages_environment(
|
||||
headers=headers,
|
||||
model="github_copilot/claude-haiku-4.5",
|
||||
|
|
@ -107,12 +87,12 @@ def test_github_copilot_anthropic_messages_validate_environment():
|
|||
optional_params={},
|
||||
litellm_params={},
|
||||
api_key=None,
|
||||
api_base="https://attacker.example.com",
|
||||
api_base=None,
|
||||
)
|
||||
|
||||
assert "copilot-integration-id" in validated_headers
|
||||
assert validated_headers["copilot-integration-id"] == "vscode-chat"
|
||||
assert "Authorization" in validated_headers
|
||||
assert validated_headers["Authorization"] == "Bearer gh.test-key-123"
|
||||
assert "anthropic-version" in validated_headers
|
||||
assert validated_headers["anthropic-version"] == "2023-06-01"
|
||||
# /v1/messages must use the messages-proxy intent so the Copilot backend
|
||||
|
|
@ -120,7 +100,7 @@ def test_github_copilot_anthropic_messages_validate_environment():
|
|||
assert validated_headers["openai-intent"] == "messages-proxy"
|
||||
assert validated_headers["x-interaction-type"] == "messages-proxy"
|
||||
assert validated_headers["x-github-api-version"] == "2026-06-01"
|
||||
assert api_base == "https://api.githubcopilot.com"
|
||||
assert api_base == DEFAULT_GITHUB_COPILOT_API_BASE
|
||||
|
||||
|
||||
def test_github_copilot_anthropic_messages_validate_environment_injects_beta_headers():
|
||||
|
|
@ -130,7 +110,6 @@ def test_github_copilot_anthropic_messages_validate_environment_injects_beta_hea
|
|||
config = GithubCopilotAnthropicMessagesConfig()
|
||||
config.authenticator = MagicMock()
|
||||
config.authenticator.get_api_key.return_value = "gh.test-key"
|
||||
config.authenticator.get_api_base.return_value = None
|
||||
|
||||
validated_headers, _ = config.validate_anthropic_messages_environment(
|
||||
headers={},
|
||||
|
|
@ -151,7 +130,6 @@ def test_github_copilot_anthropic_messages_validate_environment_preserves_caller
|
|||
config = GithubCopilotAnthropicMessagesConfig()
|
||||
config.authenticator = MagicMock()
|
||||
config.authenticator.get_api_key.return_value = "gh.test-key"
|
||||
config.authenticator.get_api_base.return_value = None
|
||||
|
||||
validated_headers, _ = config.validate_anthropic_messages_environment(
|
||||
headers={"anthropic-version": "2024-10-22"},
|
||||
|
|
@ -172,7 +150,6 @@ def test_github_copilot_anthropic_messages_validate_environment_injects_context_
|
|||
config = GithubCopilotAnthropicMessagesConfig()
|
||||
config.authenticator = MagicMock()
|
||||
config.authenticator.get_api_key.return_value = "gh.test-key"
|
||||
config.authenticator.get_api_base.return_value = None
|
||||
|
||||
validated_headers, _ = config.validate_anthropic_messages_environment(
|
||||
headers={},
|
||||
|
|
@ -192,7 +169,7 @@ def test_github_copilot_anthropic_messages_validate_environment_injects_context_
|
|||
|
||||
@pytest.mark.usefixtures("local_beta_headers_config")
|
||||
def test_github_copilot_safeguards_do_not_derive_the_dangerous_tool_use_beta() -> None:
|
||||
session: Final = GithubCopilotUserSession(token="gh.test-key", api_base="https://api.githubcopilot.com")
|
||||
session: Final = GithubCopilotUserSession(token="gh.test-key", endpoints=github_host_endpoints("github.com"))
|
||||
|
||||
validated_headers, _ = GithubCopilotAnthropicMessagesConfig().validate_anthropic_messages_environment(
|
||||
headers={},
|
||||
|
|
@ -270,25 +247,48 @@ def test_provider_config_manager_skips_non_claude_copilot_models():
|
|||
assert config is None
|
||||
|
||||
|
||||
def test_github_copilot_anthropic_messages_validate_environment_normalizes_trailing_slash():
|
||||
"""A tenant base with a trailing slash from the authenticator must be
|
||||
normalized so the URL built downstream has no double slash."""
|
||||
config = GithubCopilotAnthropicMessagesConfig()
|
||||
@pytest.mark.parametrize(
|
||||
("deployment_base", "environment_base", "expected_base"),
|
||||
[
|
||||
(None, None, DEFAULT_GITHUB_COPILOT_API_BASE),
|
||||
(None, "https://copilot.example.test/", "https://copilot.example.test"),
|
||||
(None, "http://localhost:8080/", "http://localhost:8080"),
|
||||
("https://deployment.example.test/", "https://copilot.example.test/", "https://deployment.example.test"),
|
||||
],
|
||||
)
|
||||
def test_validate_environment_uses_the_deployment_base_else_the_configured_endpoint(
|
||||
monkeypatch: pytest.MonkeyPatch, deployment_base: str | None, environment_base: str | None, expected_base: str
|
||||
) -> None:
|
||||
if environment_base is None:
|
||||
monkeypatch.delenv("GITHUB_COPILOT_API_BASE", raising=False)
|
||||
else:
|
||||
monkeypatch.setenv("GITHUB_COPILOT_API_BASE", environment_base)
|
||||
config: Final = GithubCopilotAnthropicMessagesConfig()
|
||||
config.authenticator = MagicMock()
|
||||
config.authenticator.get_api_key.return_value = "gh.test-key"
|
||||
config.authenticator.get_api_base.return_value = "https://api.business.githubcopilot.com/"
|
||||
|
||||
_, api_base = config.validate_anthropic_messages_environment(
|
||||
headers, api_base = config.validate_anthropic_messages_environment(
|
||||
headers={},
|
||||
model="github_copilot/claude-haiku-4.5",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
api_key=None,
|
||||
api_base="https://attacker.example.com",
|
||||
api_base=deployment_base,
|
||||
)
|
||||
|
||||
assert api_base == "https://api.business.githubcopilot.com"
|
||||
assert api_base == expected_base
|
||||
assert headers["Authorization"] == "Bearer gh.test-key"
|
||||
assert (
|
||||
config.get_complete_url(
|
||||
api_base=api_base,
|
||||
api_key=None,
|
||||
model="github_copilot/claude-haiku-4.5",
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
)
|
||||
== f"{expected_base}/v1/messages"
|
||||
)
|
||||
|
||||
|
||||
def test_github_copilot_config_disables_anthropic_beta_filtering():
|
||||
|
|
@ -306,7 +306,6 @@ def test_github_copilot_config_disables_anthropic_beta_filtering():
|
|||
|
||||
config.authenticator = MagicMock()
|
||||
config.authenticator.get_api_key.return_value = "gh.test-key"
|
||||
config.authenticator.get_api_base.return_value = None
|
||||
|
||||
headers, _ = config.validate_anthropic_messages_environment(
|
||||
headers={},
|
||||
|
|
@ -350,42 +349,73 @@ def test_github_copilot_messages_config_probes_capabilities_under_copilot_namesp
|
|||
assert GithubCopilotAnthropicMessagesConfig().custom_llm_provider == "github_copilot"
|
||||
|
||||
|
||||
def test_validate_environment_uses_per_user_session_and_skips_authenticator():
|
||||
@pytest.mark.parametrize(
|
||||
("deployment_base", "environment_base", "expected_base"),
|
||||
[
|
||||
(None, None, DEFAULT_GITHUB_COPILOT_API_BASE),
|
||||
(None, "http://localhost:8080/", "http://localhost:8080"),
|
||||
("https://deployment.example.test", "http://localhost:8080/", "http://localhost:8080"),
|
||||
("https://deployment.example.test", None, DEFAULT_GITHUB_COPILOT_API_BASE),
|
||||
],
|
||||
)
|
||||
def test_validate_environment_uses_per_user_session_and_skips_authenticator(
|
||||
monkeypatch: pytest.MonkeyPatch, deployment_base: str | None, environment_base: str | None, expected_base: str
|
||||
) -> None:
|
||||
from litellm.llms.github_copilot.per_user_auth import GithubCopilotUserSession
|
||||
|
||||
if environment_base is None:
|
||||
monkeypatch.delenv("GITHUB_COPILOT_API_BASE", raising=False)
|
||||
else:
|
||||
monkeypatch.setenv("GITHUB_COPILOT_API_BASE", environment_base)
|
||||
|
||||
config = GithubCopilotAnthropicMessagesConfig()
|
||||
config.authenticator = MagicMock()
|
||||
config.authenticator.get_api_key.side_effect = AssertionError("shared authenticator must not run")
|
||||
config.authenticator.get_api_base.side_effect = AssertionError("shared authenticator must not run")
|
||||
|
||||
session = GithubCopilotUserSession(token="user-copilot-token", api_base="https://tenant.githubcopilot.com/")
|
||||
session = GithubCopilotUserSession(token="user-copilot-token", endpoints=github_host_endpoints("github.com"))
|
||||
headers, api_base = config.validate_anthropic_messages_environment(
|
||||
headers={},
|
||||
model="github_copilot/claude-sonnet-5.5",
|
||||
messages=[],
|
||||
optional_params={},
|
||||
litellm_params={"github_copilot_user_session": session},
|
||||
api_base=deployment_base,
|
||||
)
|
||||
assert headers["Authorization"] == "Bearer user-copilot-token"
|
||||
assert api_base == "https://tenant.githubcopilot.com"
|
||||
config.authenticator.get_api_key.assert_not_called()
|
||||
assert api_base == expected_base
|
||||
assert config.authenticator.mock_calls == []
|
||||
assert (
|
||||
config.get_complete_url(
|
||||
api_base=api_base,
|
||||
api_key=None,
|
||||
model="github_copilot/claude-sonnet-5.5",
|
||||
optional_params={},
|
||||
litellm_params={"github_copilot_user_session": session},
|
||||
)
|
||||
== f"{expected_base}/v1/messages"
|
||||
)
|
||||
|
||||
|
||||
def test_per_user_session_token_wins_over_caller_authorization():
|
||||
@pytest.mark.parametrize("authorization_header", ["Authorization", "authorization", "AUTHORIZATION"])
|
||||
def test_per_user_session_token_wins_over_caller_authorization(authorization_header: str):
|
||||
from litellm.llms.github_copilot.per_user_auth import GithubCopilotUserSession
|
||||
|
||||
config = GithubCopilotAnthropicMessagesConfig()
|
||||
config.authenticator = MagicMock()
|
||||
config.authenticator.get_api_base.return_value = "https://api.githubcopilot.com"
|
||||
session = GithubCopilotUserSession(token="user-copilot-token", api_base="https://api.githubcopilot.com")
|
||||
session = GithubCopilotUserSession(token="user-copilot-token", endpoints=github_host_endpoints("github.com"))
|
||||
headers, _ = config.validate_anthropic_messages_environment(
|
||||
headers={"authorization": "Bearer caller-token"},
|
||||
headers={authorization_header: "Bearer caller-token", "x-custom": "preserved"},
|
||||
model="github_copilot/claude-sonnet-4.5",
|
||||
messages=[],
|
||||
optional_params={},
|
||||
litellm_params={"github_copilot_user_session": session},
|
||||
)
|
||||
assert headers["Authorization"] == "Bearer user-copilot-token"
|
||||
assert {key: value for key, value in headers.items() if key.lower() == "authorization"} == {
|
||||
"Authorization": "Bearer user-copilot-token"
|
||||
}
|
||||
assert headers["x-custom"] == "preserved"
|
||||
assert config.authenticator.mock_calls == []
|
||||
|
||||
|
||||
def test_transform_response_carries_upstream_usage():
|
||||
|
|
|
|||
|
|
@ -7,18 +7,35 @@ transformations for the Responses API.
|
|||
Source: litellm/llms/github_copilot/responses/transformation.py
|
||||
"""
|
||||
|
||||
from unittest.mock import patch, MagicMock
|
||||
from typing import Final
|
||||
import os
|
||||
import uuid
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
import litellm
|
||||
from litellm.litellm_core_utils.get_model_cost_map import get_model_cost_map
|
||||
from litellm.types.utils import LlmProviders
|
||||
from litellm.utils import ProviderConfigManager
|
||||
from litellm.llms.github_copilot.common_utils import DEFAULT_GITHUB_COPILOT_API_BASE
|
||||
from litellm.llms.github_copilot.github_host import github_host_endpoints
|
||||
from litellm.llms.github_copilot.per_user_auth import GithubCopilotUserSession
|
||||
from litellm.llms.github_copilot.responses.transformation import (
|
||||
GithubCopilotResponsesAPIConfig,
|
||||
)
|
||||
from litellm.types.llms.openai import ResponsesAPIOptionalRequestParams
|
||||
from litellm.types.utils import LlmProviders
|
||||
from litellm.utils import ProviderConfigManager
|
||||
|
||||
|
||||
STORED_ACCESS_TOKEN: Final = "stored-oauth-access-token"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stored_access_token(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("GITHUB_COPILOT_TOKEN_DIR", str(tmp_path))
|
||||
monkeypatch.delenv("GITHUB_COPILOT_ACCESS_TOKEN_FILE", raising=False)
|
||||
(tmp_path / "access-token").write_text(STORED_ACCESS_TOKEN)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
|
|
@ -48,52 +65,116 @@ class TestGithubCopilotResponsesAPITransformation:
|
|||
)
|
||||
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
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("api_base", "environment_base", "shared_base", "per_user_base"),
|
||||
(
|
||||
(None, "https://copilot-env.example", "https://copilot-env.example", "https://copilot-env.example"),
|
||||
("http://localhost:8080", "https://copilot-env.example", "http://localhost:8080", "https://copilot-env.example"),
|
||||
("https://copilot-deployment.example/", None, "https://copilot-deployment.example", DEFAULT_GITHUB_COPILOT_API_BASE),
|
||||
),
|
||||
)
|
||||
@pytest.mark.parametrize("per_user", (False, True))
|
||||
def test_github_copilot_responses_endpoint_url(
|
||||
self,
|
||||
api_base: str | None,
|
||||
environment_base: str | None,
|
||||
shared_base: str,
|
||||
per_user_base: str,
|
||||
per_user: bool,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
if environment_base is None:
|
||||
monkeypatch.delenv("GITHUB_COPILOT_API_BASE", raising=False)
|
||||
else:
|
||||
monkeypatch.setenv("GITHUB_COPILOT_API_BASE", environment_base)
|
||||
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}"
|
||||
config.authenticator = MagicMock()
|
||||
url = config.get_complete_url(
|
||||
api_base=api_base,
|
||||
litellm_params=(
|
||||
{"github_copilot_user_session": GithubCopilotUserSession(token="user-github-oauth-token", endpoints=github_host_endpoints("github.com"))}
|
||||
if per_user
|
||||
else {}
|
||||
),
|
||||
)
|
||||
assert url == f"{per_user_base if per_user else shared_base}/responses"
|
||||
config.authenticator.get_api_key.assert_not_called()
|
||||
|
||||
# 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}"
|
||||
def test_github_copilot_responses_url_falls_back_to_the_default_base_without_a_known_plan_host(self, monkeypatch):
|
||||
monkeypatch.delenv("GITHUB_COPILOT_API_BASE", raising=False)
|
||||
config = GithubCopilotResponsesAPIConfig()
|
||||
config.authenticator = MagicMock()
|
||||
config.authenticator.get_api_key.return_value = "shared-github-token"
|
||||
|
||||
# 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"
|
||||
url = config.get_complete_url(api_base=None, litellm_params={})
|
||||
|
||||
@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
|
||||
assert url == f"{DEFAULT_GITHUB_COPILOT_API_BASE}/responses"
|
||||
|
||||
@pytest.mark.usefixtures("stored_access_token")
|
||||
def test_validate_environment_default_headers(self, 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)
|
||||
|
||||
config = GithubCopilotResponsesAPIConfig()
|
||||
|
||||
headers = config.validate_environment(headers={}, model="gpt-5.1-codex", litellm_params={})
|
||||
next_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["Authorization"] == f"Bearer {STORED_ACCESS_TOKEN}"
|
||||
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 headers["x-vscode-user-agent-library-version"] == "electron-fetch"
|
||||
assert str(uuid.UUID(headers["x-request-id"])) == headers["x-request-id"]
|
||||
assert next_headers["x-request-id"] != headers["x-request-id"]
|
||||
|
||||
@pytest.mark.usefixtures("stored_access_token")
|
||||
def test_validate_environment_headers_from_environment(self):
|
||||
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"] == f"Bearer {STORED_ACCESS_TOKEN}"
|
||||
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"
|
||||
|
||||
@pytest.mark.usefixtures("stored_access_token")
|
||||
def test_empty_environment_header_omits_default(self):
|
||||
|
||||
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):
|
||||
|
|
@ -756,37 +837,34 @@ class TestGithubCopilotReasoningStreamItemIdNormalization:
|
|||
|
||||
|
||||
def test_validate_environment_uses_per_user_session_and_skips_authenticator():
|
||||
from litellm.llms.github_copilot.per_user_auth import GithubCopilotUserSession
|
||||
|
||||
config = GithubCopilotResponsesAPIConfig()
|
||||
config.authenticator = MagicMock()
|
||||
config.authenticator.get_api_key.side_effect = AssertionError("shared authenticator must not run")
|
||||
|
||||
session = GithubCopilotUserSession(token="user-copilot-token", api_base="https://api.githubcopilot.com")
|
||||
session = GithubCopilotUserSession(token="user-github-oauth-token", endpoints=github_host_endpoints("github.com"))
|
||||
headers = config.validate_environment(
|
||||
headers={},
|
||||
model="github_copilot/gpt-5.4",
|
||||
litellm_params={"github_copilot_user_session": session},
|
||||
)
|
||||
assert headers["Authorization"] == "Bearer user-copilot-token"
|
||||
assert headers["Authorization"] == f"Bearer {session.token}"
|
||||
config.authenticator.get_api_key.assert_not_called()
|
||||
|
||||
|
||||
def test_per_user_session_token_wins_over_caller_authorization():
|
||||
"""extra_headers["Authorization"] (any casing) must never displace the per-user
|
||||
session token on the outgoing request."""
|
||||
from litellm.llms.github_copilot.per_user_auth import GithubCopilotUserSession
|
||||
|
||||
config = GithubCopilotResponsesAPIConfig()
|
||||
config.authenticator = MagicMock()
|
||||
session = GithubCopilotUserSession(token="user-copilot-token", api_base="https://api.githubcopilot.com")
|
||||
session = GithubCopilotUserSession(token="user-github-oauth-token", endpoints=github_host_endpoints("github.com"))
|
||||
headers = config.validate_environment(
|
||||
headers={"Authorization": "Bearer caller-token", "authorization": "Bearer caller-token-lower"},
|
||||
model="github_copilot/gpt-5.1",
|
||||
litellm_params={"github_copilot_user_session": session},
|
||||
)
|
||||
assert headers["Authorization"] == "Bearer user-copilot-token"
|
||||
assert headers["Authorization"] == f"Bearer {session.token}"
|
||||
assert "authorization" not in headers
|
||||
config.authenticator.get_api_key.assert_not_called()
|
||||
|
||||
|
||||
def test_shared_mode_keeps_caller_authorization():
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
from unittest.mock import MagicMock, mock_open, patch
|
||||
|
||||
|
|
@ -14,24 +12,25 @@ import respx
|
|||
|
||||
from litellm.llms.github_copilot.authenticator import Authenticator
|
||||
from litellm.llms.github_copilot.common_utils import (
|
||||
APIKeyExpiredError,
|
||||
DEFAULT_COPILOT_EDITOR_PLUGIN_VERSION,
|
||||
DEFAULT_COPILOT_EDITOR_VERSION,
|
||||
DEFAULT_COPILOT_INTEGRATION_ID,
|
||||
DEFAULT_COPILOT_USER_AGENT,
|
||||
DEFAULT_GITHUB_COPILOT_API_BASE,
|
||||
GetAccessTokenError,
|
||||
GetAPIKeyError,
|
||||
GetDeviceCodeError,
|
||||
RefreshAPIKeyError,
|
||||
InvalidAPIBaseError,
|
||||
get_copilot_api_base,
|
||||
get_copilot_default_headers,
|
||||
)
|
||||
|
||||
API_KEY_URL: Final = "https://copilot-key.test/copilot_internal/v2/token"
|
||||
REFRESHED_KEY: Final = MappingProxyType({"token": "new-api-key", "expires_at": 4102444800})
|
||||
TENANT_API: Final = "https://api.tenant.githubcopilot.com"
|
||||
REPAIRED_API: Final = "https://api.repaired.githubcopilot.com"
|
||||
TRUNCATED_UTF_16: Final = b"\xff\xfe\x00\xd8"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def logged_in_token_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
monkeypatch.setenv("GITHUB_COPILOT_TOKEN_DIR", str(tmp_path))
|
||||
monkeypatch.setenv("GITHUB_COPILOT_API_KEY_URL", API_KEY_URL)
|
||||
(tmp_path / "access-token").write_text("gho_logged_in")
|
||||
return tmp_path
|
||||
|
||||
|
|
@ -65,7 +64,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):
|
||||
|
|
@ -77,33 +75,225 @@ 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
|
||||
@pytest.mark.parametrize(
|
||||
"api_base",
|
||||
(
|
||||
"https://configured.example.com",
|
||||
"http://localhost:8080",
|
||||
"http://127.0.0.1:8080",
|
||||
"http://[::1]:8080",
|
||||
"http://copilot-relay:8080",
|
||||
"http://10.0.0.1:8080",
|
||||
),
|
||||
)
|
||||
def test_get_api_base_prefers_environment(self, api_base):
|
||||
with patch.dict(os.environ, {"GITHUB_COPILOT_API_BASE": api_base}, clear=True):
|
||||
assert get_copilot_api_base() == api_base
|
||||
|
||||
headers_with_token = authenticator._get_github_headers("test-token")
|
||||
assert headers_with_token["authorization"] == "token test-token"
|
||||
@pytest.mark.parametrize(
|
||||
"api_base",
|
||||
(
|
||||
"ftp://api.githubcopilot.com",
|
||||
"https://user:password@api.githubcopilot.com",
|
||||
"https://api.githubcopilot.com?tenant=example",
|
||||
"https://api.githubcopilot.com#fragment",
|
||||
"api.githubcopilot.com",
|
||||
"https:///missing-host",
|
||||
"https://[invalid",
|
||||
),
|
||||
)
|
||||
def test_get_api_base_rejects_invalid_configuration(self, api_base):
|
||||
with (
|
||||
patch.dict(os.environ, {"GITHUB_COPILOT_API_BASE": api_base}, clear=True),
|
||||
pytest.raises(
|
||||
InvalidAPIBaseError, match="GitHub Copilot GITHUB_COPILOT_API_BASE must be an HTTP or HTTPS URL"
|
||||
),
|
||||
):
|
||||
get_copilot_api_base()
|
||||
|
||||
def test_get_api_base_uses_default_when_unconfigured(self):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
assert get_copilot_api_base() == DEFAULT_GITHUB_COPILOT_API_BASE
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"api_base", ("https://deployment.example.com", "http://localhost:9000", "http://deployment.example.com")
|
||||
)
|
||||
def test_get_api_base_prefers_deployment_endpoint(self, api_base):
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"GITHUB_COPILOT_API_BASE": "https://configured.example.com"},
|
||||
clear=True,
|
||||
):
|
||||
assert get_copilot_api_base(api_base) == api_base
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"api_base",
|
||||
[
|
||||
"ftp://deployment.example.com",
|
||||
"https://user:pw@deployment.example.com",
|
||||
"https://deployment.example.com?q=1",
|
||||
"https://deployment.example.com#fragment",
|
||||
"https:///missing-host",
|
||||
"deployment.example.com",
|
||||
],
|
||||
)
|
||||
def test_get_api_base_rejects_invalid_deployment_endpoint_instead_of_falling_back(self, api_base):
|
||||
with (
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{"GITHUB_COPILOT_API_BASE": "https://configured.example.com"},
|
||||
clear=True,
|
||||
),
|
||||
pytest.raises(InvalidAPIBaseError, match="GitHub Copilot api_base must be an HTTP or HTTPS URL"),
|
||||
):
|
||||
get_copilot_api_base(api_base)
|
||||
|
||||
@pytest.mark.parametrize("deployment_base", [None, ""])
|
||||
@pytest.mark.parametrize("environment_base", [None, ""])
|
||||
def test_get_api_base_skips_empty_candidates_without_warning(self, deployment_base, environment_base, caplog):
|
||||
environment = {} if environment_base is None else {"GITHUB_COPILOT_API_BASE": environment_base}
|
||||
with patch.dict(os.environ, environment, clear=True), caplog.at_level(logging.WARNING, logger="LiteLLM"):
|
||||
assert get_copilot_api_base(deployment_base) == DEFAULT_GITHUB_COPILOT_API_BASE
|
||||
assert caplog.text == ""
|
||||
|
||||
def test_get_api_base_refuses_an_enterprise_token_exchange_url_without_a_copilot_base(self):
|
||||
with (
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{"GITHUB_COPILOT_API_KEY_URL": "https://api.octo.ghe.com/copilot_internal/v2/token"},
|
||||
clear=True,
|
||||
),
|
||||
pytest.raises(InvalidAPIBaseError, match="Set GITHUB_COPILOT_API_BASE"),
|
||||
):
|
||||
get_copilot_api_base()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("environment", "expected"),
|
||||
(
|
||||
(
|
||||
{"GITHUB_COPILOT_API_KEY_URL": "https://api.github.com/copilot_internal/v2/token"},
|
||||
DEFAULT_GITHUB_COPILOT_API_BASE,
|
||||
),
|
||||
(
|
||||
{
|
||||
"GITHUB_COPILOT_API_KEY_URL": "https://api.octo.ghe.com/copilot_internal/v2/token",
|
||||
"GITHUB_COPILOT_API_BASE": "https://copilot-api.octo.ghe.com",
|
||||
},
|
||||
"https://copilot-api.octo.ghe.com",
|
||||
),
|
||||
),
|
||||
)
|
||||
def test_get_api_base_accepts_a_token_exchange_url_that_needs_no_action(self, environment, expected):
|
||||
with patch.dict(os.environ, environment, clear=True):
|
||||
assert get_copilot_api_base() == expected
|
||||
|
||||
def test_get_github_headers(self, authenticator):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
headers = authenticator._get_github_headers()
|
||||
assert headers == {
|
||||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
"copilot-integration-id": DEFAULT_COPILOT_INTEGRATION_ID,
|
||||
"editor-version": DEFAULT_COPILOT_EDITOR_VERSION,
|
||||
"editor-plugin-version": DEFAULT_COPILOT_EDITOR_PLUGIN_VERSION,
|
||||
"user-agent": DEFAULT_COPILOT_USER_AGENT,
|
||||
}
|
||||
|
||||
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_USER_AGENT_LIBRARY_VERSION": "",
|
||||
"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"
|
||||
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 {header: value for header, value in request_headers.items() if header != "x-request-id"} == {
|
||||
**expected_auth_headers,
|
||||
"openai-intent": "conversation-edits",
|
||||
"x-github-api-version": "2026-06-01",
|
||||
"Authorization": "Bearer " + "opencode-oauth-token",
|
||||
}
|
||||
assert str(uuid.UUID(request_headers["x-request-id"])) == request_headers["x-request-id"]
|
||||
mock_client.get.assert_not_called()
|
||||
|
||||
def test_get_access_token_from_file(self, logged_in_token_dir: Path) -> None:
|
||||
"""Test retrieving an access token from a file."""
|
||||
assert Authenticator().get_access_token() == "gho_logged_in"
|
||||
|
||||
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, caplog):
|
||||
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),
|
||||
caplog.at_level(logging.ERROR, logger="LiteLLM"),
|
||||
):
|
||||
token = authenticator.get_access_token()
|
||||
assert token == mock_token
|
||||
authenticator._login.assert_called_once()
|
||||
|
||||
assert token == mock_token
|
||||
mock_login.assert_called_once()
|
||||
assert "Error saving access token to file" in caplog.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_access_token_refuses_device_code_login_in_event_loop(self, authenticator):
|
||||
|
|
@ -134,17 +324,14 @@ class TestGitHubCopilotAuthenticator:
|
|||
mock_login.assert_not_called()
|
||||
|
||||
@pytest.mark.parametrize("encoding", ["utf-16", "utf-16-le", "utf-8-sig"])
|
||||
def test_saved_access_token_in_another_encoding_is_sent_on_refresh(
|
||||
def test_saved_access_token_in_another_encoding_is_sent_to_copilot(
|
||||
self, logged_in_token_dir: Path, encoding: str
|
||||
) -> None:
|
||||
"""An access-token file a Windows editor saved as UTF-16 or with a BOM still refreshes the key."""
|
||||
"""An access-token file a Windows editor saved as UTF-16 or with a BOM is still the token Copilot gets."""
|
||||
(logged_in_token_dir / "access-token").write_bytes("gho_logged_in\n".encode(encoding))
|
||||
|
||||
with respx.mock(assert_all_called=True) as router:
|
||||
route: Final = router.get(API_KEY_URL).respond(json=dict(REFRESHED_KEY))
|
||||
assert Authenticator().get_api_key() == "new-api-key"
|
||||
|
||||
assert route.calls.last.request.headers["authorization"] == "token gho_logged_in"
|
||||
with respx.mock:
|
||||
assert Authenticator().get_api_key() == "gho_logged_in"
|
||||
|
||||
def test_undecodable_access_token_is_refused_like_a_missing_one(self, logged_in_token_dir: Path) -> None:
|
||||
"""A proxy worker gets the login hint for an access-token file it cannot decode, and the file is kept."""
|
||||
|
|
@ -171,36 +358,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}
|
||||
).encode()
|
||||
|
||||
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}
|
||||
).encode()
|
||||
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()
|
||||
with pytest.raises(GetAPIKeyError, match="Failed to get OAuth access token"):
|
||||
authenticator.get_api_key()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"malformed",
|
||||
|
|
@ -212,204 +377,17 @@ class TestGitHubCopilotAuthenticator:
|
|||
],
|
||||
ids=["json-list", "text-expires-at", "truncated-utf-16", "list-endpoints-beside-live-key"],
|
||||
)
|
||||
def test_get_api_key_malformed_file_refreshes_like_missing(
|
||||
def test_get_api_key_malformed_api_key_file_is_ignored_like_a_missing_one(
|
||||
self, logged_in_token_dir: Path, malformed: bytes
|
||||
) -> None:
|
||||
"""A file that is not a Copilot key object is ignored, refreshed over, never answered with a parse error."""
|
||||
"""A leftover api-key.json in any shape never replaces the access token and is left for inspection."""
|
||||
api_key_file: Final = logged_in_token_dir / "api-key.json"
|
||||
api_key_file.write_bytes(malformed)
|
||||
|
||||
with respx.mock(assert_all_called=True) as router:
|
||||
route: Final = router.get(API_KEY_URL).respond(json=dict(REFRESHED_KEY))
|
||||
assert Authenticator().get_api_key() == "new-api-key"
|
||||
|
||||
assert route.call_count == 1
|
||||
assert route.calls.last.request.headers["authorization"] == "token gho_logged_in"
|
||||
assert json.loads(api_key_file.read_text()) == REFRESHED_KEY
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"malformed",
|
||||
[
|
||||
b"[]",
|
||||
json.dumps({"token": "tid=abc", "expires_at": "soon"}).encode(),
|
||||
TRUNCATED_UTF_16,
|
||||
json.dumps({"token": "tid=abc", "expires_at": 4102444800, "endpoints": []}).encode(),
|
||||
],
|
||||
ids=["json-list", "text-expires-at", "truncated-utf-16", "list-endpoints-beside-live-key"],
|
||||
)
|
||||
def test_get_api_key_malformed_file_is_kept_when_refresh_fails(
|
||||
self, logged_in_token_dir: Path, malformed: bytes
|
||||
) -> None:
|
||||
"""Ignoring the file never deletes it, so a failed refresh leaves the operator's file for inspection."""
|
||||
api_key_file: Final = logged_in_token_dir / "api-key.json"
|
||||
api_key_file.write_bytes(malformed)
|
||||
|
||||
with respx.mock(assert_all_called=True) as router:
|
||||
router.get(API_KEY_URL).respond(401)
|
||||
with pytest.raises(GetAPIKeyError):
|
||||
Authenticator().get_api_key()
|
||||
|
||||
assert api_key_file.read_bytes() == malformed
|
||||
|
||||
def test_refreshed_key_that_cannot_be_saved_is_reused_with_its_endpoint(self, logged_in_token_dir: Path) -> None:
|
||||
unwritable_key_file: Final = logged_in_token_dir / "api-key.json"
|
||||
unwritable_key_file.mkdir()
|
||||
first: Final = Authenticator()
|
||||
second: Final = Authenticator()
|
||||
|
||||
with respx.mock(assert_all_called=True) as router:
|
||||
route: Final = router.get(API_KEY_URL).respond(json={**REFRESHED_KEY, "endpoints": {"api": TENANT_API}})
|
||||
served: Final = (first.get_api_key(), second.get_api_key(), first.get_api_key())
|
||||
|
||||
assert served == ("new-api-key",) * 3
|
||||
assert (first.get_api_base(), second.get_api_base()) == (TENANT_API, TENANT_API)
|
||||
assert route.call_count == 1
|
||||
assert unwritable_key_file.is_dir()
|
||||
|
||||
def test_refreshed_key_that_cannot_be_saved_and_has_expired_is_refreshed_again(
|
||||
self, logged_in_token_dir: Path
|
||||
) -> None:
|
||||
(logged_in_token_dir / "api-key.json").mkdir()
|
||||
auth: Final = Authenticator()
|
||||
|
||||
with respx.mock(assert_all_called=True) as router:
|
||||
route: Final = router.get(API_KEY_URL).respond(json={"token": "expired-api-key", "expires_at": 1})
|
||||
served: Final = (auth.get_api_key(), auth.get_api_key())
|
||||
|
||||
assert served == ("expired-api-key",) * 2
|
||||
assert route.call_count == 2
|
||||
|
||||
def test_kept_key_and_its_endpoint_stay_paired_after_the_key_file_is_repaired(
|
||||
self, logged_in_token_dir: Path
|
||||
) -> None:
|
||||
key_file: Final = logged_in_token_dir / "api-key.json"
|
||||
key_file.mkdir()
|
||||
auth: Final = Authenticator()
|
||||
|
||||
with respx.mock(assert_all_called=True) as router:
|
||||
router.get(API_KEY_URL).respond(json={**REFRESHED_KEY, "endpoints": {"api": TENANT_API}})
|
||||
auth.get_api_key()
|
||||
key_file.rmdir()
|
||||
key_file.write_text(
|
||||
json.dumps({"token": "repaired-api-key", "expires_at": 9999999999, "endpoints": {"api": REPAIRED_API}})
|
||||
)
|
||||
|
||||
assert (auth.get_api_key(), auth.get_api_base()) in (
|
||||
("new-api-key", TENANT_API),
|
||||
("repaired-api-key", REPAIRED_API),
|
||||
)
|
||||
|
||||
def test_unsaved_key_is_not_reused_after_the_access_token_changes(self, logged_in_token_dir: Path) -> None:
|
||||
(logged_in_token_dir / "api-key.json").mkdir()
|
||||
|
||||
with respx.mock(assert_all_called=True) as router:
|
||||
route: Final = router.get(API_KEY_URL).respond(json=dict(REFRESHED_KEY))
|
||||
Authenticator().get_api_key()
|
||||
(logged_in_token_dir / "access-token").write_text("gho_logged_in_again")
|
||||
Authenticator().get_api_key()
|
||||
|
||||
assert [call.request.headers["authorization"] for call in route.calls] == [
|
||||
"token gho_logged_in",
|
||||
"token gho_logged_in_again",
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"malformed",
|
||||
[
|
||||
b"[]",
|
||||
json.dumps({"endpoints": {"api": 5}}).encode(),
|
||||
json.dumps({"token": "t", "expires_at": 1, "endpoints": []}).encode(),
|
||||
TRUNCATED_UTF_16,
|
||||
],
|
||||
ids=["json-list", "number-api", "list-endpoints", "truncated-utf-16"],
|
||||
)
|
||||
def test_get_api_base_malformed_file_returns_none(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, malformed: bytes
|
||||
) -> None:
|
||||
"""get_api_base runs before get_api_key on every request, so it must survive malformed shapes too."""
|
||||
monkeypatch.setenv("GITHUB_COPILOT_TOKEN_DIR", str(tmp_path))
|
||||
(tmp_path / "api-key.json").write_bytes(malformed)
|
||||
|
||||
assert Authenticator().get_api_base() is None
|
||||
|
||||
def test_get_api_base_keeps_endpoints_when_the_key_is_malformed(self, logged_in_token_dir: Path) -> None:
|
||||
"""The endpoint is read on its own, so a bad key never sends the refreshed key to the default host."""
|
||||
(logged_in_token_dir / "api-key.json").write_text(
|
||||
json.dumps({"token": "tid=abc", "expires_at": "soon", "endpoints": {"api": TENANT_API}})
|
||||
)
|
||||
auth: Final = Authenticator()
|
||||
|
||||
assert auth.get_api_base() == TENANT_API
|
||||
with respx.mock(assert_all_called=True) as router:
|
||||
router.get(API_KEY_URL).respond(json=dict(REFRESHED_KEY))
|
||||
assert auth.get_api_key() == "new-api-key"
|
||||
|
||||
@pytest.mark.parametrize("encoding", ["utf-16", "utf-16-le", "utf-8-sig"])
|
||||
def test_live_key_file_in_another_json_encoding_is_used_without_a_refresh(
|
||||
self, logged_in_token_dir: Path, encoding: str
|
||||
) -> None:
|
||||
"""A key file a Windows editor saved as UTF-16 or with a BOM still holds a live key and tenant."""
|
||||
live_key: Final = json.dumps({"token": "tid=abc", "expires_at": 4102444800, "endpoints": {"api": TENANT_API}})
|
||||
api_key_file: Final = logged_in_token_dir / "api-key.json"
|
||||
api_key_file.write_bytes(live_key.encode(encoding))
|
||||
auth: Final = Authenticator()
|
||||
|
||||
with respx.mock:
|
||||
assert auth.get_api_base() == TENANT_API
|
||||
assert auth.get_api_key() == "tid=abc"
|
||||
assert Authenticator().get_api_key() == "gho_logged_in"
|
||||
|
||||
assert api_key_file.read_bytes() == live_key.encode(encoding)
|
||||
|
||||
def test_ignored_key_file_is_logged_without_its_contents(
|
||||
self, logged_in_token_dir: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""The warning names the file but never echoes a token saved in the wrong shape."""
|
||||
(logged_in_token_dir / "api-key.json").write_text(
|
||||
json.dumps({"token": ["tid=secret-value"], "expires_at": 4102444800})
|
||||
)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="LiteLLM"), respx.mock(assert_all_called=True) as router:
|
||||
router.get(API_KEY_URL).respond(json=dict(REFRESHED_KEY))
|
||||
assert Authenticator().get_api_key() == "new-api-key"
|
||||
|
||||
assert "not a Copilot API key file" in caplog.text
|
||||
assert "tid=secret-value" not in caplog.text
|
||||
|
||||
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
|
||||
assert api_key_file.read_bytes() == malformed
|
||||
|
||||
def test_get_device_code(self, authenticator, mock_http_client):
|
||||
"""Test getting a device code."""
|
||||
|
|
@ -458,35 +436,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"},
|
||||
}
|
||||
).encode()
|
||||
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
|
||||
|
|
@ -544,16 +503,3 @@ class TestGitHubCopilotAuthenticator:
|
|||
):
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,33 +1,22 @@
|
|||
import asyncio
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from typing import AsyncGenerator
|
||||
from unittest.mock import AsyncMock, MagicMock, mock_open, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import httpx
|
||||
from respx import MockRouter
|
||||
import pytest
|
||||
|
||||
import litellm
|
||||
|
||||
# Import at the top to make the patch work correctly
|
||||
import litellm.llms.github_copilot.chat.transformation
|
||||
from litellm import Choices, Message, ModelResponse, Usage, acompletion, completion
|
||||
from litellm import ModelResponse, completion
|
||||
from litellm.exceptions import AuthenticationError
|
||||
from litellm.llms.github_copilot.authenticator import Authenticator
|
||||
from litellm.llms.github_copilot.chat.transformation import GithubCopilotConfig
|
||||
from litellm.llms.github_copilot.common_utils import (
|
||||
APIKeyExpiredError,
|
||||
GetAccessTokenError,
|
||||
DEFAULT_GITHUB_COPILOT_API_BASE,
|
||||
GetAPIKeyError,
|
||||
GetDeviceCodeError,
|
||||
RefreshAPIKeyError,
|
||||
)
|
||||
|
||||
|
||||
def test_github_copilot_config_get_openai_compatible_provider_info():
|
||||
def test_github_copilot_config_get_openai_compatible_provider_info(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Test the GitHub Copilot configuration provider info retrieval."""
|
||||
|
||||
config = GithubCopilotConfig()
|
||||
|
|
@ -36,10 +25,7 @@ def test_github_copilot_config_get_openai_compatible_provider_info():
|
|||
mock_api_key = "gh.test-key-123456789"
|
||||
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"
|
||||
)
|
||||
monkeypatch.setenv("GITHUB_COPILOT_API_BASE", "https://api.enterprise.githubcopilot.com")
|
||||
|
||||
# Test with default values
|
||||
model = "github_copilot/gpt-4"
|
||||
|
|
@ -57,9 +43,15 @@ 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
|
||||
monkeypatch.delenv("GITHUB_COPILOT_API_BASE")
|
||||
(
|
||||
api_base,
|
||||
dynamic_api_key,
|
||||
|
|
@ -70,7 +62,7 @@ def test_github_copilot_config_get_openai_compatible_provider_info():
|
|||
api_key=None,
|
||||
custom_llm_provider="github_copilot",
|
||||
)
|
||||
assert api_base == "https://api.githubcopilot.com"
|
||||
assert api_base == DEFAULT_GITHUB_COPILOT_API_BASE
|
||||
|
||||
# Test with authentication failure
|
||||
config.authenticator.get_api_key.side_effect = GetAPIKeyError(
|
||||
|
|
@ -93,10 +85,12 @@ def test_github_copilot_config_get_openai_compatible_provider_info():
|
|||
@patch("litellm.main.openai_chat_completions.completion")
|
||||
@patch("litellm.llms.openai.openai.OpenAIChatCompletion.completion")
|
||||
def test_completion_github_copilot_mock_response(
|
||||
mock_class_completion, mock_instance_completion, mock_get_api_key, monkeypatch
|
||||
mock_class_completion, mock_instance_completion, mock_get_api_key, monkeypatch, tmp_path
|
||||
):
|
||||
"""Test the completion function with GitHub Copilot provider."""
|
||||
|
||||
monkeypatch.setenv("GITHUB_COPILOT_TOKEN_DIR", str(tmp_path))
|
||||
|
||||
# Force chat path through the patched openai_chat_completions instance even if
|
||||
# a previous test left EXPERIMENTAL_OPENAI_BASE_LLM_HTTP_HANDLER set in the env.
|
||||
monkeypatch.delenv("EXPERIMENTAL_OPENAI_BASE_LLM_HTTP_HANDLER", raising=False)
|
||||
|
|
@ -145,7 +139,6 @@ def test_completion_github_copilot_mock_response(
|
|||
|
||||
def test_transform_messages_disable_copilot_system_to_assistant(monkeypatch):
|
||||
"""Test that system messages are converted to assistant unless disable_copilot_system_to_assistant is True."""
|
||||
import litellm
|
||||
from litellm.llms.github_copilot.chat.transformation import GithubCopilotConfig
|
||||
|
||||
# Save original value
|
||||
|
|
@ -185,7 +178,6 @@ def test_x_initiator_header_user_request():
|
|||
# Mock the authenticator
|
||||
config.authenticator = MagicMock()
|
||||
config.authenticator.get_api_key.return_value = "gh.test-key-123"
|
||||
config.authenticator.get_api_base.return_value = None
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": "You are an assistant."},
|
||||
|
|
@ -212,7 +204,6 @@ def test_x_initiator_header_agent_request_with_assistant():
|
|||
# Mock the authenticator
|
||||
config.authenticator = MagicMock()
|
||||
config.authenticator.get_api_key.return_value = "gh.test-key-123"
|
||||
config.authenticator.get_api_base.return_value = None
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": "You are an assistant."},
|
||||
|
|
@ -239,7 +230,6 @@ def test_x_initiator_header_agent_request_with_tool():
|
|||
# Mock the authenticator
|
||||
config.authenticator = MagicMock()
|
||||
config.authenticator.get_api_key.return_value = "gh.test-key-123"
|
||||
config.authenticator.get_api_base.return_value = None
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": "You are an assistant."},
|
||||
|
|
@ -266,7 +256,6 @@ def test_x_initiator_header_mixed_messages_with_agent_roles():
|
|||
# Mock the authenticator
|
||||
config.authenticator = MagicMock()
|
||||
config.authenticator.get_api_key.return_value = "gh.test-key-123"
|
||||
config.authenticator.get_api_base.return_value = None
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
|
|
@ -294,7 +283,6 @@ def test_x_initiator_header_user_only_messages():
|
|||
# Mock the authenticator
|
||||
config.authenticator = MagicMock()
|
||||
config.authenticator.get_api_key.return_value = "gh.test-key-123"
|
||||
config.authenticator.get_api_base.return_value = None
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": "You are an assistant."},
|
||||
|
|
@ -322,7 +310,6 @@ def test_x_initiator_header_empty_messages():
|
|||
# Mock the authenticator
|
||||
config.authenticator = MagicMock()
|
||||
config.authenticator.get_api_key.return_value = "gh.test-key-123"
|
||||
config.authenticator.get_api_base.return_value = None
|
||||
|
||||
messages = []
|
||||
|
||||
|
|
@ -346,7 +333,6 @@ def test_x_initiator_header_system_only_messages():
|
|||
# Mock the authenticator
|
||||
config.authenticator = MagicMock()
|
||||
config.authenticator.get_api_key.return_value = "gh.test-key-123"
|
||||
config.authenticator.get_api_base.return_value = None
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": "You are an assistant."},
|
||||
|
|
@ -365,10 +351,6 @@ def test_x_initiator_header_system_only_messages():
|
|||
assert headers["X-Initiator"] == "user"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_copilot_vision_request_header_with_image():
|
||||
"""Test that Copilot-Vision-Request header is added when messages contain images"""
|
||||
config = GithubCopilotConfig()
|
||||
|
|
@ -376,7 +358,6 @@ def test_copilot_vision_request_header_with_image():
|
|||
# Mock the authenticator
|
||||
config.authenticator = MagicMock()
|
||||
config.authenticator.get_api_key.return_value = "gh.test-key-123"
|
||||
config.authenticator.get_api_base.return_value = None
|
||||
|
||||
messages = [
|
||||
{
|
||||
|
|
@ -412,7 +393,6 @@ def test_copilot_vision_request_header_text_only():
|
|||
# Mock the authenticator
|
||||
config.authenticator = MagicMock()
|
||||
config.authenticator.get_api_key.return_value = "gh.test-key-123"
|
||||
config.authenticator.get_api_base.return_value = None
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": "Just a text message"},
|
||||
|
|
@ -439,7 +419,6 @@ def test_copilot_vision_request_header_with_type_image_url():
|
|||
# Mock the authenticator
|
||||
config.authenticator = MagicMock()
|
||||
config.authenticator.get_api_key.return_value = "gh.test-key-123"
|
||||
config.authenticator.get_api_base.return_value = None
|
||||
|
||||
messages = [
|
||||
{
|
||||
|
|
@ -693,13 +672,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."""
|
||||
|
|
@ -867,12 +841,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
|
||||
|
|
@ -922,17 +892,22 @@ def test_openai_handler_repairs_github_copilot_empty_choices(
|
|||
|
||||
def test_validate_environment_uses_per_user_session_and_skips_authenticator():
|
||||
from litellm.llms.github_copilot.per_user_auth import GithubCopilotUserSession
|
||||
from litellm.llms.github_copilot.github_host import github_host_endpoints
|
||||
|
||||
config = GithubCopilotConfig()
|
||||
config.authenticator = MagicMock()
|
||||
config.authenticator.get_api_key.side_effect = AssertionError("shared authenticator must not run")
|
||||
|
||||
session = GithubCopilotUserSession(token="user-copilot-token", api_base="https://api.githubcopilot.com")
|
||||
session = GithubCopilotUserSession(token="user-github-oauth-token", endpoints=github_host_endpoints("github.com"))
|
||||
headers = config.validate_environment(
|
||||
headers={}, model="github_copilot/gpt-4o", messages=[], optional_params={},
|
||||
headers={"Authorization": "Bearer caller-token"},
|
||||
model="github_copilot/gpt-4o",
|
||||
messages=[],
|
||||
optional_params={},
|
||||
litellm_params={"github_copilot_user_session": session},
|
||||
api_key="caller-api-key",
|
||||
)
|
||||
assert headers["Authorization"] == "Bearer user-copilot-token"
|
||||
assert headers["Authorization"] == f"Bearer {session.token}"
|
||||
config.authenticator.get_api_key.assert_not_called()
|
||||
|
||||
|
||||
|
|
@ -946,9 +921,7 @@ def test_transform_response_carries_upstream_usage():
|
|||
"object": "chat.completion",
|
||||
"created": 1,
|
||||
"model": "github_copilot/gpt-4o",
|
||||
"choices": [
|
||||
{"index": 0, "message": {"role": "assistant", "content": "hi"}, "finish_reason": "stop"}
|
||||
],
|
||||
"choices": [{"index": 0, "message": {"role": "assistant", "content": "hi"}, "finish_reason": "stop"}],
|
||||
"usage": {"prompt_tokens": 11, "completion_tokens": 7, "total_tokens": 18},
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -32,29 +32,29 @@ def test_normalized_github_host_accepts_only_bare_supported_hosts(raw: object, e
|
|||
def test_github_com_endpoints_read_url_overrides_at_call_time(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("GITHUB_COPILOT_DEVICE_CODE_URL", "https://device.example/code")
|
||||
monkeypatch.setenv("GITHUB_COPILOT_ACCESS_TOKEN_URL", "https://device.example/token")
|
||||
monkeypatch.setenv("GITHUB_COPILOT_API_KEY_URL", "https://api.example/copilot")
|
||||
monkeypatch.setenv("GITHUB_COPILOT_API_BASE", "https://copilot.example")
|
||||
monkeypatch.setenv("GITHUB_COPILOT_CLIENT_ID", "custom-client")
|
||||
|
||||
endpoints: Final = github_host_endpoints("github.com")
|
||||
|
||||
assert endpoints.device_code_url == "https://device.example/code"
|
||||
assert endpoints.access_token_url == "https://device.example/token"
|
||||
assert endpoints.api_key_url == "https://api.example/copilot"
|
||||
assert endpoints.configured_copilot_api_base == "https://copilot.example"
|
||||
assert endpoints.client_id == "custom-client"
|
||||
|
||||
|
||||
def test_ghe_endpoints_ignore_github_com_url_overrides(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("GITHUB_COPILOT_DEVICE_CODE_URL", "https://device.example/code")
|
||||
monkeypatch.setenv("GITHUB_COPILOT_ACCESS_TOKEN_URL", "https://device.example/token")
|
||||
monkeypatch.setenv("GITHUB_COPILOT_API_KEY_URL", "https://api.example/copilot")
|
||||
monkeypatch.setenv("GITHUB_COPILOT_API_BASE", "https://copilot.example")
|
||||
monkeypatch.setenv("GITHUB_COPILOT_CLIENT_ID", "custom-client")
|
||||
|
||||
endpoints: Final = github_host_endpoints("octo.ghe.com")
|
||||
|
||||
assert endpoints.device_code_url == "https://octo.ghe.com/login/device/code"
|
||||
assert endpoints.access_token_url == "https://octo.ghe.com/login/oauth/access_token"
|
||||
assert endpoints.api_key_url == "https://api.octo.ghe.com/copilot_internal/v2/token"
|
||||
assert endpoints.copilot_user_url == "https://api.octo.ghe.com/copilot_internal/user"
|
||||
assert endpoints.configured_copilot_api_base is None
|
||||
assert endpoints.user_url == "https://api.octo.ghe.com/user"
|
||||
assert endpoints.default_copilot_api_base == "https://copilot-api.octo.ghe.com"
|
||||
assert endpoints.client_id == "custom-client"
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -5,7 +5,7 @@ from contextlib import contextmanager
|
|||
from functools import reduce
|
||||
from types import SimpleNamespace
|
||||
from typing import Final, Protocol
|
||||
from unittest.mock import AsyncMock, MagicMock, call, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
|
@ -18,7 +18,6 @@ from pydantic import TypeAdapter
|
|||
import litellm
|
||||
from litellm.litellm_core_utils.credential_accessor import CredentialAccessor
|
||||
from litellm.litellm_core_utils.litellm_logging import get_masked_values
|
||||
from litellm.llms.github_copilot.per_user_auth import _SESSION_CACHE, _session_cache_key
|
||||
from litellm.models.credentials import CredentialSource, UserConnectionFlowHandle
|
||||
from litellm.proxy._types import LiteLLM_UserTable, LitellmUserRoles, UserAPIKeyAuth
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
|
|
@ -1812,12 +1811,6 @@ def test_update_credential_purges_connections_when_github_host_changes(monkeypat
|
|||
redis_cache.async_get_cache = AsyncMock(side_effect=get_cache)
|
||||
monkeypatch.setattr(proxy_server, "user_api_key_cache", DualCache(redis_cache=redis_cache))
|
||||
|
||||
session_key: Final = _session_cache_key("user-a", "copilot", "github.com")
|
||||
_SESSION_CACHE.set_cache(
|
||||
session_key,
|
||||
("gho_secret", "copilot-token", "https://api.githubcopilot.com", 4102444800),
|
||||
ttl=3600,
|
||||
)
|
||||
response = _patch_credential(
|
||||
"copilot",
|
||||
{
|
||||
|
|
@ -1828,15 +1821,9 @@ def test_update_credential_purges_connections_when_github_host_changes(monkeypat
|
|||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
table.find_many.assert_has_awaits(
|
||||
[
|
||||
call(where={"credential_name": "copilot"}),
|
||||
call(where={"credential_name": "copilot"}),
|
||||
]
|
||||
)
|
||||
table.find_many.assert_awaited_once_with(where={"credential_name": "copilot"})
|
||||
table.delete_many.assert_awaited_once_with(where={"credential_name": "copilot"})
|
||||
assert cached_values[upc._cache_key("user-a", "copilot")] == upc._NOT_CONNECTED
|
||||
assert _SESSION_CACHE.get_cache(session_key) is None
|
||||
|
||||
|
||||
def test_update_credential_does_not_purge_connections_for_case_only_host_change(restore_credential_list):
|
||||
|
|
@ -1852,12 +1839,6 @@ def test_update_credential_does_not_purge_connections_for_case_only_host_change(
|
|||
proxy_server.prisma_client.db.litellm_userprovidercredentials = table
|
||||
proxy_server.prisma_client.writer_db = proxy_server.prisma_client.db
|
||||
|
||||
session_key: Final = _session_cache_key("user-a", "copilot", "octo.ghe.com")
|
||||
_SESSION_CACHE.set_cache(
|
||||
session_key,
|
||||
("gho_secret", "copilot-token", "https://copilot-api.octo.ghe.com", 4102444800),
|
||||
ttl=3600,
|
||||
)
|
||||
response = _patch_credential(
|
||||
"copilot",
|
||||
{
|
||||
|
|
@ -1870,8 +1851,6 @@ def test_update_credential_does_not_purge_connections_for_case_only_host_change(
|
|||
assert response.status_code == 200, response.text
|
||||
table.find_many.assert_not_awaited()
|
||||
table.delete_many.assert_not_awaited()
|
||||
assert _SESSION_CACHE.get_cache(session_key) is not None
|
||||
_SESSION_CACHE.delete_cache(session_key)
|
||||
|
||||
|
||||
def test_update_credential_does_not_purge_connections_for_legacy_host_case_difference(restore_credential_list):
|
||||
|
|
@ -1887,12 +1866,6 @@ def test_update_credential_does_not_purge_connections_for_legacy_host_case_diffe
|
|||
proxy_server.prisma_client.db.litellm_userprovidercredentials = table
|
||||
proxy_server.prisma_client.writer_db = proxy_server.prisma_client.db
|
||||
|
||||
session_key: Final = _session_cache_key("user-a", "copilot", "octo.ghe.com")
|
||||
_SESSION_CACHE.set_cache(
|
||||
session_key,
|
||||
("gho_secret", "copilot-token", "https://copilot-api.octo.ghe.com", 4102444800),
|
||||
ttl=3600,
|
||||
)
|
||||
response = _patch_credential(
|
||||
"copilot",
|
||||
{
|
||||
|
|
@ -1905,8 +1878,6 @@ def test_update_credential_does_not_purge_connections_for_legacy_host_case_diffe
|
|||
assert response.status_code == 200, response.text
|
||||
table.find_many.assert_not_awaited()
|
||||
table.delete_many.assert_not_awaited()
|
||||
assert _SESSION_CACHE.get_cache(session_key) is not None
|
||||
_SESSION_CACHE.delete_cache(session_key)
|
||||
|
||||
|
||||
def test_update_credential_purges_connections_when_github_host_is_deleted(restore_credential_list):
|
||||
|
|
@ -2065,12 +2036,15 @@ from contextlib import contextmanager as _ctx
|
|||
|
||||
from litellm.caching.dual_cache import DualCache
|
||||
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
|
||||
from litellm.llms.github_copilot.common_utils import DEFAULT_GITHUB_COPILOT_API_BASE
|
||||
from litellm.proxy.credential_endpoints import user_provider_credentials as upc
|
||||
|
||||
_DEVICE_CODE_URL = "https://github.com/login/device/code"
|
||||
_ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token"
|
||||
_COPILOT_TOKEN_URL = "https://api.github.com/copilot_internal/v2/token"
|
||||
_COPILOT_MODELS_URL: Final = f"{DEFAULT_GITHUB_COPILOT_API_BASE}/models"
|
||||
_GITHUB_USER_URL = "https://api.github.com/user"
|
||||
_COPILOT_USER_URL: Final = "https://api.github.com/copilot_internal/user"
|
||||
_DEFAULT_PLAN: Final = {"endpoints": {"api": DEFAULT_GITHUB_COPILOT_API_BASE}}
|
||||
|
||||
|
||||
def _as_user(user_id="user-a"):
|
||||
|
|
@ -2118,6 +2092,7 @@ def _connection_row(
|
|||
@pytest.fixture(autouse=True)
|
||||
def _connection_master_key(monkeypatch):
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.master_key", "sk-test-master")
|
||||
monkeypatch.delenv("GITHUB_COPILOT_API_BASE", raising=False)
|
||||
|
||||
|
||||
@_ctx
|
||||
|
|
@ -2137,6 +2112,15 @@ def _github_http(mapping):
|
|||
yield
|
||||
|
||||
|
||||
def _copilot_models_response(github_token):
|
||||
def respond(request):
|
||||
assert request.method == "GET"
|
||||
assert request.headers["Authorization"] == f"Bearer {github_token}"
|
||||
return {"data": []}
|
||||
|
||||
return respond
|
||||
|
||||
|
||||
_DEVICE_FLOW_START = {
|
||||
"device_code": "dc-1",
|
||||
"user_code": "UC-1",
|
||||
|
|
@ -2239,13 +2223,30 @@ def test_user_connection_poll_rejects_an_undecryptable_flow_handle(monkeypatch):
|
|||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_user_connection_poll_connected_persists_and_reports_login(monkeypatch):
|
||||
table: Final = _patch_user_connection_env(monkeypatch, [_per_user_credential()])
|
||||
@pytest.mark.parametrize(
|
||||
("api_base", "plan_lookup", "seat_check_base"),
|
||||
[
|
||||
(None, {_COPILOT_USER_URL: _DEFAULT_PLAN}, DEFAULT_GITHUB_COPILOT_API_BASE),
|
||||
(
|
||||
None,
|
||||
{_COPILOT_USER_URL: {"endpoints": {"api": "https://api.business.githubcopilot.com"}}},
|
||||
"https://api.business.githubcopilot.com",
|
||||
),
|
||||
("https://copilot.test/gateway/", {}, "https://copilot.test/gateway"),
|
||||
],
|
||||
)
|
||||
def test_user_connection_poll_connected_persists_and_reports_login(
|
||||
monkeypatch, api_base, plan_lookup, seat_check_base
|
||||
):
|
||||
table = _patch_user_connection_env(monkeypatch, [_per_user_credential()])
|
||||
if api_base is not None:
|
||||
monkeypatch.setenv("GITHUB_COPILOT_API_BASE", api_base)
|
||||
with _github_http(
|
||||
{
|
||||
_DEVICE_CODE_URL: _DEVICE_FLOW_START,
|
||||
_ACCESS_TOKEN_URL: {"access_token": "gho_1"},
|
||||
_COPILOT_TOKEN_URL: {"token": "copilot-tok", "expires_at": 4102444800},
|
||||
**plan_lookup,
|
||||
f"{seat_check_base}/models": _copilot_models_response("gho_1"),
|
||||
_GITHUB_USER_URL: {"login": "octo"},
|
||||
}
|
||||
):
|
||||
|
|
@ -2264,11 +2265,10 @@ def test_user_connection_poll_connected_persists_and_reports_login(monkeypatch):
|
|||
assert poll.status_code == 200, poll.text
|
||||
assert poll.json() == {"status": "connected", "interval": None, "github_login": "octo"}
|
||||
table.upsert.assert_awaited_once()
|
||||
stored_connection: Final = upc.decode_user_provider_credential(
|
||||
table.upsert.await_args.kwargs["data"]["create"]["credential_b64"]
|
||||
)
|
||||
assert stored_connection is not None
|
||||
assert stored_connection.github_host == "github.com"
|
||||
written = table.upsert.await_args.kwargs["data"]
|
||||
for operation in ("create", "update"):
|
||||
stored_connection = upc.decode_user_provider_credential(written[operation]["credential_b64"])
|
||||
assert stored_connection == upc.GithubCopilotUserConnectionPayload(access_token="gho_1", github_login="octo")
|
||||
|
||||
|
||||
def test_poll_host_change_race_requires_reconnect_on_the_next_request(monkeypatch):
|
||||
|
|
@ -2276,19 +2276,16 @@ def test_poll_host_change_race_requires_reconnect_on_the_next_request(monkeypatc
|
|||
|
||||
table: Final = _patch_user_connection_env(monkeypatch, [_per_user_credential(host="octo.ghe.com")])
|
||||
|
||||
def change_host_after_token_exchange(request: httpx.Request) -> dict[str, str]:
|
||||
def change_host_after_device_flow(request: httpx.Request) -> dict[str, str]:
|
||||
monkeypatch.setattr(litellm, "credential_list", [_per_user_credential()])
|
||||
return {"access_token": "gho_1"}
|
||||
|
||||
with _github_http(
|
||||
{
|
||||
"https://octo.ghe.com/login/device/code": _DEVICE_FLOW_START,
|
||||
"https://octo.ghe.com/login/oauth/access_token": change_host_after_token_exchange,
|
||||
"https://api.octo.ghe.com/copilot_internal/v2/token": {
|
||||
"token": "copilot-tok",
|
||||
"expires_at": 4102444800,
|
||||
"endpoints": {"api": "https://copilot-api.octo.ghe.com"},
|
||||
},
|
||||
"https://octo.ghe.com/login/oauth/access_token": change_host_after_device_flow,
|
||||
"https://api.octo.ghe.com/copilot_internal/user": {"endpoints": {"api": "https://copilot-api.octo.ghe.com"}},
|
||||
"https://copilot-api.octo.ghe.com/models": _copilot_models_response("gho_1"),
|
||||
"https://api.octo.ghe.com/user": {"login": "octo"},
|
||||
}
|
||||
):
|
||||
|
|
@ -2318,7 +2315,7 @@ def test_poll_host_change_race_requires_reconnect_on_the_next_request(monkeypatc
|
|||
},
|
||||
}
|
||||
module_level_client: Final = MagicMock()
|
||||
module_level_client.get.side_effect = AssertionError("unexpected token exchange")
|
||||
module_level_client.get.side_effect = AssertionError("unexpected GitHub request")
|
||||
with _github_http({}), patch.object(litellm, "module_level_client", module_level_client):
|
||||
with pytest.raises(litellm.CallerCredentialAuthenticationError) as exc:
|
||||
attach_github_copilot_user_session(kwargs)
|
||||
|
|
@ -2326,17 +2323,14 @@ def test_poll_host_change_race_requires_reconnect_on_the_next_request(monkeypatc
|
|||
assert stored_connection.github_host == "octo.ghe.com"
|
||||
|
||||
|
||||
def test_ghe_user_connection_uses_tenant_urls_for_device_flow_exchange_and_login(monkeypatch):
|
||||
def test_ghe_user_connection_uses_tenant_urls_for_device_flow_seat_check_and_login(monkeypatch):
|
||||
table: Final = _patch_user_connection_env(monkeypatch, [_per_user_credential(host="octo.ghe.com")])
|
||||
with _github_http(
|
||||
{
|
||||
"https://octo.ghe.com/login/device/code": _DEVICE_FLOW_START,
|
||||
"https://octo.ghe.com/login/oauth/access_token": {"access_token": "gho_1"},
|
||||
"https://api.octo.ghe.com/copilot_internal/v2/token": {
|
||||
"token": "copilot-tok",
|
||||
"expires_at": 4102444800,
|
||||
"endpoints": {"api": "https://copilot-api.octo.ghe.com"},
|
||||
},
|
||||
"https://api.octo.ghe.com/copilot_internal/user": {"endpoints": {"api": "https://copilot-api.octo.ghe.com"}},
|
||||
"https://copilot-api.octo.ghe.com/models": _copilot_models_response("gho_1"),
|
||||
"https://api.octo.ghe.com/user": {"login": "octo"},
|
||||
}
|
||||
):
|
||||
|
|
@ -2459,7 +2453,7 @@ def test_user_connections_isolated_per_user(monkeypatch):
|
|||
table.delete_many.assert_awaited_once_with(where={"user_id": "user-a", "credential_name": "copilot-cred"})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("seat_status", [403, 404])
|
||||
@pytest.mark.parametrize("seat_status", [401, 403, 404])
|
||||
def test_user_connection_poll_reports_no_copilot_seat(monkeypatch, seat_status):
|
||||
"""A GitHub seat check that rejects the user's token maps to no_copilot_seat, stores
|
||||
nothing, and clears the in-flight device code."""
|
||||
|
|
@ -2471,7 +2465,11 @@ def test_user_connection_poll_reports_no_copilot_seat(monkeypatch, seat_status):
|
|||
return httpx.Response(200, json=_DEVICE_FLOW_START, request=request)
|
||||
if url == _ACCESS_TOKEN_URL:
|
||||
return httpx.Response(200, json={"access_token": "gho_seat"}, request=request)
|
||||
if url == _COPILOT_TOKEN_URL:
|
||||
if url == _COPILOT_USER_URL:
|
||||
return httpx.Response(200, json=_DEFAULT_PLAN, request=request)
|
||||
if url == _COPILOT_MODELS_URL:
|
||||
assert request.method == "GET"
|
||||
assert request.headers["Authorization"] == "Bearer gho_seat"
|
||||
return httpx.Response(seat_status, json={}, request=request)
|
||||
pytest.fail(f"unexpected request to {url}")
|
||||
|
||||
|
|
@ -2499,7 +2497,11 @@ def test_user_connection_poll_rate_limit_returns_429(monkeypatch):
|
|||
return httpx.Response(200, json=_DEVICE_FLOW_START, request=request)
|
||||
if url == _ACCESS_TOKEN_URL:
|
||||
return httpx.Response(200, json={"access_token": "gho_limited"}, request=request)
|
||||
if url == _COPILOT_TOKEN_URL:
|
||||
if url == _COPILOT_USER_URL:
|
||||
return httpx.Response(200, json=_DEFAULT_PLAN, request=request)
|
||||
if url == _COPILOT_MODELS_URL:
|
||||
assert request.method == "GET"
|
||||
assert request.headers["Authorization"] == "Bearer gho_limited"
|
||||
return httpx.Response(429, json={}, request=request)
|
||||
pytest.fail(f"unexpected request to {url}")
|
||||
|
||||
|
|
@ -2729,23 +2731,6 @@ def test_pending_device_flow_survives_disconnect_via_stateless_handle(monkeypatc
|
|||
assert delete.json() == {"status": "disconnected"}
|
||||
|
||||
|
||||
def test_delete_user_connection_evicts_copilot_session(monkeypatch):
|
||||
table: Final = _patch_user_connection_env(monkeypatch, [_per_user_credential()], rows=[_connection_row()])
|
||||
session_key: Final = _session_cache_key("user-a", "copilot-cred", "github.com")
|
||||
_SESSION_CACHE.set_cache(
|
||||
session_key,
|
||||
("gho_secret", "copilot-token", "https://api.githubcopilot.com", 4102444800),
|
||||
ttl=3600,
|
||||
)
|
||||
|
||||
response: Final = _call_as("DELETE", "/credentials/copilot-cred/user_connection", auth=_as_user())
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json() == {"status": "disconnected"}
|
||||
assert _SESSION_CACHE.get_cache(session_key) is None
|
||||
table.delete_many.assert_awaited_once_with(where={"user_id": "user-a", "credential_name": "copilot-cred"})
|
||||
|
||||
|
||||
def test_user_connection_cache_keys_cannot_collide_on_colons():
|
||||
"""user_id/credential_name are joined losslessly, so ('a:b','c') and ('a','b:c')
|
||||
can never share a cache or device-flow entry."""
|
||||
|
|
@ -2875,7 +2860,8 @@ def test_user_connection_poll_valid_handle_works_with_a_fresh_cache(monkeypatch)
|
|||
{
|
||||
_DEVICE_CODE_URL: _DEVICE_FLOW_START,
|
||||
_ACCESS_TOKEN_URL: {"access_token": "gho_1"},
|
||||
_COPILOT_TOKEN_URL: {"token": "copilot-tok", "expires_at": 4102444800},
|
||||
_COPILOT_USER_URL: _DEFAULT_PLAN,
|
||||
_COPILOT_MODELS_URL: _copilot_models_response("gho_1"),
|
||||
_GITHUB_USER_URL: {"login": "octo"},
|
||||
}
|
||||
):
|
||||
|
|
@ -2959,7 +2945,8 @@ def test_user_connection_poll_reports_503_when_the_cache_overwrite_cannot_land(m
|
|||
{
|
||||
_DEVICE_CODE_URL: _DEVICE_FLOW_START,
|
||||
_ACCESS_TOKEN_URL: {"access_token": "gho_1"},
|
||||
_COPILOT_TOKEN_URL: {"token": "copilot-tok", "expires_at": 4102444800},
|
||||
_COPILOT_USER_URL: _DEFAULT_PLAN,
|
||||
_COPILOT_MODELS_URL: _copilot_models_response("gho_1"),
|
||||
_GITHUB_USER_URL: {"login": "octo"},
|
||||
}
|
||||
):
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ Pins (PR2):
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
|
@ -177,15 +176,6 @@ def test_supported_openai_params_never_runs_oauth_for_authenticating_providers(c
|
|||
through ``get_llm_provider`` would run the provider's OAuth device flow and block the event loop."""
|
||||
monkeypatch.setenv("GITHUB_COPILOT_TOKEN_DIR", str(tmp_path))
|
||||
(tmp_path / "access-token").write_text("fake-access-token")
|
||||
(tmp_path / "api-key.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"token": "fake-api-key",
|
||||
"expires_at": 4102444800,
|
||||
"endpoints": {"api": "https://api.githubcopilot.com"},
|
||||
}
|
||||
)
|
||||
)
|
||||
router = litellm.Router(
|
||||
model_list=[
|
||||
{
|
||||
|
|
|
|||
|
|
@ -5136,11 +5136,8 @@ class TestRouterPreRoutingAliasOverrides:
|
|||
string, and model info adopts the declared prefix. The recording wrapper raises for a
|
||||
copilot-directed resolution rather than calling through, so a regression fails on the
|
||||
recorded call instead of hanging the suite in a device-code poll."""
|
||||
import json
|
||||
import time
|
||||
|
||||
monkeypatch.setenv("GITHUB_COPILOT_TOKEN_DIR", str(tmp_path))
|
||||
(tmp_path / "api-key.json").write_text(json.dumps({"token": "tid=test", "expires_at": int(time.time()) + 3600}))
|
||||
(tmp_path / "access-token").write_text("gho_test")
|
||||
router = Router(
|
||||
model_list=[
|
||||
{
|
||||
|
|
@ -15150,11 +15147,8 @@ class TestContextWindowEscalation:
|
|||
"""Resolving github_copilot runs its OAuth device flow, so a window question must adopt
|
||||
the declaration instead of resolving: the copilot group reads as unknown-window and the
|
||||
request stays put, with zero copilot resolutions recorded."""
|
||||
import json
|
||||
import time
|
||||
|
||||
monkeypatch.setenv("GITHUB_COPILOT_TOKEN_DIR", str(tmp_path))
|
||||
(tmp_path / "api-key.json").write_text(json.dumps({"token": "tid=test", "expires_at": int(time.time()) + 3600}))
|
||||
(tmp_path / "access-token").write_text("gho_test")
|
||||
router = ComplexityRouter(
|
||||
model_name="test-router",
|
||||
ctx=RoutingContext(
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import random
|
|||
import time
|
||||
import uuid
|
||||
from collections.abc import Mapping, Sequence
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
|
@ -1590,20 +1591,18 @@ class TestPerUserCopilotAuthFailureCooldown:
|
|||
assert get_cooldown_deployments(RoutingContext(router), parent_otel_span=None) == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shared_mode_copilot_auth_failure_still_cools_down(self) -> None:
|
||||
from litellm.llms.github_copilot.authenticator import Authenticator
|
||||
|
||||
async def test_shared_mode_copilot_auth_failure_still_cools_down(
|
||||
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
monkeypatch.setenv("GITHUB_COPILOT_TOKEN_DIR", str(tmp_path))
|
||||
(tmp_path / "access-token").write_text("gho_shared")
|
||||
model_id: Final = "shared-mode"
|
||||
with (
|
||||
patch.object(Authenticator, "get_api_key", return_value="shared-copilot-token"),
|
||||
patch.object(Authenticator, "get_api_base", return_value="https://api.githubcopilot.com"),
|
||||
):
|
||||
router: Final = self._router(model_id, {"api_key": "sk-shared"})
|
||||
self._callback(
|
||||
router,
|
||||
model_id,
|
||||
litellm.AuthenticationError("upstream rejected the shared token", "github_copilot", "gpt-4o"),
|
||||
)
|
||||
router: Final = self._router(model_id, {"api_key": "sk-shared"})
|
||||
self._callback(
|
||||
router,
|
||||
model_id,
|
||||
litellm.AuthenticationError("upstream rejected the shared token", "github_copilot", "gpt-4o"),
|
||||
)
|
||||
assert get_deployment_failures_for_current_minute(RoutingContext(router), model_id) == 1
|
||||
assert get_cooldown_deployments(RoutingContext(router), parent_otel_span=None) == [model_id]
|
||||
|
||||
|
|
@ -1637,21 +1636,17 @@ class TestPerUserCopilotAuthFailureCooldown:
|
|||
assert get_cooldown_deployments(RoutingContext(router), parent_otel_span=None) == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_shared_mode_auth_failure_still_cools_down(self) -> None:
|
||||
from litellm.llms.github_copilot.authenticator import Authenticator
|
||||
|
||||
async def test_fallback_shared_mode_auth_failure_still_cools_down(
|
||||
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
monkeypatch.setenv("GITHUB_COPILOT_TOKEN_DIR", str(tmp_path))
|
||||
(tmp_path / "access-token").write_text("gho_shared")
|
||||
model_id: Final = "fallback-shared"
|
||||
with (
|
||||
patch.object(Authenticator, "get_api_key", return_value="shared-copilot-token"),
|
||||
patch.object(Authenticator, "get_api_base", return_value="https://api.githubcopilot.com"),
|
||||
):
|
||||
router: Final = self._router(model_id, {"api_key": "sk-shared"})
|
||||
exception: Final = litellm.AuthenticationError(
|
||||
"upstream rejected the shared token", "github_copilot", "gpt-4o"
|
||||
)
|
||||
exception.failed_deployment_id = model_id
|
||||
router: Final = self._router(model_id, {"api_key": "sk-shared"})
|
||||
exception: Final = litellm.AuthenticationError("upstream rejected the shared token", "github_copilot", "gpt-4o")
|
||||
exception.failed_deployment_id = model_id
|
||||
|
||||
_trigger_cooldown_for_failed_deployment(ctx=RoutingContext(router), kwargs={}, exception=exception)
|
||||
_trigger_cooldown_for_failed_deployment(ctx=RoutingContext(router), kwargs={}, exception=exception)
|
||||
|
||||
assert get_cooldown_deployments(RoutingContext(router), parent_otel_span=None) == [model_id]
|
||||
|
||||
|
|
@ -1687,23 +1682,21 @@ class TestPerUserCopilotAuthFailureCooldown:
|
|||
assert get_cooldown_deployments(RoutingContext(router), parent_otel_span=None) == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_rate_limit_on_shared_deployment_still_cools_down(self) -> None:
|
||||
from litellm.llms.github_copilot.authenticator import Authenticator
|
||||
|
||||
async def test_fallback_rate_limit_on_shared_deployment_still_cools_down(
|
||||
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
monkeypatch.setenv("GITHUB_COPILOT_TOKEN_DIR", str(tmp_path))
|
||||
(tmp_path / "access-token").write_text("gho_shared")
|
||||
model_id: Final = "fallback-shared-429"
|
||||
with (
|
||||
patch.object(Authenticator, "get_api_key", return_value="shared-copilot-token"),
|
||||
patch.object(Authenticator, "get_api_base", return_value="https://api.githubcopilot.com"),
|
||||
):
|
||||
router: Final = self._router(
|
||||
model_id,
|
||||
{"api_key": "sk-shared"},
|
||||
allowed_fails_policy={"RateLimitErrorAllowedFails": 0},
|
||||
)
|
||||
exception: Final = litellm.RateLimitError("copilot 429", "github_copilot", "gpt-4o")
|
||||
exception.failed_deployment_id = model_id
|
||||
router: Final = self._router(
|
||||
model_id,
|
||||
{"api_key": "sk-shared"},
|
||||
allowed_fails_policy={"RateLimitErrorAllowedFails": 0},
|
||||
)
|
||||
exception: Final = litellm.RateLimitError("copilot 429", "github_copilot", "gpt-4o")
|
||||
exception.failed_deployment_id = model_id
|
||||
|
||||
_trigger_cooldown_for_failed_deployment(ctx=RoutingContext(router), kwargs={}, exception=exception)
|
||||
_trigger_cooldown_for_failed_deployment(ctx=RoutingContext(router), kwargs={}, exception=exception)
|
||||
|
||||
assert get_cooldown_deployments(RoutingContext(router), parent_otel_span=None) == [model_id]
|
||||
|
||||
|
|
@ -3282,11 +3275,12 @@ async def test_router_allowed_fails_still_benches_a_deployment_that_has_a_siblin
|
|||
def test_per_user_session_upstream_errors_never_cool_down_the_shared_deployment(single_deployment_router):
|
||||
"""A 429/401 from the caller's own Copilot seat is scoped to that user; it must
|
||||
not cool down the shared deployment for every other caller."""
|
||||
from litellm.llms.github_copilot.github_host import github_host_endpoints
|
||||
from litellm.llms.github_copilot.per_user_auth import GithubCopilotUserSession
|
||||
|
||||
session_kwargs = {
|
||||
"github_copilot_user_session": GithubCopilotUserSession(
|
||||
token="copilot-token", api_base="https://api.githubcopilot.com"
|
||||
token="gho_user", endpoints=github_host_endpoints("github.com")
|
||||
)
|
||||
}
|
||||
for exc, status in (
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ _IN_MEMORY_ONLY_CALLERS: Final = frozenset(
|
|||
"litellm/llms/custom_httpx/http_handler.py",
|
||||
"litellm/llms/databricks/common_utils.py",
|
||||
"litellm/llms/gigachat/authenticator.py",
|
||||
"litellm/llms/github_copilot/per_user_auth.py",
|
||||
"litellm/llms/github_copilot/common_utils.py",
|
||||
"litellm/llms/litellm_proxy/skills/handler.py",
|
||||
"litellm/llms/openai/common_utils.py",
|
||||
"litellm/llms/openai_like/model_info.py",
|
||||
|
|
|
|||
|
|
@ -2533,9 +2533,8 @@ def non_openai_responses_bridge(
|
|||
json.dumps({"access_token": "token", "account_id": "acct", "expires_at": 4102444800})
|
||||
)
|
||||
monkeypatch.setenv("GITHUB_COPILOT_TOKEN_DIR", str(tmp_path))
|
||||
(tmp_path / "api-key.json").write_text(
|
||||
json.dumps({"token": "token", "expires_at": 4102444800, "endpoints": {"api": "https://api.githubcopilot.com"}})
|
||||
)
|
||||
monkeypatch.setenv("GITHUB_COPILOT_API_BASE", "https://api.githubcopilot.com")
|
||||
(tmp_path / "access-token").write_text("token")
|
||||
litellm.register_model(
|
||||
{
|
||||
**{
|
||||
|
|
|
|||
|
|
@ -11021,6 +11021,46 @@ def test_update_kwargs_with_deployment_no_tags():
|
|||
assert "tags" not in kwargs["metadata"]
|
||||
|
||||
|
||||
def _copilot_deployment_object(
|
||||
token_dir: Path, monkeypatch: pytest.MonkeyPatch, **litellm_params: object
|
||||
) -> tuple[litellm.Router, Deployment | None]:
|
||||
(token_dir / "access-token").write_text("shared-copilot-token")
|
||||
monkeypatch.setenv("GITHUB_COPILOT_TOKEN_DIR", str(token_dir))
|
||||
router: Final = litellm.Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "copilot",
|
||||
"litellm_params": {
|
||||
"model": "github_copilot/gpt-4.1",
|
||||
"api_base": "https://deployment.example.com/v1",
|
||||
**litellm_params,
|
||||
},
|
||||
}
|
||||
]
|
||||
)
|
||||
return router, router.get_deployment_by_model_group_name(model_group_name="copilot")
|
||||
|
||||
|
||||
def test_update_kwargs_with_deployment_drops_caller_api_base_for_a_copilot_deployment_object_with_drop_params(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
router, deployment = _copilot_deployment_object(tmp_path, monkeypatch, drop_params=True)
|
||||
kwargs: Final[dict[str, object]] = {"metadata": {}, "api_base": "https://caller.example.com"}
|
||||
|
||||
router._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs)
|
||||
|
||||
assert "api_base" not in kwargs
|
||||
|
||||
|
||||
def test_update_kwargs_with_deployment_refuses_caller_api_base_for_a_copilot_deployment_object(tmp_path, monkeypatch):
|
||||
router, deployment = _copilot_deployment_object(tmp_path, monkeypatch)
|
||||
|
||||
with pytest.raises(litellm.BadRequestError, match="do not accept api_base from the caller"):
|
||||
router._update_kwargs_with_deployment(
|
||||
deployment=deployment, kwargs={"metadata": {}, "api_base": "https://caller.example.com"}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_does_not_narrow_tag_filtered_group_to_failed_deployments_tags():
|
||||
router = Router(
|
||||
|
|
@ -31662,7 +31702,7 @@ def test_pre_call_checks_filters_an_authenticating_provider_deployment_that_lack
|
|||
):
|
||||
monkeypatch.setattr(litellm, "drop_params", False)
|
||||
monkeypatch.setenv("GITHUB_COPILOT_TOKEN_DIR", str(tmp_path))
|
||||
(tmp_path / "api-key.json").write_text(json.dumps({"token": "tid=test", "expires_at": 4102444800}))
|
||||
(tmp_path / "access-token").write_text("gho_test")
|
||||
model_list: Final = [
|
||||
{
|
||||
"model_name": "mixed-json-mode",
|
||||
|
|
|
|||
|
|
@ -49,9 +49,6 @@ from litellm.utils import (
|
|||
)
|
||||
|
||||
|
||||
_COPILOT_TOKEN_EXPIRES_AT: Final = 4_102_444_800
|
||||
|
||||
|
||||
def _simulate_price_data_reload(fetched_catalog):
|
||||
"""Drive what a price data reload does to this process's litellm state.
|
||||
|
||||
|
|
@ -2974,7 +2971,7 @@ def test_a_github_copilot_chat_pin_opts_a_dual_endpoint_model_out_of_the_respons
|
|||
from litellm.llms.github_copilot.responses.transformation import github_copilot_supports_responses_api
|
||||
|
||||
monkeypatch.setenv("GITHUB_COPILOT_TOKEN_DIR", str(tmp_path))
|
||||
(tmp_path / "api-key.json").write_text(json.dumps({"token": "tid=test", "expires_at": _COPILOT_TOKEN_EXPIRES_AT}))
|
||||
(tmp_path / "access-token").write_text("gho_test")
|
||||
monkeypatch.setattr(litellm, "model_cost", copy.deepcopy(litellm.model_cost))
|
||||
litellm.model_cost["github_copilot/routing-test-model"] = {
|
||||
"litellm_provider": "github_copilot",
|
||||
|
|
@ -3014,7 +3011,7 @@ def test_a_github_copilot_responses_pin_still_opts_the_model_into_the_responses_
|
|||
from litellm.llms.github_copilot.responses.transformation import github_copilot_supports_responses_api
|
||||
|
||||
monkeypatch.setenv("GITHUB_COPILOT_TOKEN_DIR", str(tmp_path))
|
||||
(tmp_path / "api-key.json").write_text(json.dumps({"token": "tid=test", "expires_at": _COPILOT_TOKEN_EXPIRES_AT}))
|
||||
(tmp_path / "access-token").write_text("gho_test")
|
||||
monkeypatch.setattr(litellm, "model_cost", copy.deepcopy(litellm.model_cost))
|
||||
litellm.model_cost["github_copilot/routing-test-model"] = {
|
||||
"litellm_provider": "github_copilot",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue