diff --git a/litellm/constants.py b/litellm/constants.py index 32b47bc6b46..ea6d1b49ae8 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -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" diff --git a/litellm/litellm_core_utils/clientside_credential_handler.py b/litellm/litellm_core_utils/clientside_credential_handler.py index 2bd58405693..60d2e637f2f 100644 --- a/litellm/litellm_core_utils/clientside_credential_handler.py +++ b/litellm/litellm_core_utils/clientside_credential_handler.py @@ -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" ) diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index 6d39f272f3e..8de992bd2ca 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -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") ): diff --git a/litellm/litellm_core_utils/get_llm_provider_logic.py b/litellm/litellm_core_utils/get_llm_provider_logic.py index 1ffb5e2f8e5..aef1cbbbb31 100644 --- a/litellm/litellm_core_utils/get_llm_provider_logic.py +++ b/litellm/litellm_core_utils/get_llm_provider_logic.py @@ -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: ( diff --git a/litellm/litellm_core_utils/llm_response_utils/get_api_base.py b/litellm/litellm_core_utils/llm_response_utils/get_api_base.py index 4d731b5e63a..5b39e14548b 100644 --- a/litellm/litellm_core_utils/llm_response_utils/get_api_base.py +++ b/litellm/litellm_core_utils/llm_response_utils/get_api_base.py @@ -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, diff --git a/litellm/llms/github_copilot/authenticator.py b/litellm/llms/github_copilot/authenticator.py index ee0e7af7e9b..6bdaeacb808 100644 --- a/litellm/llms/github_copilot/authenticator.py +++ b/litellm/llms/github_copilot/authenticator.py @@ -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: """ diff --git a/litellm/llms/github_copilot/chat/transformation.py b/litellm/llms/github_copilot/chat/transformation.py index 83bc8df23a6..8fb92eb7573 100644 --- a/litellm/llms/github_copilot/chat/transformation.py +++ b/litellm/llms/github_copilot/chat/transformation.py @@ -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, diff --git a/litellm/llms/github_copilot/common_utils.py b/litellm/llms/github_copilot/common_utils.py index 515b7bf4fb5..315ae0e5021 100644 --- a/litellm/llms/github_copilot/common_utils.py +++ b/litellm/llms/github_copilot/common_utils.py @@ -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..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, diff --git a/litellm/llms/github_copilot/embedding/transformation.py b/litellm/llms/github_copilot/embedding/transformation.py index 4f308b01159..4d58887488d 100644 --- a/litellm/llms/github_copilot/embedding/transformation.py +++ b/litellm/llms/github_copilot/embedding/transformation.py @@ -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, diff --git a/litellm/llms/github_copilot/github_host.py b/litellm/llms/github_copilot/github_host.py index cfe122ebac4..afea6b38cd5 100644 --- a/litellm/llms/github_copilot/github_host.py +++ b/litellm/llms/github_copilot/github_host.py @@ -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, ) diff --git a/litellm/llms/github_copilot/messages/transformation.py b/litellm/llms/github_copilot/messages/transformation.py index bc2b68cd02b..ef2628e0363 100644 --- a/litellm/llms/github_copilot/messages/transformation.py +++ b/litellm/llms/github_copilot/messages/transformation.py @@ -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") diff --git a/litellm/llms/github_copilot/per_user_auth.py b/litellm/llms/github_copilot/per_user_auth.py index e210414ce8f..ffd92196e80 100644 --- a/litellm/llms/github_copilot/per_user_auth.py +++ b/litellm/llms/github_copilot/per_user_auth.py @@ -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) diff --git a/litellm/llms/github_copilot/responses/transformation.py b/litellm/llms/github_copilot/responses/transformation.py index b87a3d543e7..1b80126eb3a 100644 --- a/litellm/llms/github_copilot/responses/transformation.py +++ b/litellm/llms/github_copilot/responses/transformation.py @@ -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]: """ diff --git a/litellm/main.py b/litellm/main.py index 8cb133bf76b..51df08f1242 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -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") diff --git a/litellm/proxy/credential_endpoints/endpoints.py b/litellm/proxy/credential_endpoints/endpoints.py index 86e716f19e3..ae9b84eeb05 100644 --- a/litellm/proxy/credential_endpoints/endpoints.py +++ b/litellm/proxy/credential_endpoints/endpoints.py @@ -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( diff --git a/litellm/proxy/credential_endpoints/user_provider_credentials.py b/litellm/proxy/credential_endpoints/user_provider_credentials.py index 22ee271410e..38c85241a20 100644 --- a/litellm/proxy/credential_endpoints/user_provider_credentials.py +++ b/litellm/proxy/credential_endpoints/user_provider_credentials.py @@ -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, diff --git a/litellm/router_python_core.py b/litellm/router_python_core.py index 4b5ecc82581..df8cb8138c5 100644 --- a/litellm/router_python_core.py +++ b/litellm/router_python_core.py @@ -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 diff --git a/litellm/utils.py b/litellm/utils.py index 4add9d53cb3..e27bbb9f0cf 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -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: diff --git a/tests/integration/_support/copilot_session_prometheus_worker.py b/tests/integration/_support/copilot_session_prometheus_worker.py index a6b2c5d4692..279807f3170 100644 --- a/tests/integration/_support/copilot_session_prometheus_worker.py +++ b/tests/integration/_support/copilot_session_prometheus_worker.py @@ -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), diff --git a/tests/integration/_support/device_login.py b/tests/integration/_support/device_login.py index 55519678f40..5f957cc8c6a 100644 --- a/tests/integration/_support/device_login.py +++ b/tests/integration/_support/device_login.py @@ -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) diff --git a/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_safeguards_beta_wire.py b/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_safeguards_beta_wire.py index a1c5f6964bf..ec3a634d614 100644 --- a/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_safeguards_beta_wire.py +++ b/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_safeguards_beta_wire.py @@ -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) diff --git a/tests/integration/providers/test_device_code_login_guard_boot.py b/tests/integration/providers/test_device_code_login_guard_boot.py index 91f502179f3..5f4776c029e 100644 --- a/tests/integration/providers/test_device_code_login_guard_boot.py +++ b/tests/integration/providers/test_device_code_login_guard_boot.py @@ -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, diff --git a/tests/integration/providers/test_device_code_login_guard_wire.py b/tests/integration/providers/test_device_code_login_guard_wire.py index 39e36fd95fa..003ad64f20f 100644 --- a/tests/integration/providers/test_device_code_login_guard_wire.py +++ b/tests/integration/providers/test_device_code_login_guard_wire.py @@ -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", diff --git a/tests/integration/providers/test_github_copilot_ghe_per_user.py b/tests/integration/providers/test_github_copilot_ghe_per_user.py new file mode 100644 index 00000000000..a7cd11fe2cb --- /dev/null +++ b/tests/integration/providers/test_github_copilot_ghe_per_user.py @@ -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" diff --git a/tests/integration/providers/test_responses_minted_reasoning_replay_chaos.py b/tests/integration/providers/test_responses_minted_reasoning_replay_chaos.py index a266b20b227..826ed6f6840 100644 --- a/tests/integration/providers/test_responses_minted_reasoning_replay_chaos.py +++ b/tests/integration/providers/test_responses_minted_reasoning_replay_chaos.py @@ -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), diff --git a/tests/integration/sdk/test_device_code_login_guard_sdk.py b/tests/integration/sdk/test_device_code_login_guard_sdk.py index 7640fe13d5d..256871faf7c 100644 --- a/tests/integration/sdk/test_device_code_login_guard_sdk.py +++ b/tests/integration/sdk/test_device_code_login_guard_sdk.py @@ -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 diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 914eb1f2508..4a9482d6827 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -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() diff --git a/tests/unit/integrations/test_prometheus_invalid_key_filtering.py b/tests/unit/integrations/test_prometheus_invalid_key_filtering.py index 3f3e2e0d465..e6a12d8667a 100644 --- a/tests/unit/integrations/test_prometheus_invalid_key_filtering.py +++ b/tests/unit/integrations/test_prometheus_invalid_key_filtering.py @@ -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") ) } }, diff --git a/tests/unit/litellm_core_utils/llm_response_utils/test_get_api_base.py b/tests/unit/litellm_core_utils/llm_response_utils/test_get_api_base.py index 90fd5ba6c88..f0cf95e1285 100644 --- a/tests/unit/litellm_core_utils/llm_response_utils/test_get_api_base.py +++ b/tests/unit/litellm_core_utils/llm_response_utils/test_get_api_base.py @@ -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"), ], ) diff --git a/tests/unit/llms/github_copilot/embedding/test_github_copilot_embedding_transformation.py b/tests/unit/llms/github_copilot/embedding/test_github_copilot_embedding_transformation.py index 88fc8ba6e6a..062a077b5f6 100644 --- a/tests/unit/llms/github_copilot/embedding/test_github_copilot_embedding_transformation.py +++ b/tests/unit/llms/github_copilot/embedding/test_github_copilot_embedding_transformation.py @@ -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 == [] diff --git a/tests/unit/llms/github_copilot/messages/test_github_copilot_messages_transformation.py b/tests/unit/llms/github_copilot/messages/test_github_copilot_messages_transformation.py index 20ff65f11d9..b20c966a9eb 100644 --- a/tests/unit/llms/github_copilot/messages/test_github_copilot_messages_transformation.py +++ b/tests/unit/llms/github_copilot/messages/test_github_copilot_messages_transformation.py @@ -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(): diff --git a/tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py b/tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py index c3a62592039..4858c071fb1 100644 --- a/tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py +++ b/tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py @@ -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(): diff --git a/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py b/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py index 55fd02bc4dd..78c051cd637 100644 --- a/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py +++ b/tests/unit/llms/github_copilot/test_github_copilot_authenticator.py @@ -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 diff --git a/tests/unit/llms/github_copilot/test_github_copilot_transformation.py b/tests/unit/llms/github_copilot/test_github_copilot_transformation.py index 2dbfae07526..3b906dc8bfa 100644 --- a/tests/unit/llms/github_copilot/test_github_copilot_transformation.py +++ b/tests/unit/llms/github_copilot/test_github_copilot_transformation.py @@ -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}, }, ) diff --git a/tests/unit/llms/github_copilot/test_github_host.py b/tests/unit/llms/github_copilot/test_github_host.py index daf5bfaab80..bac33260df9 100644 --- a/tests/unit/llms/github_copilot/test_github_host.py +++ b/tests/unit/llms/github_copilot/test_github_host.py @@ -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" diff --git a/tests/unit/llms/github_copilot/test_per_user_auth.py b/tests/unit/llms/github_copilot/test_per_user_auth.py index 5274f0c1cb8..1aefcf2c534 100644 --- a/tests/unit/llms/github_copilot/test_per_user_auth.py +++ b/tests/unit/llms/github_copilot/test_per_user_auth.py @@ -5,6 +5,7 @@ import datetime import time from collections.abc import Callable from contextlib import contextmanager +from pathlib import Path from typing import Final from unittest.mock import AsyncMock, MagicMock, patch @@ -14,6 +15,7 @@ import pytest import litellm from litellm.constants import GITHUB_COPILOT_DEFAULT_HOST from litellm.exceptions import ( + APIConnectionError, AuthenticationError, BadRequestError, CallerCredentialAuthenticationError, @@ -21,30 +23,27 @@ from litellm.exceptions import ( ServiceUnavailableError, ) from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler +from litellm.llms.github_copilot.common_utils import InvalidAPIBaseError +from litellm.llms.github_copilot.github_host import github_host_endpoints from litellm.llms.github_copilot.per_user_auth import ( - _SESSION_CACHE, GITHUB_COPILOT_USER_SESSION_KWARG_KEY, GithubCopilotUserSession, - _session_cache_key, - aattach_github_copilot_user_session, - aexchange_github_token, + acheck_copilot_seat, + afetch_github_login, apoll_device_flow, astart_device_flow, attach_github_copilot_user_session, - evict_copilot_user_session, - exchange_github_token, - github_copilot_host_endpoints, github_copilot_auth_mode, github_copilot_user_session_from, - validated_copilot_api_base, ) -from litellm.llms.github_copilot.github_host import github_host_endpoints from litellm.types.utils import CredentialItem, ModelResponse -EXCHANGE_URL = "https://api.github.com/copilot_internal/v2/token" DEVICE_CODE_URL = "https://github.com/login/device/code" ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token" + + _GITHUB_ENDPOINTS: Final = github_host_endpoints("github.com") +_GHE_ENDPOINTS: Final = github_host_endpoints("octo.ghe.com") def _credential( @@ -62,22 +61,33 @@ def _credential( ) -def _exchange_payload(token="copilot-token", expires_at=None, api_base="https://api.githubcopilot.com"): - return { - "token": token, - "expires_at": expires_at if expires_at is not None else int(time.time()) + 3600, - "endpoints": {"api": api_base}, - } +COPILOT_USER_URL: Final = "https://api.github.com/copilot_internal/user" +DEFAULT_COPILOT_HOST: Final = "https://api.githubcopilot.com" +PLAN_COPILOT_HOST: Final = "https://api.business.githubcopilot.com" + + +def answer_plan(endpoints_api: object, status_code: int = 200) -> Callable[[httpx.Request], httpx.Response]: + return lambda request: httpx.Response(status_code, json={"endpoints": {"api": endpoints_api}}, request=request) @contextmanager -def github_http(respond: Callable[[httpx.Request], httpx.Response]): +def github_http( + respond: Callable[[httpx.Request], httpx.Response], + plan_lookup: Callable[[httpx.Request], httpx.Response] | None = None, + plan_lookups: list[httpx.Request] | None = None, +): """Swap both module-level HTTP clients for httpx MockTransport versions and collect every request, so tests assert on the real HTTP edge (URL, headers) - while no socket is ever opened.""" + while no socket is ever opened. GitHub's Copilot plan lookup is answered by + plan_lookup (the default host when unset) and collected into plan_lookups instead.""" requests: list[httpx.Request] = [] + answer_plan_lookup = plan_lookup or answer_plan(DEFAULT_COPILOT_HOST) def capture(request: httpx.Request) -> httpx.Response: + if request.url.path == "/copilot_internal/user": + if plan_lookups is not None: + plan_lookups.append(request) + return answer_plan_lookup(request) requests.append(request) return respond(request) @@ -88,26 +98,11 @@ def github_http(respond: Callable[[httpx.Request], httpx.Response]): patch("litellm.llms.custom_httpx.llm_http_handler.get_async_httpx_client", return_value=async_client), patch("litellm.llms.custom_httpx.llm_http_handler.get_httpx_client", return_value=sync_client), patch.object(litellm, "module_level_client", sync_client), + patch.object(litellm, "client_session", sync_client.client), ): yield requests -def _exchange_responder(payload=None, status=200): - def respond(request: httpx.Request) -> httpx.Response: - if str(request.url) == EXCHANGE_URL: - return httpx.Response(status, json=payload or _exchange_payload(), request=request) - pytest.fail(f"unexpected request to {request.url}") - - return respond - - -@pytest.fixture(autouse=True) -def _clear_session_cache(): - _SESSION_CACHE.flush_cache() - yield - _SESSION_CACHE.flush_cache() - - @pytest.fixture def per_user_credential(): with patch.object(litellm, "credential_list", [_credential()]): @@ -139,83 +134,8 @@ def test_unknown_named_credential_falls_back_to_kwargs(): assert github_copilot_auth_mode("missing-cred", None) is False -@pytest.mark.parametrize( - "raw,expected", - [ - ("https://api.githubcopilot.com", "https://api.githubcopilot.com"), - ("https://api.githubcopilot.com/", "https://api.githubcopilot.com"), - ("https://tenant.githubcopilot.com:443", "https://tenant.githubcopilot.com:443"), - ("githubcopilot.com", "https://api.githubcopilot.com"), # no scheme -> default - ("http://api.githubcopilot.com", "https://api.githubcopilot.com"), # http rejected - ("https://evil.com", "https://api.githubcopilot.com"), - ("https://githubcopilot.com.evil.com", "https://api.githubcopilot.com"), - ("https://user:pw@api.githubcopilot.com", "https://api.githubcopilot.com"), - ("https://api.githubcopilot.com:8080", "https://api.githubcopilot.com"), - (None, "https://api.githubcopilot.com"), - (1234, "https://api.githubcopilot.com"), - ("https://API.GITHUBCOPILOT.COM", "https://API.GITHUBCOPILOT.COM"), - ], -) -def test_validated_copilot_api_base(raw, expected): - assert validated_copilot_api_base(raw, github_host_endpoints("github.com")) == expected - - -@pytest.mark.parametrize( - "raw", - [ - "https://copilot-api.evil.ghe.com", - "https://api.githubcopilot.com", - "http://copilot-api.octo.ghe.com", - "https://user@copilot-api.octo.ghe.com", - "https://copilot-api.octo.ghe.com:8443", - ], -) -def test_ghe_exchange_response_must_stay_on_its_tenant_host(raw: object) -> None: - endpoints: Final = github_host_endpoints("octo.ghe.com") - assert validated_copilot_api_base(raw, endpoints) == "https://copilot-api.octo.ghe.com" - - -def test_github_com_exchange_rejects_a_ghe_copilot_host() -> None: - endpoints: Final = github_host_endpoints("github.com") - assert validated_copilot_api_base("https://copilot-api.octo.ghe.com", endpoints) == "https://api.githubcopilot.com" - - -def test_ghe_exchange_uses_stored_host_not_request_kwargs(): - credential: Final = CredentialItem( - credential_name="copilot-cred", - credential_values={"github_copilot_auth_type": "per_user_oauth", "github_copilot_host": "octo.ghe.com"}, - credential_info={}, - ) - - def respond(request: httpx.Request) -> httpx.Response: - assert str(request.url) == "https://api.octo.ghe.com/copilot_internal/v2/token" - return httpx.Response(200, json=_exchange_payload(api_base="https://copilot-api.octo.ghe.com"), request=request) - - with patch.object(litellm, "credential_list", [credential]), github_http(respond) as requests: - kwargs: Final = _kwargs_with_connection(github_host="octo.ghe.com") - kwargs["github_copilot_host"] = "evil.ghe.com" - attach_github_copilot_user_session(kwargs) - - session: Final = github_copilot_user_session_from(kwargs) - assert session is not None and session.api_base == "https://copilot-api.octo.ghe.com" - assert len(requests) == 1 - - -def test_invalid_stored_host_fails_before_exchange(): - credential: Final = CredentialItem( - credential_name="copilot-cred", - credential_values={"github_copilot_auth_type": "per_user_oauth", "github_copilot_host": "https://octo.ghe.com"}, - credential_info={}, - ) - with patch.object(litellm, "credential_list", [credential]): - with github_http(lambda request: pytest.fail(f"unexpected request to {request.url}")) as requests: - with pytest.raises(BadRequestError): - attach_github_copilot_user_session(_kwargs_with_connection()) - assert not requests - - def test_session_repr_and_str_hide_the_token(caplog): - session = GithubCopilotUserSession(token="secret-copilot-token", api_base="https://api.githubcopilot.com") + session = GithubCopilotUserSession(token="secret-copilot-token", endpoints=_GITHUB_ENDPOINTS) assert "secret-copilot-token" not in repr(session) assert "secret-copilot-token" not in str(session) with caplog.at_level("DEBUG"): @@ -225,153 +145,6 @@ def test_session_repr_and_str_hide_the_token(caplog): assert "secret-copilot-token" not in caplog.text -def test_exchange_returns_session_and_caches_per_user(): - with github_http(_exchange_responder()) as requests: - s1 = exchange_github_token("user-a", "gh-token", "copilot-cred", _GITHUB_ENDPOINTS) - assert len(requests) == 1 - assert requests[0].headers["Authorization"] == "token gh-token" - s2 = exchange_github_token("user-a", "gh-token", "copilot-cred", _GITHUB_ENDPOINTS) - assert len(requests) == 1 - assert s2.token == s1.token == "copilot-token" - # different user -> separate exchange - exchange_github_token("user-b", "gh-token", "copilot-cred", _GITHUB_ENDPOINTS) - assert len(requests) == 2 - # different github token -> separate exchange - exchange_github_token("user-a", "gh-token-2", "copilot-cred", _GITHUB_ENDPOINTS) - assert len(requests) == 3 - - -def test_cached_session_is_not_reused_after_github_token_changes(): - with github_http(_exchange_responder()) as requests: - exchange_github_token("user-a", "gh-token-a", "copilot-cred", _GITHUB_ENDPOINTS) - exchange_github_token("user-a", "gh-token-b", "copilot-cred", _GITHUB_ENDPOINTS) - - assert [request.headers["Authorization"] for request in requests] == ["token gh-token-a", "token gh-token-b"] - - -@pytest.mark.parametrize("status", [401, 403, 404]) -def test_exchange_auth_failures_raise_caller_auth_error_and_evict(status): - with github_http(_exchange_responder(status=status)): - with pytest.raises(CallerCredentialAuthenticationError) as exc: - exchange_github_token("user-a", "gh-token", "copilot-cred", _GITHUB_ENDPOINTS) - assert "Reconnect GitHub Copilot for credential 'copilot-cred' in the LiteLLM UI (LLM Credentials)" in str( - exc.value - ) - assert ( - _SESSION_CACHE.get_cache(_session_cache_key("user-a", "copilot-cred", GITHUB_COPILOT_DEFAULT_HOST)) is None - ) - - -def test_exchange_429_raises_caller_rate_limit(): - with github_http(_exchange_responder(status=429)): - with pytest.raises(CallerCredentialRateLimitError): - exchange_github_token("user-a", "gh-token", "copilot-cred", _GITHUB_ENDPOINTS) - - -def test_exchange_other_failure_raises_service_unavailable_without_body(): - with github_http(_exchange_responder(status=500, payload={"secret": "body"})): - with pytest.raises(ServiceUnavailableError) as exc: - exchange_github_token("user-a", "gh-token", "copilot-cred", _GITHUB_ENDPOINTS) - assert "body" not in str(exc.value) - - -def test_exchange_expiry_margin_refetches_near_expiry(): - with github_http(_exchange_responder(_exchange_payload(expires_at=int(time.time()) + 30))) as requests: - exchange_github_token("user-a", "gh-token", "copilot-cred", _GITHUB_ENDPOINTS) - # ttl under the 60s safety margin -> not cached, second call refetches - exchange_github_token("user-a", "gh-token", "copilot-cred", _GITHUB_ENDPOINTS) - assert len(requests) == 2 - - -@pytest.mark.asyncio -async def test_aexchange_single_flight_one_http_call(): - def respond(request: httpx.Request) -> httpx.Response: - return httpx.Response(200, json=_exchange_payload(), request=request) - - with github_http(respond) as requests: - s1, s2 = await asyncio.gather( - aexchange_github_token("user-a", "gh-token", "copilot-cred", _GITHUB_ENDPOINTS), - aexchange_github_token("user-a", "gh-token", "copilot-cred", _GITHUB_ENDPOINTS), - ) - assert len(requests) == 1 - assert s1.token == s2.token == "copilot-token" - - -@pytest.mark.asyncio -async def test_aexchange_401_raises_and_evicts(): - with github_http(_exchange_responder(status=401)): - with pytest.raises(CallerCredentialAuthenticationError): - await aexchange_github_token("user-a", "gh-token", "copilot-cred", _GITHUB_ENDPOINTS) - assert ( - _SESSION_CACHE.get_cache(_session_cache_key("user-a", "copilot-cred", GITHUB_COPILOT_DEFAULT_HOST)) is None - ) - - -ACCOUNT_URL = "https://api.github.com/copilot_internal/user" -_CUSTOM_CLIENT_ID: Final = "Ov23liCustomOrgApp" -_ACCOUNT_API_BASE: Final = "https://api.business.githubcopilot.com" - - -def _account_payload(access_type_sku="copilot_enterprise_seat", chat_enabled=True, api_base=_ACCOUNT_API_BASE): - return {"access_type_sku": access_type_sku, "chat_enabled": chat_enabled, "endpoints": {"api": api_base}} - - -def _account_responder(payload=None, status=200, model_payload=None): - def respond(request: httpx.Request) -> httpx.Response: - if str(request.url) == ACCOUNT_URL: - return httpx.Response(status, json=payload or _account_payload(), request=request) - if model_payload is not None and request.url.host.endswith("githubcopilot.com"): - return httpx.Response(200, json=model_payload, request=request) - pytest.fail(f"unexpected request to {request.url}") - - return respond - - -@pytest.fixture -def custom_client_id(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setenv("GITHUB_COPILOT_CLIENT_ID", _CUSTOM_CLIENT_ID) - - -@pytest.mark.asyncio -async def test_custom_client_id_uses_the_github_token_without_the_copilot_token_swap(custom_client_id): - endpoints: Final = github_host_endpoints("github.com") - with github_http(_account_responder()) as requests: - first = await aexchange_github_token("user-a", "gho_org_app", "copilot-cred", endpoints) - second = await aexchange_github_token("user-a", "gho_org_app", "copilot-cred", endpoints) - - assert [str(r.url) for r in requests] == [ACCOUNT_URL] - assert requests[0].headers["Authorization"] == "token gho_org_app" - assert first.token == second.token == "gho_org_app" - assert first.api_base == _ACCOUNT_API_BASE - - -@pytest.mark.parametrize( - "payload", - [ - _account_payload(access_type_sku="no_access", chat_enabled=False), - _account_payload(chat_enabled=False), - _account_payload(access_type_sku="no_access"), - ], -) -def test_custom_client_id_without_a_copilot_seat_raises_reconnect(custom_client_id, payload): - with github_http(_account_responder(payload)): - with pytest.raises(CallerCredentialAuthenticationError): - exchange_github_token("user-a", "gho_org_app", "copilot-cred", github_host_endpoints("github.com")) - - -@pytest.mark.parametrize("status", [401, 403, 404]) -def test_custom_client_id_rejected_account_lookup_raises_reconnect(custom_client_id, status): - with github_http(_account_responder(status=status)): - with pytest.raises(CallerCredentialAuthenticationError): - exchange_github_token("user-a", "gho_org_app", "copilot-cred", github_host_endpoints("github.com")) - - -def test_custom_client_id_account_api_base_must_stay_on_copilot_hosts(custom_client_id): - with github_http(_account_responder(_account_payload(api_base="https://evil.example"))): - session = exchange_github_token("user-a", "gho_org_app", "copilot-cred", github_host_endpoints("github.com")) - assert session.api_base == "https://api.githubcopilot.com" - - def _kwargs_with_connection( token: str = "gho_user_token", github_host: str | None = "github.com", @@ -390,21 +163,114 @@ def _kwargs_with_connection( } -@pytest.mark.asyncio -async def test_aattach_injects_session_into_kwargs(per_user_credential): - with github_http(_exchange_responder()): - kwargs = _kwargs_with_connection() - await aattach_github_copilot_user_session(kwargs) - session = github_copilot_user_session_from(kwargs) - assert session is not None - assert session.token == "copilot-token" - - -def test_attach_sync_injects_session(per_user_credential): - with github_http(_exchange_responder()): - kwargs = _kwargs_with_connection() +def test_attach_uses_stored_github_token_without_network(per_user_credential): + kwargs = _kwargs_with_connection() + with github_http(lambda request: pytest.fail(f"unexpected request to {request.url}")) as requests: attach_github_copilot_user_session(kwargs) - assert isinstance(kwargs[GITHUB_COPILOT_USER_SESSION_KWARG_KEY], GithubCopilotUserSession) + assert github_copilot_user_session_from(kwargs).token == "gho_user_token" + assert requests == [] + + +@pytest.mark.parametrize("header_location", ["headers", "extra_headers", "optional_params", "litellm_params"]) +def test_attach_strips_authorization_without_mutating_caller_headers(per_user_credential, header_location): + headers = {"Authorization": "Bearer caller", "authorization": "Bearer other", "x-custom": "keep"} + kwargs = { + **_kwargs_with_connection(), + header_location: {"extra_headers": headers} + if header_location in ("optional_params", "litellm_params") + else headers, + } + attach_github_copilot_user_session(kwargs) + actual = kwargs[header_location] + if header_location in ("optional_params", "litellm_params"): + actual = actual["extra_headers"] + assert actual == {"x-custom": "keep"} + assert headers == {"Authorization": "Bearer caller", "authorization": "Bearer other", "x-custom": "keep"} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("api_base", ["http://localhost:8080/", "https://configured.example.com/"]) +async def test_seat_check_sends_stored_token_to_configured_models_endpoint(monkeypatch, api_base): + monkeypatch.setenv("GITHUB_COPILOT_API_BASE", api_base) + monkeypatch.setenv("GITHUB_COPILOT_USER_AGENT", "test-agent") + with github_http(lambda request: httpx.Response(204, request=request)) as requests: + await acheck_copilot_seat("stored-github-token", "copilot-cred", github_host_endpoints("github.com")) + assert len(requests) == 1 + assert str(requests[0].url) == f"{api_base.rstrip('/')}/models" + assert requests[0].headers["authorization"] == "Bearer stored-github-token" + assert requests[0].headers["user-agent"] == "test-agent" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "status,error_type,message", + [ + (401, CallerCredentialAuthenticationError, "GitHub rejected the GitHub Copilot connection"), + (403, CallerCredentialAuthenticationError, "GitHub rejected the GitHub Copilot connection"), + (404, CallerCredentialAuthenticationError, "GitHub rejected the GitHub Copilot connection"), + (429, CallerCredentialRateLimitError, "rate limited the seat check"), + (302, ServiceUnavailableError, "seat check failed with status 302"), + (500, ServiceUnavailableError, "seat check failed with status 500"), + ], +) +async def test_seat_check_maps_upstream_failures(status, error_type, message): + with github_http(lambda request: httpx.Response(status, json={"secret": "private-body"}, request=request)): + with pytest.raises(error_type, match=message) as exc: + await acheck_copilot_seat("stored-github-token", "copilot-cred", _GITHUB_ENDPOINTS) + assert "private-body" not in str(exc.value) + assert "stored-github-token" not in str(exc.value) + if error_type is CallerCredentialAuthenticationError: + assert "Reconnect GitHub Copilot for credential 'copilot-cred'" in str(exc.value) + + +@pytest.mark.asyncio +async def test_seat_check_transport_failure_raises_connection_error(): + def respond(request): + raise httpx.ReadError("private transport detail", request=request) + + with github_http(respond): + with pytest.raises(APIConnectionError, match="seat check request failed") as exc: + await acheck_copilot_seat("stored-github-token", "copilot-cred", _GITHUB_ENDPOINTS) + assert "private transport detail" not in str(exc.value) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "env_name,env_value,message", + [ + ( + "GITHUB_COPILOT_API_KEY_URL", + "https://api.octo.ghe.com/copilot_internal/v2/token", + "Set GITHUB_COPILOT_API_BASE", + ), + ("GITHUB_COPILOT_API_BASE", "ftp://copilot.example.com", "must be an HTTP or HTTPS URL"), + ], +) +async def test_seat_check_surfaces_the_copilot_api_base_configuration_error(monkeypatch, env_name, env_value, message): + monkeypatch.setenv(env_name, env_value) + with ( + github_http( + lambda request: pytest.fail(f"unexpected request to {request.url}"), answer_plan(PLAN_COPILOT_HOST) + ), + pytest.raises(InvalidAPIBaseError, match=message), + ): + await acheck_copilot_seat("stored-github-token", "copilot-cred", github_host_endpoints("github.com")) + + +@pytest.mark.asyncio +async def test_github_login_uses_configured_auth_headers(monkeypatch): + monkeypatch.setenv("GITHUB_COPILOT_USER_AGENT", "test-agent") + monkeypatch.setenv("GITHUB_COPILOT_INTEGRATION_ID", "") + with github_http( + lambda request: httpx.Response(200, json={"login": "connected-user"}, request=request) + ) as requests: + login = await afetch_github_login("stored-github-token", _GITHUB_ENDPOINTS) + assert login == "connected-user" + assert len(requests) == 1 + assert str(requests[0].url) == "https://api.github.com/user" + assert requests[0].headers["authorization"] == "token stored-github-token" + assert requests[0].headers["user-agent"] == "test-agent" + assert "copilot-integration-id" not in requests[0].headers def test_attach_per_user_without_connection_raises_connect_401(per_user_credential): @@ -418,13 +284,10 @@ def test_attach_per_user_without_connection_raises_connect_401(per_user_credenti ("credential_host", "connection_host"), [("github.com", "octo.ghe.com"), ("octo.ghe.com", "github.com")], ) -def test_attach_rejects_connection_from_a_different_github_host( - credential_host: str, - connection_host: str, -): +def test_attach_rejects_connection_from_a_different_github_host(credential_host: str, connection_host: str): with patch.object(litellm, "credential_list", [_credential(github_host=credential_host)]): kwargs: Final = _kwargs_with_connection(github_host=connection_host) - with github_http(lambda request: pytest.fail(f"unexpected token exchange at {request.url}")) as requests: + with github_http(lambda request: pytest.fail(f"unexpected request to {request.url}")) as requests: with pytest.raises(CallerCredentialAuthenticationError) as exc: attach_github_copilot_user_session(kwargs) assert "Connect GitHub Copilot for credential 'copilot-cred'" in str(exc.value) @@ -432,11 +295,30 @@ def test_attach_rejects_connection_from_a_different_github_host( def test_legacy_connection_without_host_uses_github_com(per_user_credential): - with github_http(_exchange_responder()) as requests: - kwargs: Final = _kwargs_with_connection(github_host=None) + kwargs: Final = _kwargs_with_connection(github_host=None) + with github_http(lambda request: pytest.fail(f"unexpected request to {request.url}")) as requests: attach_github_copilot_user_session(kwargs) - assert len(requests) == 1 - assert str(requests[0].url) == EXCHANGE_URL + session: Final = github_copilot_user_session_from(kwargs) + assert session is not None and session.endpoints.host == GITHUB_COPILOT_DEFAULT_HOST + assert not requests + + +def test_ghe_session_uses_stored_host_not_request_kwargs(): + with patch.object(litellm, "credential_list", [_credential(github_host="octo.ghe.com")]): + kwargs: Final = {**_kwargs_with_connection(github_host="octo.ghe.com"), "github_copilot_host": "evil.ghe.com"} + attach_github_copilot_user_session(kwargs) + session: Final = github_copilot_user_session_from(kwargs) + assert session is not None and session.endpoints.host == "octo.ghe.com" + + +def test_invalid_stored_host_fails_before_any_request(): + with ( + patch.object(litellm, "credential_list", [_credential(github_host="https://octo.ghe.com")]), + github_http(lambda request: pytest.fail(f"unexpected request to {request.url}")) as requests, + pytest.raises(BadRequestError), + ): + attach_github_copilot_user_session(_kwargs_with_connection()) + assert not requests def test_attach_shared_mode_does_nothing_without_connection(): @@ -446,14 +328,6 @@ def test_attach_shared_mode_does_nothing_without_connection(): assert GITHUB_COPILOT_USER_SESSION_KWARG_KEY not in kwargs -def test_evict_drops_cached_session(): - with github_http(_exchange_responder()) as requests: - exchange_github_token("user-a", "gh-token", "copilot-cred", _GITHUB_ENDPOINTS) - evict_copilot_user_session("user-a", "copilot-cred", _GITHUB_ENDPOINTS.host) - exchange_github_token("user-a", "gh-token", "copilot-cred", _GITHUB_ENDPOINTS) - assert len(requests) == 2 - - @pytest.mark.asyncio async def test_astart_device_flow_shape(): def respond(request: httpx.Request) -> httpx.Response: @@ -515,7 +389,7 @@ async def test_apoll_device_flow_connected(): def test_is_github_copilot_per_user_request(): from litellm.llms.github_copilot.per_user_auth import is_github_copilot_per_user_request - session = GithubCopilotUserSession(token="t", api_base="https://api.githubcopilot.com") + session = GithubCopilotUserSession(token="t", endpoints=_GITHUB_ENDPOINTS) assert is_github_copilot_per_user_request({GITHUB_COPILOT_USER_SESSION_KWARG_KEY: session}) is True assert is_github_copilot_per_user_request({}) is False assert is_github_copilot_per_user_request({GITHUB_COPILOT_USER_SESSION_KWARG_KEY: "not-a-session"}) is False @@ -538,13 +412,13 @@ def _chat_completion_response(): ) -def _secret_fields(user_id: str, github_token: str): +def _secret_fields(user_id: str, github_token: str, github_host: str = GITHUB_COPILOT_DEFAULT_HOST): return { "user_provider_credentials_user_id": user_id, "user_provider_credentials": { "copilot-cred": { "access_token": github_token, - "github_host": GITHUB_COPILOT_DEFAULT_HOST, + "github_host": github_host, } }, } @@ -559,7 +433,7 @@ def test_two_per_user_calls_with_identical_bodies_both_reach_upstream( # test-q monkeypatch.setattr(litellm, "cache", Cache()) with ( - github_http(_exchange_responder()), + github_http(lambda request: pytest.fail(f"unexpected request to {request.url}")), patch( "litellm.main.openai_chat_completions.completion", return_value=_chat_completion_response(), @@ -584,7 +458,7 @@ async def test_two_per_user_async_calls_with_identical_bodies_both_reach_upstrea monkeypatch.setattr(litellm, "cache", Cache()) with ( - github_http(_exchange_responder()), + github_http(lambda request: pytest.fail(f"unexpected request to {request.url}")), patch( "litellm.main.openai_chat_completions.acompletion", new=AsyncMock(return_value=_chat_completion_response()), @@ -605,27 +479,19 @@ def test_shared_mode_second_identical_call_hits_response_cache( # test-quality- ): """Control: shared device-login github_copilot keeps caching exactly as before, so a second identical call does not touch the upstream.""" - import json - from litellm.caching import Cache - # a mounted shared login: api-key.json with a far-future token, no device flow needed - (tmp_path / "api-key.json").write_text( - json.dumps( - { - "token": "shared-copilot-token", - "expires_at": 4102444800, - "endpoints": {"api": "https://api.githubcopilot.com"}, - } - ) - ) + (tmp_path / "access-token").write_text("shared-copilot-token") monkeypatch.setenv("GITHUB_COPILOT_TOKEN_DIR", str(tmp_path)) monkeypatch.setattr(litellm, "cache", Cache()) - with patch( - "litellm.main.openai_chat_completions.completion", - return_value=_chat_completion_response(), - ) as mock_completion: + with ( + github_http(lambda request: pytest.fail(f"unexpected request to {request.url}")), + patch( + "litellm.main.openai_chat_completions.completion", + return_value=_chat_completion_response(), + ) as mock_completion, + ): call = { "model": "github_copilot/gpt-4o", "messages": [{"role": "user", "content": "hi"}], @@ -679,9 +545,12 @@ def _anthropic_messages_payload(): def _copilot_surface_responder(model_payload): def respond(request: httpx.Request) -> httpx.Response: - if str(request.url) == EXCHANGE_URL: - return httpx.Response(200, json=_exchange_payload(), request=request) - if request.url.host.endswith("githubcopilot.com"): + if request.url.host in ( + "api.githubcopilot.com", + "api.business.githubcopilot.com", + "configured.example.com", + "deployment.example.com", + ): return httpx.Response(200, json=model_payload, request=request) pytest.fail(f"unexpected request to {request.url}") @@ -696,19 +565,174 @@ def _no_authenticator(): ) -def _assert_exchange_then_model_call(requests, github_token, model_host="https://api.githubcopilot.com"): - exchange = [r for r in requests if str(r.url) == EXCHANGE_URL] - upstream = [r for r in requests if r.url.host.endswith("githubcopilot.com")] - assert exchange, "expected the GitHub token exchange request" - assert upstream, "expected the model request to the Copilot host" - assert exchange[0].headers["Authorization"] == f"token {github_token}" - assert upstream[0].headers["Authorization"].startswith("Bearer ") - assert upstream[0].url.host == httpx.URL(model_host).host +def _assert_direct_model_call(requests, github_token, model_host="https://api.githubcopilot.com"): + assert len(requests) == 1 + assert requests[0].headers["Authorization"] == f"Bearer {github_token}" + assert requests[0].url.host == httpx.URL(model_host).host + + +_SURFACE_MODELS: Final = { + "completion": "github_copilot/gpt-4o", + "embedding": "github_copilot/text-embedding-3-small", + "responses": "github_copilot/gpt-5.3-codex", +} +_SURFACE_PATHS: Final = {"completion": "chat/completions", "embedding": "embeddings", "responses": "responses"} + + +def _surface_payload(surface): + return { + "completion": _chat_completion_response().model_dump, + "embedding": _embedding_response_payload, + "responses": _responses_payload, + }[surface]() + + +def _call_surface(target, surface, **kwargs): + if surface == "completion": + return target.completion(messages=[{"role": "user", "content": "hi"}], **kwargs) + if surface == "embedding": + return target.embedding(input=["hi"], **kwargs) + return target.responses(input="hi", **kwargs) + + +@pytest.mark.parametrize("surface", ["completion", "embedding", "responses"]) +@pytest.mark.parametrize("deployment_base", [None, "https://deployment.example.com/v1"]) +def test_per_user_request_goes_to_the_configured_base_never_the_deployment_base( + per_user_credential, monkeypatch, surface, deployment_base +): + monkeypatch.setenv("GITHUB_COPILOT_API_BASE", "https://configured.example.com/v1") + lookups: list[httpx.Request] = [] + with ( + github_http( + _copilot_surface_responder(_surface_payload(surface)), answer_plan(PLAN_COPILOT_HOST), lookups + ) as requests, + _no_authenticator(), + ): + _call_surface( + litellm, + surface, + model=_SURFACE_MODELS[surface], + litellm_credential_name="copilot-cred", + secret_fields=_secret_fields("user-a", "stored-github-token"), + extra_headers=dict(_EVIL_AUTH_HEADERS), + api_base=deployment_base, + ) + assert [str(request.url) for request in requests] == [f"https://configured.example.com/v1/{_SURFACE_PATHS[surface]}"] + assert requests[0].headers["authorization"] == "Bearer stored-github-token" + assert lookups == [] + + +def _copilot_router(surface, mode, default_litellm_params=None, **deployment_params): + return litellm.Router( + default_litellm_params=default_litellm_params, + model_list=[ + { + "model_name": "copilot", + "litellm_params": { + "model": _SURFACE_MODELS[surface], + "api_base": "https://deployment.example.com/v1", + **({"litellm_credential_name": "copilot-cred"} if mode == "per_user" else {}), + **deployment_params, + }, + } + ] + ) + + +def _call_with_caller_endpoint(router, surface, mode): + return _call_surface( + router, + surface, + model="copilot", + api_base="https://caller.example.com/v1", + base_url="https://caller.example.com/v1", + **({"secret_fields": _secret_fields("user-a", "stored-github-token")} if mode == "per_user" else {}), + ) + + +@pytest.mark.parametrize("surface", ["completion", "embedding", "responses"]) +@pytest.mark.parametrize("mode", ["shared", "per_user"]) +def test_caller_endpoint_on_a_copilot_deployment_answers_400(per_user_credential, tmp_path, monkeypatch, surface, mode): + _shared_login(tmp_path, monkeypatch) + router = _copilot_router(surface, mode) + with ( + github_http(_copilot_surface_responder(_surface_payload(surface))) as requests, + pytest.raises(BadRequestError, match="do not accept api_base or base_url from the caller"), + ): + _call_with_caller_endpoint(router, surface, mode) + assert requests == [] + + +@pytest.mark.parametrize("surface", ["completion", "embedding", "responses"]) +@pytest.mark.parametrize("mode", ["shared", "per_user"]) +@pytest.mark.parametrize( + "router_settings", + [{"drop_params": True}, {"default_litellm_params": {"drop_params": True}}], + ids=["deployment_drop_params", "router_default_drop_params"], +) +def test_caller_endpoint_on_a_copilot_deployment_is_dropped_under_drop_params( + per_user_credential, tmp_path, monkeypatch, surface, mode, router_settings +): + _shared_login(tmp_path, monkeypatch) + router = _copilot_router(surface, mode, **router_settings) + with github_http(_copilot_surface_responder(_surface_payload(surface)), answer_plan(PLAN_COPILOT_HOST)) as requests: + _call_with_caller_endpoint(router, surface, mode) + expected_base = PLAN_COPILOT_HOST if mode == "per_user" else "https://deployment.example.com/v1" + assert [str(request.url) for request in requests] == [f"{expected_base}/{_SURFACE_PATHS[surface]}"] + expected_token = "stored-github-token" if mode == "per_user" else "shared-copilot-token" + assert requests[0].headers["authorization"] == f"Bearer {expected_token}" + + +def _unauthorized(request: httpx.Request) -> httpx.Response: + return httpx.Response(401, json={"error": {"message": "Unauthorized"}}, request=request) + + +_RECONNECT_MESSAGE: Final = "Reconnect GitHub Copilot for credential 'copilot-cred'" + + +@pytest.mark.parametrize("surface", ["completion", "embedding", "responses"]) +def test_per_user_token_github_rejects_asks_that_caller_to_reconnect(per_user_credential, surface): + with ( + github_http(_unauthorized), + _no_authenticator(), + pytest.raises(CallerCredentialAuthenticationError, match=_RECONNECT_MESSAGE), + ): + _call_surface( + litellm, + surface, + model=_SURFACE_MODELS[surface], + litellm_credential_name="copilot-cred", + secret_fields=_secret_fields("user-a", "revoked-github-token"), + ) + + +@pytest.mark.asyncio +async def test_per_user_token_github_rejects_on_messages_asks_that_caller_to_reconnect(per_user_credential): + with ( + github_http(_unauthorized), + _no_authenticator(), + pytest.raises(CallerCredentialAuthenticationError, match=_RECONNECT_MESSAGE), + ): + await litellm.anthropic.messages.acreate( + model="github_copilot/claude-haiku-4.5", + max_tokens=16, + messages=[{"role": "user", "content": "hi"}], + litellm_credential_name="copilot-cred", + secret_fields=_secret_fields("user-a", "revoked-github-token"), + ) + + +@pytest.mark.parametrize("surface", ["completion", "embedding", "responses"]) +def test_shared_token_github_rejects_stays_a_deployment_authentication_error(tmp_path, monkeypatch, surface): + _shared_login(tmp_path, monkeypatch) + with github_http(_unauthorized), pytest.raises(AuthenticationError) as excinfo: + _call_surface(litellm, surface, model=_SURFACE_MODELS[surface]) + assert not isinstance(excinfo.value, CallerCredentialAuthenticationError) def test_e2e_completion_per_user_credential(per_user_credential): with ( - github_http(_exchange_responder()) as requests, + github_http(lambda request: pytest.fail(f"unexpected request to {request.url}")) as requests, _no_authenticator(), patch( "litellm.main.openai_chat_completions.completion", @@ -721,13 +745,12 @@ def test_e2e_completion_per_user_credential(per_user_credential): litellm_credential_name="copilot-cred", secret_fields=_secret_fields("user-a", "gho_a"), ) - assert len(requests) == 1 and str(requests[0].url) == EXCHANGE_URL - assert requests[0].headers["Authorization"] == "token gho_a" + assert requests == [] call_kwargs = mock_completion.call_args.kwargs - assert call_kwargs["api_key"] == "copilot-token" + assert call_kwargs["api_key"] == "gho_a" assert call_kwargs["api_base"] == "https://api.githubcopilot.com" extra_headers = call_kwargs["optional_params"]["extra_headers"] - assert extra_headers["Authorization"] == "Bearer copilot-token" + assert extra_headers["Authorization"] == "Bearer gho_a" assert response.usage.total_tokens > 0 @@ -735,7 +758,7 @@ def test_e2e_completion_per_user_session_token_wins_over_caller_authorization(pe """The session token reaches the wire even when the caller passes their own Authorization in extra_headers (chat dispatches through _complete_custom_openai).""" with ( - github_http(_exchange_responder()), + github_http(lambda request: pytest.fail(f"unexpected request to {request.url}")), _no_authenticator(), patch( "litellm.main.openai_chat_completions.completion", @@ -750,7 +773,7 @@ def test_e2e_completion_per_user_session_token_wins_over_caller_authorization(pe extra_headers={"Authorization": "Bearer caller-token", "x-custom": "keep"}, ) extra_headers = mock_completion.call_args.kwargs["optional_params"]["extra_headers"] - assert extra_headers["Authorization"] == "Bearer copilot-token" + assert extra_headers["Authorization"] == "Bearer gho_a" assert extra_headers["x-custom"] == "keep" @@ -758,9 +781,8 @@ _EVIL_AUTH_HEADERS = {"authorization": "Bearer caller-evil", "x-keep": "1"} def _assert_session_token_on_the_wire(requests): - upstream = [r for r in requests if r.url.host.endswith("githubcopilot.com")] - assert upstream, "expected the model request to the Copilot host" - assert upstream[0].headers["authorization"] == "Bearer copilot-token" + assert len(requests) == 1 + assert requests[0].headers["authorization"] == "Bearer gho_a" def test_e2e_completion_per_user_caller_authorization_cannot_override_session(per_user_credential): @@ -769,7 +791,7 @@ def test_e2e_completion_per_user_caller_authorization_cannot_override_session(pe handed to openai_chat_completions.completion.""" caller_headers: dict[str, str] = dict(_EVIL_AUTH_HEADERS) with ( - github_http(_exchange_responder()), + github_http(lambda request: pytest.fail(f"unexpected request to {request.url}")), _no_authenticator(), patch( "litellm.main.openai_chat_completions.completion", @@ -784,7 +806,7 @@ def test_e2e_completion_per_user_caller_authorization_cannot_override_session(pe extra_headers=caller_headers, ) extra_headers = mock_completion.call_args.kwargs["optional_params"]["extra_headers"] - assert extra_headers["Authorization"] == "Bearer copilot-token" + assert extra_headers["Authorization"] == "Bearer gho_a" assert "authorization" not in extra_headers assert extra_headers["x-keep"] == "1" assert caller_headers == dict(_EVIL_AUTH_HEADERS), "the caller's dict must not be mutated" @@ -805,11 +827,13 @@ def test_e2e_embedding_per_user_caller_authorization_cannot_override_session(per _assert_session_token_on_the_wire(requests) -def test_e2e_custom_client_id_sends_the_github_token_to_the_accounts_copilot_host( - per_user_credential, custom_client_id -): +def test_e2e_custom_client_id_sends_the_github_token_to_the_accounts_copilot_host(per_user_credential, monkeypatch): + monkeypatch.setenv("GITHUB_COPILOT_CLIENT_ID", "Ov23liCustomOrgApp") + lookups: Final[list[httpx.Request]] = [] with ( - github_http(_account_responder(model_payload=_embedding_response_payload())) as requests, + github_http( + _copilot_surface_responder(_embedding_response_payload()), answer_plan(PLAN_COPILOT_HOST), lookups + ) as requests, _no_authenticator(), ): litellm.embedding( @@ -818,10 +842,8 @@ def test_e2e_custom_client_id_sends_the_github_token_to_the_accounts_copilot_hos litellm_credential_name="copilot-cred", secret_fields=_secret_fields("user-a", "gho_org_app"), ) - assert EXCHANGE_URL not in [str(r.url) for r in requests] - upstream = [r for r in requests if r.url.host.endswith("githubcopilot.com")] - assert upstream[0].url.host == httpx.URL(_ACCOUNT_API_BASE).host - assert upstream[0].headers["authorization"] == "Bearer gho_org_app" + assert [request.headers["authorization"] for request in lookups] == ["token gho_org_app"] + _assert_direct_model_call(requests, "gho_org_app", PLAN_COPILOT_HOST) def test_e2e_responses_per_user_caller_authorization_cannot_override_session(per_user_credential): @@ -869,7 +891,7 @@ def test_e2e_embedding_per_user_credential(per_user_credential): litellm_credential_name="copilot-cred", secret_fields=_secret_fields("user-a", "gho_a"), ) - _assert_exchange_then_model_call(requests, "gho_a") + _assert_direct_model_call(requests, "gho_a") assert requests[-1].url.path == "/embeddings" assert response.usage.prompt_tokens == 4 @@ -885,7 +907,7 @@ def test_e2e_responses_per_user_credential(per_user_credential): litellm_credential_name="copilot-cred", secret_fields=_secret_fields("user-a", "gho_a"), ) - _assert_exchange_then_model_call(requests, "gho_a") + _assert_direct_model_call(requests, "gho_a") assert requests[-1].url.path == "/responses" assert response.usage.total_tokens == 6 @@ -903,7 +925,7 @@ async def test_e2e_anthropic_messages_per_user_credential(per_user_credential): litellm_credential_name="copilot-cred", secret_fields=_secret_fields("user-a", "gho_a"), ) - _assert_exchange_then_model_call(requests, "gho_a") + _assert_direct_model_call(requests, "gho_a") assert requests[-1].url.path == "/v1/messages" assert response["usage"]["input_tokens"] == 4 @@ -954,26 +976,19 @@ async def test_e2e_anthropic_messages_not_connected_raises_connect_401(per_user_ def _shared_login(tmp_path, monkeypatch): - import json - - (tmp_path / "api-key.json").write_text( - json.dumps( - { - "token": "shared-copilot-token", - "expires_at": 4102444800, - "endpoints": {"api": "https://api.githubcopilot.com"}, - } - ) - ) + (tmp_path / "access-token").write_text("shared-copilot-token") monkeypatch.setenv("GITHUB_COPILOT_TOKEN_DIR", str(tmp_path)) def test_e2e_completion_shared_mode_still_uses_authenticator(tmp_path, monkeypatch): _shared_login(tmp_path, monkeypatch) - with patch( - "litellm.main.openai_chat_completions.completion", - return_value=_chat_completion_response(), - ) as mock_completion: + with ( + github_http(lambda request: pytest.fail(f"unexpected request to {request.url}")), + patch( + "litellm.main.openai_chat_completions.completion", + return_value=_chat_completion_response(), + ) as mock_completion, + ): response = litellm.completion( model="github_copilot/gpt-4o", messages=[{"role": "user", "content": "hi"}], @@ -982,6 +997,32 @@ def test_e2e_completion_shared_mode_still_uses_authenticator(tmp_path, monkeypat assert response.usage.total_tokens > 0 +def test_completion_sends_each_copilot_client_header_once_with_its_configured_value(tmp_path, monkeypatch): + from openai import OpenAI + + _shared_login(tmp_path, monkeypatch) + monkeypatch.setenv("GITHUB_COPILOT_USER_AGENT", "opencode/1.18.7") + sent: Final[list[httpx.Request]] = [] + + def capture(request: httpx.Request) -> httpx.Response: + sent.append(request) + return httpx.Response(200, json=_chat_completion_response().model_dump(), request=request) + + sdk_client: Final = OpenAI(api_key="unused", http_client=httpx.Client(transport=httpx.MockTransport(capture))) + with github_http(lambda request: pytest.fail(f"unexpected request to {request.url}")): + litellm.completion( + model="github_copilot/gpt-4o", + messages=[{"role": "user", "content": "hi"}], + client=sdk_client, + ) + assert len(sent) == 1 + assert [sent[0].headers.get_list(name) for name in ("accept", "content-type", "user-agent")] == [ + ["application/json"], + ["application/json"], + ["opencode/1.18.7"], + ] + + def test_e2e_embedding_shared_mode_still_uses_authenticator(tmp_path, monkeypatch): _shared_login(tmp_path, monkeypatch) with github_http(_copilot_surface_responder(_embedding_response_payload())) as requests: @@ -1008,9 +1049,6 @@ def test_e2e_responses_shared_mode_still_uses_authenticator(tmp_path, monkeypatc @pytest.mark.asyncio async def test_e2e_anthropic_messages_shared_mode_still_uses_authenticator(tmp_path, monkeypatch): - # The shared Authenticator's access-token refresh refuses to run inside an event - # loop, so on this async surface the mounted-login path can't reach it; patch the - # shared-login edge to emulate a mounted api-key.json. from litellm.llms.github_copilot.authenticator import Authenticator with ( @@ -1018,9 +1056,6 @@ async def test_e2e_anthropic_messages_shared_mode_still_uses_authenticator(tmp_p patch.object( # test-quality-ok: patching the shared-login edge is the fixture under test Authenticator, "get_api_key", return_value="shared-copilot-token" ) as mock_api_key, - patch.object( # test-quality-ok: same edge; api_key read is mocked so base must be too - Authenticator, "get_api_base", return_value="https://api.githubcopilot.com" - ), ): response = await litellm.anthropic.messages.acreate( model="github_copilot/claude-haiku-4.5", @@ -1033,59 +1068,170 @@ async def test_e2e_anthropic_messages_shared_mode_still_uses_authenticator(tmp_p assert response["usage"]["input_tokens"] == 4 -def test_sync_exchange_single_flight_one_http_call(): - import threading +_MODE_TOKENS: Final = {"shared": "shared-copilot-token", "per_user": "gho_a"} - def slow_respond(request: httpx.Request) -> httpx.Response: - time.sleep(0.05) - return httpx.Response(200, json=_exchange_payload(), request=request) - sessions: list[GithubCopilotUserSession] = [] - with github_http(slow_respond) as requests: - threads = [ - threading.Thread( - target=lambda: sessions.append( - exchange_github_token("user-a", "gh-token", "copilot-cred", _GITHUB_ENDPOINTS) - ) - ) - for _ in range(6) +def _credential_kwargs_for(mode, tmp_path, monkeypatch): + if mode == "shared": + _shared_login(tmp_path, monkeypatch) + return {} + return {"litellm_credential_name": "copilot-cred", "secret_fields": _secret_fields("user-a", "gho_a")} + + +def _assert_plan_host_served_both_calls(requests, lookups, mode, path): + assert [str(request.url) for request in requests] == [f"{PLAN_COPILOT_HOST}/{path}"] * 2 + assert {request.headers["authorization"] for request in requests} == {f"Bearer {_MODE_TOKENS[mode]}"} + assert [request.headers["authorization"] for request in lookups] == [f"token {_MODE_TOKENS[mode]}"] + + +@pytest.mark.parametrize("surface", ["completion", "embedding", "responses"]) +@pytest.mark.parametrize("mode", ["shared", "per_user"]) +def test_requests_go_to_the_plan_host_github_reports_for_the_token( + per_user_credential, tmp_path, monkeypatch, mode, surface +): + credential_kwargs = _credential_kwargs_for(mode, tmp_path, monkeypatch) + lookups: list[httpx.Request] = [] + with github_http( + _copilot_surface_responder(_surface_payload(surface)), answer_plan(PLAN_COPILOT_HOST), lookups + ) as requests: + responses = [ + _call_surface(litellm, surface, model=_SURFACE_MODELS[surface], **credential_kwargs) for _ in range(2) ] - for t in threads: - t.start() - for t in threads: - t.join() - assert len(requests) == 1 - assert len(sessions) == 6 and all(s.token == "copilot-token" for s in sessions) + _assert_plan_host_served_both_calls(requests, lookups, mode, _SURFACE_PATHS[surface]) + assert [response._hidden_params["api_base"] for response in responses] == [PLAN_COPILOT_HOST] * 2 -def test_two_users_same_github_token_do_not_share_session(): - with github_http(_exchange_responder()): - s1: Final = exchange_github_token("user-a", "gh-same", "copilot-cred", _GITHUB_ENDPOINTS) - s2: Final = exchange_github_token("user-b", "gh-same", "copilot-cred", _GITHUB_ENDPOINTS) - user_a_key: Final = _session_cache_key("user-a", "copilot-cred", _GITHUB_ENDPOINTS.host) - user_b_key: Final = _session_cache_key("user-b", "copilot-cred", _GITHUB_ENDPOINTS.host) - other_credential_key: Final = _session_cache_key("user-a", "another-credential", _GITHUB_ENDPOINTS.host) - other_host_key: Final = _session_cache_key("user-a", "copilot-cred", "octo.ghe.com") - assert user_a_key != user_b_key - assert user_a_key != other_credential_key - assert user_a_key != other_host_key - assert s1.token == s2.token == "copilot-token" +def test_an_aliased_shared_model_goes_to_the_plan_host(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + _shared_login(tmp_path, monkeypatch) + monkeypatch.setattr(litellm, "model_alias_map", {"copilot": _SURFACE_MODELS["completion"]}) + lookups: Final[list[httpx.Request]] = [] + with github_http( + _copilot_surface_responder(_surface_payload("completion")), answer_plan(PLAN_COPILOT_HOST), lookups + ) as requests: + _call_surface(litellm, "completion", model="copilot") + assert [str(request.url) for request in requests] == [f"{PLAN_COPILOT_HOST}/chat/completions"] + assert [request.headers["authorization"] for request in lookups] == ["token shared-copilot-token"] -def test_same_user_and_token_do_not_share_sessions_across_hosts(): - from litellm.llms.github_copilot.github_host import github_host_endpoints +@pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["shared", "per_user"]) +async def test_messages_go_to_the_plan_host_github_reports_for_the_token( + per_user_credential, tmp_path, monkeypatch, mode +): + credential_kwargs = _credential_kwargs_for(mode, tmp_path, monkeypatch) + lookups: list[httpx.Request] = [] + with github_http( + _copilot_surface_responder(_anthropic_messages_payload()), answer_plan(PLAN_COPILOT_HOST), lookups + ) as requests: + for _ in range(2): + await litellm.anthropic.messages.acreate( + model="github_copilot/claude-haiku-4.5", + max_tokens=16, + messages=[{"role": "user", "content": "hi"}], + **credential_kwargs, + ) + _assert_plan_host_served_both_calls(requests, lookups, mode, "v1/messages") - ghe_endpoints: Final = github_host_endpoints("octo.ghe.com") - def respond(request: httpx.Request) -> httpx.Response: - return httpx.Response(200, json=_exchange_payload(), request=request) +@pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["shared", "per_user"]) +async def test_concurrent_requests_on_a_cold_cache_share_one_plan_lookup( + per_user_credential, tmp_path, monkeypatch, mode +): + credential_kwargs = _credential_kwargs_for(mode, tmp_path, monkeypatch) + lookups: list[httpx.Request] = [] - with github_http(respond) as requests: - github_session: Final = exchange_github_token("user-a", "gh-same", "copilot-cred", _GITHUB_ENDPOINTS) - ghe_session: Final = exchange_github_token("user-a", "gh-same", "copilot-cred", ghe_endpoints) - assert len(requests) == 2 - assert github_session.api_base == "https://api.githubcopilot.com" - assert ghe_session.api_base == "https://copilot-api.octo.ghe.com" + async def answer_plan_slowly(request: httpx.Request) -> httpx.Response: + await asyncio.sleep(0.05) + return answer_plan(PLAN_COPILOT_HOST)(request) + + with github_http(_copilot_surface_responder(_responses_payload()), answer_plan_slowly, lookups) as requests: + await asyncio.gather( + *(litellm.aresponses(model=_SURFACE_MODELS["responses"], input="hi", **credential_kwargs) for _ in range(5)) + ) + assert len(lookups) == 1 + assert [str(request.url) for request in requests] == [f"{PLAN_COPILOT_HOST}/responses"] * 5 + + +@pytest.mark.parametrize( + ("plan_lookup", "lookups_after_a_minute"), + [ + (lambda request: httpx.Response(200, text="not json", request=request), 2), + (answer_plan(PLAN_COPILOT_HOST, status_code=500), 2), + (answer_plan(PLAN_COPILOT_HOST), 1), + ], + ids=["malformed_body_is_retried", "server_error_is_retried", "plan_host_is_kept"], +) +def test_a_failed_plan_lookup_is_retried_a_minute_later(tmp_path, monkeypatch, plan_lookup, lookups_after_a_minute): + _shared_login(tmp_path, monkeypatch) + lookups: list[httpx.Request] = [] + started: Final = time.time() + with github_http(_copilot_surface_responder(_responses_payload()), plan_lookup, lookups): + litellm.responses(model="github_copilot/gpt-5.3-codex", input="hi") + monkeypatch.setattr(time, "time", lambda: started + 61) + litellm.responses(model="github_copilot/gpt-5.3-codex", input="hi") + assert len(lookups) == lookups_after_a_minute + + +def _refuse_connection(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("connection refused", request=request) + + +@pytest.mark.parametrize( + "plan_lookup", + [ + answer_plan("https://api.example.com"), + answer_plan("https://api.githubcopilot.com.example.com"), + answer_plan("http://api.business.githubcopilot.com"), + answer_plan("https://api.business.githubcopilot.com:8443"), + answer_plan(None), + answer_plan(PLAN_COPILOT_HOST, status_code=500), + lambda request: httpx.Response(200, text="not json", request=request), + _refuse_connection, + ], + ids=[ + "other_host", + "lookalike_host", + "plain_http", + "custom_port", + "no_endpoint", + "server_error", + "malformed_body", + "transport_error", + ], +) +def test_requests_use_the_default_host_when_github_reports_no_usable_plan_host(tmp_path, monkeypatch, plan_lookup): + _shared_login(tmp_path, monkeypatch) + with github_http(_copilot_surface_responder(_responses_payload()), plan_lookup) as requests: + litellm.responses(model="github_copilot/gpt-5.3-codex", input="hi") + assert [str(request.url) for request in requests] == [f"{DEFAULT_COPILOT_HOST}/responses"] + + +@pytest.mark.parametrize("surface", ["completion", "embedding", "responses"]) +def test_enterprise_token_exchange_url_without_a_copilot_base_sends_nothing(tmp_path, monkeypatch, surface): + _shared_login(tmp_path, monkeypatch) + monkeypatch.setenv("GITHUB_COPILOT_API_KEY_URL", "https://api.octo.ghe.com/copilot_internal/v2/token") + lookups: list[httpx.Request] = [] + with ( + github_http( + _copilot_surface_responder(_surface_payload(surface)), answer_plan(PLAN_COPILOT_HOST), lookups + ) as requests, + pytest.raises(InvalidAPIBaseError, match="Set GITHUB_COPILOT_API_BASE"), + ): + _call_surface(litellm, surface, model=_SURFACE_MODELS[surface]) + assert requests == [] + assert lookups == [] + + +def test_provider_resolution_never_looks_up_the_plan_host(tmp_path, monkeypatch): + _shared_login(tmp_path, monkeypatch) + lookups: list[httpx.Request] = [] + with github_http( + lambda request: pytest.fail(f"unexpected request to {request.url}"), answer_plan(PLAN_COPILOT_HOST), lookups + ): + litellm.get_llm_provider("github_copilot/gpt-4o") + litellm.get_api_base(model="github_copilot/gpt-4o", optional_params={}) + assert lookups == [] def test_router_constructs_with_per_user_credential_deployment(per_user_credential): @@ -1109,7 +1255,32 @@ def test_router_constructs_with_per_user_credential_deployment(per_user_credenti assert any(d["model_name"] == "copilot" for d in deployments) -def test_provider_info_per_user_without_session_returns_default_base_and_no_key(per_user_credential): +@pytest.mark.parametrize( + ("env_name", "env_value"), + [ + ("GITHUB_COPILOT_API_KEY_URL", "https://api.octo.ghe.com/copilot_internal/v2/token"), + ("GITHUB_COPILOT_API_BASE", "copilot-api.example.com"), + ], +) +def test_router_keeps_a_ghe_per_user_deployment_whatever_the_github_com_env_says(monkeypatch, env_name, env_value): + monkeypatch.delenv("GITHUB_COPILOT_API_BASE", raising=False) + monkeypatch.setenv(env_name, env_value) + with patch.object(litellm, "credential_list", [_credential(github_host="octo.ghe.com")]): + router: Final = litellm.Router( + model_list=[ + { + "model_name": "copilot-ghe", + "litellm_params": {"model": "github_copilot/gpt-4.1", "litellm_credential_name": "copilot-cred"}, + } + ] + ) + assert [d["model_name"] for d in router.get_model_list() or []] == ["copilot-ghe"] + + +@pytest.mark.parametrize("deployment_base", [None, "https://deployment.example.com"]) +def test_provider_info_per_user_without_session_returns_the_copilot_base_and_no_key( + per_user_credential, deployment_base +): from litellm.llms.github_copilot.authenticator import Authenticator from litellm.types.router import LiteLLM_Params @@ -1119,7 +1290,7 @@ def test_provider_info_per_user_without_session_returns_default_base_and_no_key( ) with patch.object(Authenticator, "get_api_key", side_effect=AssertionError("shared login")) as get_key: _, _, dynamic_api_key, api_base = litellm.get_llm_provider( - model="github_copilot/gpt-4.1", litellm_params=params + model="github_copilot/gpt-4.1", api_base=deployment_base, litellm_params=params ) get_key.assert_not_called() assert api_base == "https://api.githubcopilot.com" @@ -1133,95 +1304,12 @@ def test_provider_info_shared_github_copilot_still_uses_authenticator(): params: Final = LiteLLM_Params(model="github_copilot/gpt-4.1") with ( patch.object(Authenticator, "get_api_key", return_value="shared-token") as get_key, - patch.object(Authenticator, "get_api_base", return_value="https://api.githubcopilot.com"), ): _, _, dynamic_api_key, _ = litellm.get_llm_provider(model="github_copilot/gpt-4.1", litellm_params=params) get_key.assert_called_once() assert dynamic_api_key == "shared-token" -@pytest.mark.asyncio -async def test_aexchange_cancelled_waiter_does_not_cancel_the_shared_exchange(per_user_credential): - """Two waiters share one in-flight exchange; cancelling one must not cancel the - underlying future the other is waiting on.""" - gate = asyncio.Event() - - class _GatedClient: - async def get(self, url, headers=None): - await gate.wait() - return httpx.Response(200, json=_exchange_payload(), request=httpx.Request("GET", url)) - - with patch( # test-quality-ok: the gate holds the HTTP edge open so two waiters overlap - "litellm.llms.custom_httpx.http_handler.get_async_httpx_client", return_value=_GatedClient() - ): - first = asyncio.ensure_future(aexchange_github_token("user-a", "gh-shared", "copilot-cred", _GITHUB_ENDPOINTS)) - await asyncio.sleep(0) - second = asyncio.ensure_future(aexchange_github_token("user-a", "gh-shared", "copilot-cred", _GITHUB_ENDPOINTS)) - await asyncio.sleep(0) - second.cancel() - gate.set() - session = await first - assert session.token == "copilot-token" and session.api_base == "https://api.githubcopilot.com" - with pytest.raises(asyncio.CancelledError): - await second - - -@pytest.mark.asyncio -async def test_aexchange_does_not_share_an_in_flight_session_after_token_changes(per_user_credential): - gate = asyncio.Event() - auth_headers: asyncio.Queue[str] = asyncio.Queue() - - class _GatedClient: - async def get(self, url, headers=None): - await auth_headers.put(headers["authorization"]) - await gate.wait() - token = "copilot-token-a" if headers["authorization"] == "token gh-a" else "copilot-token-b" - return httpx.Response( - 200, - json=_exchange_payload(token=token), - request=httpx.Request("GET", url), - ) - - with patch("litellm.llms.custom_httpx.http_handler.get_async_httpx_client", return_value=_GatedClient()): - first = asyncio.ensure_future(aexchange_github_token("user-a", "gh-a", "copilot-cred", _GITHUB_ENDPOINTS)) - assert await asyncio.wait_for(auth_headers.get(), timeout=1) == "token gh-a" - second = asyncio.ensure_future(aexchange_github_token("user-a", "gh-b", "copilot-cred", _GITHUB_ENDPOINTS)) - assert await asyncio.wait_for(auth_headers.get(), timeout=1) == "token gh-b" - gate.set() - sessions = await asyncio.gather(first, second) - - assert [session.token for session in sessions] == ["copilot-token-a", "copilot-token-b"] - - -@pytest.mark.asyncio -async def test_aexchange_failure_with_a_single_caller_emits_no_unretrieved_warning(per_user_credential): - """The leader removes the failed future before anyone awaits it; the stored - exception must be consumed so the loop does not log 'never retrieved'.""" - import litellm.llms.github_copilot.per_user_auth as pua - - captured: list[tuple[object, object]] = [] - loop = asyncio.get_running_loop() - previous_handler = loop.get_exception_handler() - loop.set_exception_handler(lambda _loop, context: captured.append((_loop, context))) - try: - with ( - github_http(_exchange_responder(status=401)), - pytest.raises(CallerCredentialAuthenticationError), - ): - await aexchange_github_token("user-a", "gh-fail", "copilot-cred", _GITHUB_ENDPOINTS) - _IN_FLIGHT_KEYS = list(pua._IN_FLIGHT) - import gc - - gc.collect() - await asyncio.sleep(0) - finally: - loop.set_exception_handler(previous_handler) - assert _IN_FLIGHT_KEYS == [] - assert not any( - "never retrieved" in str(context.get("message", "")) for _, context in captured if isinstance(context, dict) - ) - - @pytest.mark.asyncio async def test_per_user_streaming_call_writes_nothing_to_the_response_cache(per_user_credential, monkeypatch): """Per-user streamed completions must never be written to the shared response @@ -1240,7 +1328,7 @@ async def test_per_user_streaming_call_writes_nothing_to_the_response_cache(per_ "model": "github_copilot/gpt-4o", "messages": [{"role": "user", "content": "hi"}], GITHUB_COPILOT_USER_SESSION_KWARG_KEY: GithubCopilotUserSession( - token="copilot-token", api_base="https://api.githubcopilot.com" + token="copilot-token", endpoints=_GITHUB_ENDPOINTS ), } await handler.async_get_cache( @@ -1335,3 +1423,88 @@ def test_shared_deployment_keeps_caller_credential_override(): router._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs) assert kwargs["litellm_credential_name"] == "other-cred" assert "model_info" in kwargs + + +GHE_COPILOT_USER_URL: Final = "https://api.octo.ghe.com/copilot_internal/user" +GHE_COPILOT_HOST: Final = "https://copilot-api.octo.ghe.com" + + +@pytest.mark.asyncio +async def test_ghe_seat_check_uses_the_tenant_plan_lookup_and_ignores_github_com_env(monkeypatch): + monkeypatch.setenv("GITHUB_COPILOT_API_BASE", "https://configured.example.com") + monkeypatch.setenv("GITHUB_COPILOT_API_KEY_URL", "https://api.other.ghe.com/copilot_internal/v2/token") + lookups: list[httpx.Request] = [] + with github_http( + lambda request: httpx.Response(204, request=request), answer_plan(GHE_COPILOT_HOST), lookups + ) as requests: + await acheck_copilot_seat("ghe-github-token", "copilot-cred", github_host_endpoints("octo.ghe.com")) + assert [str(request.url) for request in lookups] == [GHE_COPILOT_USER_URL] + assert lookups[0].headers["authorization"] == "token ghe-github-token" + assert [str(request.url) for request in requests] == [f"{GHE_COPILOT_HOST}/models"] + assert requests[0].headers["authorization"] == "Bearer ghe-github-token" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "plan_host", + [ + "https://copilot-api.evil.ghe.com", + "https://api.githubcopilot.com", + "http://copilot-api.octo.ghe.com", + "https://user@copilot-api.octo.ghe.com", + "https://copilot-api.octo.ghe.com:8443", + ], +) +async def test_ghe_plan_host_outside_its_tenant_falls_back_to_the_tenant_default(plan_host): + with github_http(lambda request: httpx.Response(204, request=request), answer_plan(plan_host)) as requests: + await acheck_copilot_seat("ghe-github-token", "copilot-cred", _GHE_ENDPOINTS) + assert [str(request.url) for request in requests] == [f"{GHE_COPILOT_HOST}/models"] + + +@pytest.mark.asyncio +async def test_github_com_plan_host_on_a_ghe_tenant_falls_back_to_the_default(): + with github_http(lambda request: httpx.Response(204, request=request), answer_plan(GHE_COPILOT_HOST)) as requests: + await acheck_copilot_seat("stored-github-token", "copilot-cred", _GITHUB_ENDPOINTS) + assert [str(request.url) for request in requests] == [f"{DEFAULT_COPILOT_HOST}/models"] + + +@pytest.mark.asyncio +async def test_plan_host_cache_is_kept_per_github_host(): + lookups: list[httpx.Request] = [] + + def answer_per_host(request: httpx.Request) -> httpx.Response: + plan_host: Final = GHE_COPILOT_HOST if request.url.host == "api.octo.ghe.com" else PLAN_COPILOT_HOST + return answer_plan(plan_host)(request) + + with github_http(lambda request: httpx.Response(204, request=request), answer_per_host, lookups) as requests: + await acheck_copilot_seat("same-github-token", "copilot-cred", _GITHUB_ENDPOINTS) + await acheck_copilot_seat("same-github-token", "copilot-cred", _GHE_ENDPOINTS) + assert [str(request.url) for request in lookups] == [COPILOT_USER_URL, GHE_COPILOT_USER_URL] + assert [str(request.url) for request in requests] == [f"{PLAN_COPILOT_HOST}/models", f"{GHE_COPILOT_HOST}/models"] + + +def test_e2e_completion_per_user_ghe_credential_sends_the_token_to_its_tenant(monkeypatch): + monkeypatch.setenv("GITHUB_COPILOT_API_BASE", "https://configured.example.com") + payload: Final = _chat_completion_response().model_dump() + + def respond(request: httpx.Request) -> httpx.Response: + if request.url.host == "copilot-api.octo.ghe.com": + return httpx.Response(200, json=payload, request=request) + pytest.fail(f"unexpected request to {request.url}") + + lookups: list[httpx.Request] = [] + with ( + patch.object(litellm, "credential_list", [_credential(github_host="octo.ghe.com")]), + github_http(respond, answer_plan(GHE_COPILOT_HOST), lookups) as requests, + _no_authenticator(), + ): + response: Final = litellm.completion( + model="github_copilot/gpt-4o", + messages=[{"role": "user", "content": "hi"}], + litellm_credential_name="copilot-cred", + secret_fields=_secret_fields("user-a", "ghe-github-token", "octo.ghe.com"), + ) + assert [str(request.url) for request in lookups] == [GHE_COPILOT_USER_URL] + assert [str(request.url) for request in requests] == [f"{GHE_COPILOT_HOST}/chat/completions"] + assert requests[0].headers["authorization"] == "Bearer ghe-github-token" + assert response._hidden_params["api_base"] == GHE_COPILOT_HOST diff --git a/tests/unit/proxy/credential_endpoints/test_endpoints.py b/tests/unit/proxy/credential_endpoints/test_endpoints.py index cdd18fd4eef..20278e88339 100644 --- a/tests/unit/proxy/credential_endpoints/test_endpoints.py +++ b/tests/unit/proxy/credential_endpoints/test_endpoints.py @@ -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"}, } ): diff --git a/tests/unit/proxy/proxy_server/test_routes_utils.py b/tests/unit/proxy/proxy_server/test_routes_utils.py index f93c23c8ce4..f73ac6e02a5 100644 --- a/tests/unit/proxy/proxy_server/test_routes_utils.py +++ b/tests/unit/proxy/proxy_server/test_routes_utils.py @@ -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=[ { diff --git a/tests/unit/router_strategy/test_complexity_router.py b/tests/unit/router_strategy/test_complexity_router.py index d0e2b381253..ab3f38e795a 100644 --- a/tests/unit/router_strategy/test_complexity_router.py +++ b/tests/unit/router_strategy/test_complexity_router.py @@ -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( diff --git a/tests/unit/router_utils/test_cooldown_handlers.py b/tests/unit/router_utils/test_cooldown_handlers.py index 71c37b5b1d5..faee04c7606 100644 --- a/tests/unit/router_utils/test_cooldown_handlers.py +++ b/tests/unit/router_utils/test_cooldown_handlers.py @@ -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 ( diff --git a/tests/unit/test_internal_context.py b/tests/unit/test_internal_context.py index ceb9ef38887..02301db81b5 100644 --- a/tests/unit/test_internal_context.py +++ b/tests/unit/test_internal_context.py @@ -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", diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index 0220495032b..b12489ef553 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.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( { **{ diff --git a/tests/unit/test_router/test_router.py b/tests/unit/test_router/test_router.py index f5962cb4933..b00a8ea1cd6 100644 --- a/tests/unit/test_router/test_router.py +++ b/tests/unit/test_router/test_router.py @@ -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", diff --git a/tests/unit/test_router_model_cost_isolation.py b/tests/unit/test_router_model_cost_isolation.py index 931fabd85ad..c16cc52a9bc 100644 --- a/tests/unit/test_router_model_cost_isolation.py +++ b/tests/unit/test_router_model_cost_isolation.py @@ -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",