This commit is contained in:
codgician 2026-11-05 01:08:36 +00:00 • committed by GitHub
commit e309629249
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 2233 additions and 2033 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,20 +2,101 @@
Constants for Copilot integration
"""
from collections.abc import MutableMapping
from typing import Final
import asyncio
import hashlib
import os
from collections.abc import Callable, Mapping, MutableMapping
from functools import partial
from types import MappingProxyType
from typing import (
Final,
Protocol,
cast, # noqa: TID251 # casts pin the untyped litellm clients and cache to the local Protocols
)
from urllib.parse import urlsplit
from uuid import uuid4
import httpx
from pydantic import ConfigDict, TypeAdapter, with_config
from typing_extensions import NotRequired, ReadOnly, TypedDict
from litellm.caching.in_memory_cache import InMemoryCache
from litellm.constants import GITHUB_COPILOT_DEFAULT_HOST
from litellm.llms.base_llm.chat.transformation import BaseLLMException
# Constants
from .github_host import GithubHostEndpoints, github_host_endpoints
COPILOT_VERSION: Final = "0.26.7"
EDITOR_PLUGIN_VERSION: Final = f"copilot-chat/{COPILOT_VERSION}"
USER_AGENT: Final = f"GitHubCopilotChat/{COPILOT_VERSION}"
API_VERSION: Final = "2025-04-01"
DEFAULT_GITHUB_COPILOT_API_BASE: Final = "https://api.githubcopilot.com"
_GITHUB_API_HOST: Final = "api.github.com"
_PLAN_API_BASE_TTL_SECONDS: Final = 3600
_PLAN_API_BASE_RETRY_SECONDS: Final = 60
_PLAN_API_BASE_TIMEOUT_SECONDS: Final = 10.0
DEFAULT_COPILOT_INTEGRATION_ID: Final = "vscode-chat"
DEFAULT_COPILOT_EDITOR_VERSION: Final = "vscode/1.115.0"
DEFAULT_COPILOT_EDITOR_PLUGIN_VERSION: Final = EDITOR_PLUGIN_VERSION
DEFAULT_COPILOT_USER_AGENT: Final = USER_AGENT
_COPILOT_AUTH_HEADER_CONFIG: Final = (
("accept", "GITHUB_COPILOT_ACCEPT", "application/json"),
("content-type", "GITHUB_COPILOT_CONTENT_TYPE", "application/json"),
("copilot-integration-id", "GITHUB_COPILOT_INTEGRATION_ID", DEFAULT_COPILOT_INTEGRATION_ID),
("editor-version", "GITHUB_COPILOT_EDITOR_VERSION", DEFAULT_COPILOT_EDITOR_VERSION),
("editor-plugin-version", "GITHUB_COPILOT_EDITOR_PLUGIN_VERSION", DEFAULT_COPILOT_EDITOR_PLUGIN_VERSION),
("user-agent", "GITHUB_COPILOT_USER_AGENT", DEFAULT_COPILOT_USER_AGENT),
)
_COPILOT_REQUEST_HEADER_CONFIG: Final = _COPILOT_AUTH_HEADER_CONFIG + (
("openai-intent", "GITHUB_COPILOT_OPENAI_INTENT", "conversation-panel"),
("x-github-api-version", "GITHUB_COPILOT_API_VERSION", API_VERSION),
(
"x-vscode-user-agent-library-version",
"GITHUB_COPILOT_USER_AGENT_LIBRARY_VERSION",
"electron-fetch",
),
)
class _PlanApiBaseCache(Protocol):
def get_cache(self, key: str) -> object: ...
def set_cache(self, key: str, value: object, ttl: float | None = None) -> object: ...
def flush_cache(self) -> None: ...
class _SyncGetClient(Protocol):
def get(
self, url: str, headers: Mapping[str, str] | None = None, timeout: float | None = None
) -> httpx.Response: ...
class _AsyncGetClient(Protocol):
async def get(
self, url: str, headers: Mapping[str, str] | None = None, timeout: float | None = None
) -> httpx.Response: ...
@with_config(ConfigDict(extra="allow"))
class _CopilotUserEndpoints(TypedDict):
api: ReadOnly[NotRequired[object]]
@with_config(ConfigDict(extra="allow"))
class _CopilotUserPayload(TypedDict):
endpoints: ReadOnly[NotRequired[_CopilotUserEndpoints]]
_COPILOT_USER: Final = TypeAdapter(_CopilotUserPayload)
_PLAN_API_BASES: Final = cast( # cast-ok: pins the untyped module cache to _PlanApiBaseCache
_PlanApiBaseCache,
InMemoryCache(max_size_in_memory=1000, default_ttl=_PLAN_API_BASE_TTL_SECONDS),
)
_PLAN_API_BASE_LOOKUPS: Final[ # mutable-ok: single-flight registry of in-progress plan host lookups per token
dict[str, "asyncio.Task[None]"]
] = {}
class GithubCopilotError(BaseLLMException):
@ -46,40 +127,254 @@ class GetAccessTokenError(GithubCopilotError):
pass
class APIKeyExpiredError(GithubCopilotError):
pass
class RefreshAPIKeyError(GithubCopilotError):
pass
class GetAPIKeyError(GithubCopilotError):
pass
class InvalidAPIBaseError(GithubCopilotError):
pass
def _get_copilot_header_value(environment_variable: str, default: str | None) -> str | None:
value: Final = os.getenv(environment_variable)
if value is None:
return default
return value or None
def _get_configured_copilot_headers(
config: tuple[tuple[str, str, str | None], ...],
) -> dict[str, str]:
return {
header: value
for header, environment_variable, default in config
if (value := _get_copilot_header_value(environment_variable, default)) is not None
}
def get_copilot_auth_headers(
access_token: str | None = None,
) -> dict[str, str]: # mutable-ok: returned straight into httpx handlers whose headers params require dict
headers: Final = _get_configured_copilot_headers(_COPILOT_AUTH_HEADER_CONFIG)
return {**headers, "authorization": f"token {access_token}"} if access_token else headers
def get_copilot_default_headers(
api_key: str,
) -> dict[str, str]: # mutable-ok: callers merge caller headers into the returned dict
"""
Get default headers for GitHub Copilot Responses API.
Based on copilot-api's header configuration.
"""
return {
**_get_configured_copilot_headers(_COPILOT_REQUEST_HEADER_CONFIG),
"Authorization": f"Bearer {api_key}",
"content-type": "application/json",
"copilot-integration-id": "vscode-chat",
"editor-version": "vscode/1.95.0", # Fixed version for stability
"editor-plugin-version": EDITOR_PLUGIN_VERSION,
"user-agent": USER_AGENT,
"openai-intent": "conversation-panel",
"x-github-api-version": API_VERSION,
"x-request-id": str(uuid4()),
"x-vscode-user-agent-library-version": "electron-fetch",
}
_OPENAI_SDK_HEADER_NAMES: Final = MappingProxyType(
{"accept": "Accept", "content-type": "Content-Type", "user-agent": "User-Agent"}
)
def with_openai_sdk_header_names(
headers: Mapping[str, str],
) -> dict[str, str]: # mutable-ok: becomes the chat request's extra_headers, which the SDK merges as a dict
return {_OPENAI_SDK_HEADER_NAMES.get(name.lower(), name): value for name, value in headers.items()}
def _hostname(url: str) -> str | None:
try:
return urlsplit(url).hostname
except ValueError:
return None
def _is_valid_api_base(api_base: str) -> bool:
try:
parsed_api_base: Final = urlsplit(api_base)
except ValueError:
return False
scheme: Final = parsed_api_base.scheme.lower()
hostname: Final = parsed_api_base.hostname
return (
hostname is not None
and scheme in ("http", "https")
and parsed_api_base.username is None
and parsed_api_base.password is None
and not parsed_api_base.query
and not parsed_api_base.fragment
)
def _reject_enterprise_token_exchange_url(endpoints: GithubHostEndpoints) -> None:
api_key_url: Final = os.getenv("GITHUB_COPILOT_API_KEY_URL")
if endpoints.host == GITHUB_COPILOT_DEFAULT_HOST and api_key_url and _hostname(api_key_url) != _GITHUB_API_HOST:
raise InvalidAPIBaseError(
status_code=500,
message=(
"GITHUB_COPILOT_API_KEY_URL is no longer used. Set GITHUB_COPILOT_API_BASE to your Copilot API host, "
"such as https://copilot-api.<subdomain>.ghe.com"
),
)
def _configured_copilot_api_base_source(api_base: str | None, endpoints: GithubHostEndpoints) -> tuple[str, str | None]:
return ("api_base", api_base) if api_base else ("GITHUB_COPILOT_API_BASE", endpoints.configured_copilot_api_base)
def _configured_copilot_api_base(api_base: str | None, endpoints: GithubHostEndpoints) -> str | None:
source, configured_api_base = _configured_copilot_api_base_source(api_base, endpoints)
if not configured_api_base:
_reject_enterprise_token_exchange_url(endpoints)
return None
if not _is_valid_api_base(configured_api_base):
raise InvalidAPIBaseError(
status_code=500,
message=(f"GitHub Copilot {source} must be an HTTP or HTTPS URL without credentials, query, or fragment"),
)
return configured_api_base
def _trusted_plan_api_base(endpoints_api: object, endpoints: GithubHostEndpoints) -> str | None:
if not isinstance(endpoints_api, str) or not _is_valid_api_base(endpoints_api):
return None
try:
parsed: Final = urlsplit(endpoints_api)
port: Final = parsed.port
except ValueError:
return None
hostname: Final = (parsed.hostname or "").lower()
is_copilot_host: Final = parsed.scheme.lower() == "https" and endpoints.is_copilot_api_host(hostname)
return endpoints_api.rstrip("/") if is_copilot_host and port in (None, 443) else None
def _plan_api_base_from(response: httpx.Response, endpoints: GithubHostEndpoints) -> tuple[str, int]:
if not response.is_success:
return endpoints.default_copilot_api_base, _PLAN_API_BASE_RETRY_SECONDS
try:
plan_endpoints: Final = _COPILOT_USER.validate_python(response.json()).get("endpoints")
except ValueError:
return endpoints.default_copilot_api_base, _PLAN_API_BASE_RETRY_SECONDS
endpoints_api: Final = plan_endpoints.get("api") if plan_endpoints is not None else None
trusted_api_base: Final = _trusted_plan_api_base(endpoints_api, endpoints)
return trusted_api_base or endpoints.default_copilot_api_base, _PLAN_API_BASE_TTL_SECONDS
def _plan_api_base_key(github_token: str, endpoints: GithubHostEndpoints) -> str:
return hashlib.sha256(f"{endpoints.host}\x00{github_token}".encode()).hexdigest()
def _cached_plan_api_base(key: str) -> str | None:
cached: Final = _PLAN_API_BASES.get_cache(key)
return cached if isinstance(cached, str) else None
def _remember_plan_api_base(key: str, resolved: tuple[str, int]) -> None:
api_base, ttl = resolved
_PLAN_API_BASES.set_cache(key, api_base, ttl=ttl)
def _plan_api_base_is_needed(github_token: str, api_base: str | None, endpoints: GithubHostEndpoints) -> bool:
return (
_configured_copilot_api_base(api_base, endpoints) is None
and _cached_plan_api_base(_plan_api_base_key(github_token, endpoints)) is None
)
def _lookup_plan_api_base(github_token: str, endpoints: GithubHostEndpoints) -> tuple[str, int]:
import litellm
client: Final = cast(_SyncGetClient, litellm.module_level_client) # cast-ok: pins the untyped client
try:
response: Final = client.get(
endpoints.copilot_user_url,
headers=get_copilot_auth_headers(github_token),
timeout=_PLAN_API_BASE_TIMEOUT_SECONDS,
)
except httpx.HTTPError:
return endpoints.default_copilot_api_base, _PLAN_API_BASE_RETRY_SECONDS
return _plan_api_base_from(response, endpoints)
async def _alookup_plan_api_base(github_token: str, endpoints: GithubHostEndpoints) -> tuple[str, int]:
from litellm.llms.custom_httpx import http_handler
client: Final = cast( # cast-ok: pins the untyped client factory to the local Protocol
Callable[[str], _AsyncGetClient], http_handler.get_async_httpx_client
)("github_copilot")
try:
response: Final = await client.get(
endpoints.copilot_user_url,
headers=get_copilot_auth_headers(github_token),
timeout=_PLAN_API_BASE_TIMEOUT_SECONDS,
)
except httpx.HTTPError:
return endpoints.default_copilot_api_base, _PLAN_API_BASE_RETRY_SECONDS
return _plan_api_base_from(response, endpoints)
def _default_host_endpoints() -> GithubHostEndpoints:
return github_host_endpoints(GITHUB_COPILOT_DEFAULT_HOST)
def prefetch_copilot_plan_api_base(
github_token: str, api_base: str | None = None, endpoints: GithubHostEndpoints | None = None
) -> None:
host_endpoints: Final = endpoints or _default_host_endpoints()
if _plan_api_base_is_needed(github_token, api_base, host_endpoints):
_remember_plan_api_base(
_plan_api_base_key(github_token, host_endpoints), _lookup_plan_api_base(github_token, host_endpoints)
)
async def _alookup_and_remember_plan_api_base(key: str, github_token: str, endpoints: GithubHostEndpoints) -> None:
_remember_plan_api_base(key, await _alookup_plan_api_base(github_token, endpoints))
def _forget_plan_api_base_lookup(key: str, lookup: "asyncio.Task[None]") -> None:
if _PLAN_API_BASE_LOOKUPS.get(key) is lookup:
_PLAN_API_BASE_LOOKUPS.pop(key, None)
def _plan_api_base_lookup(key: str, github_token: str, endpoints: GithubHostEndpoints) -> "asyncio.Task[None]":
loop: Final = asyncio.get_running_loop()
in_flight: Final = _PLAN_API_BASE_LOOKUPS.get(key)
if in_flight is not None and in_flight.get_loop() is loop:
return in_flight
lookup: Final = loop.create_task(_alookup_and_remember_plan_api_base(key, github_token, endpoints))
_PLAN_API_BASE_LOOKUPS[key] = lookup
lookup.add_done_callback(partial(_forget_plan_api_base_lookup, key))
return lookup
async def aprefetch_copilot_plan_api_base(
github_token: str, api_base: str | None = None, endpoints: GithubHostEndpoints | None = None
) -> None:
host_endpoints: Final = endpoints or _default_host_endpoints()
if _plan_api_base_is_needed(github_token, api_base, host_endpoints):
key: Final = _plan_api_base_key(github_token, host_endpoints)
await asyncio.shield(_plan_api_base_lookup(key, github_token, host_endpoints))
def get_copilot_api_base(
api_base: str | None = None,
github_token: Callable[[], str | None] | None = None,
endpoints: GithubHostEndpoints | None = None,
) -> str:
host_endpoints: Final = endpoints or _default_host_endpoints()
configured_api_base: Final = _configured_copilot_api_base(api_base, host_endpoints)
if configured_api_base is not None:
return configured_api_base
token: Final = github_token() if github_token is not None else None
plan_api_base: Final = _cached_plan_api_base(_plan_api_base_key(token, host_endpoints)) if token else None
return plan_api_base or host_endpoints.default_copilot_api_base
async def aget_copilot_api_base(
github_token: str, api_base: str | None = None, endpoints: GithubHostEndpoints | None = None
) -> str:
await aprefetch_copilot_plan_api_base(github_token, api_base, endpoints)
return get_copilot_api_base(api_base, lambda: github_token, endpoints)
def pin_session_authorization(
headers: MutableMapping[str, str], # mutable-ok: the helper fixes caller-owned headers in place
session_token: str,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,154 @@
import uuid
from collections.abc import Iterator, Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Final
from urllib.parse import urlsplit
import httpx
import pytest
import yaml
from integration._support import device_login as dl
from integration._support import responses_vendor as rv
from integration._support.client import gateway_from_environment
from integration._support.process import OwnedProxy, owned_proxy_process
from integration._support.wire import Request
from pydantic import JsonValue
pytestmark: Final = [pytest.mark.timeout(300), pytest.mark.shares_worker_state]
_CREDENTIAL: Final = "copilot-ghe"
_MODEL: Final = "copilot-ghe-per-user"
_CLIENT_SECONDS: Final = 60
@dataclass(frozen=True, slots=True)
class _Rig:
owned: OwnedProxy
peers: dl.Peers
def post(self, path: str, key: str, body: Mapping[str, JsonValue]) -> httpx.Response:
with httpx.Client(
base_url=str(self.owned.gateway.client.base_url), timeout=_CLIENT_SECONDS, trust_env=False
) as client:
return client.post(path, json=dict(body), headers={"Authorization": f"Bearer {key}"})
def disconnect(self, key: str) -> None:
with httpx.Client(
base_url=str(self.owned.gateway.client.base_url), timeout=_CLIENT_SECONDS, trust_env=False
) as client:
response: Final = client.delete(
f"/credentials/{_CREDENTIAL}/user_connection", headers={"Authorization": f"Bearer {key}"}
)
assert response.status_code == 200, response.text
def _config(directory: Path) -> Path:
config: Final = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())
config["credential_list"] = [
{
"credential_name": _CREDENTIAL,
"credential_values": {"github_copilot_auth_type": "per_user_oauth", "github_copilot_host": dl.GHE_HOST},
"credential_info": {"custom_llm_provider": "github_copilot"},
}
]
config["model_list"] = [
{
"model_name": _MODEL,
"litellm_params": {
"model": "github_copilot/gpt-5.4",
"litellm_credential_name": _CREDENTIAL,
"github_copilot_auth_type": "per_user_oauth",
},
}
]
path: Final = directory / "copilot-ghe-per-user.yaml"
path.write_text(yaml.safe_dump(config))
return path
@pytest.fixture(scope="module")
def rig(tmp_path_factory: pytest.TempPathFactory) -> Iterator[_Rig]:
directory: Final = tmp_path_factory.mktemp("copilot-ghe-per-user")
copilot_dir: Final = directory / "copilot"
copilot_dir.mkdir()
with (
gateway_from_environment() as gateway,
dl.device_login_peers(directory) as peers,
owned_proxy_process(
gateway,
directory,
{**peers.environment(), "GITHUB_COPILOT_TOKEN_DIR": str(copilot_dir)},
config=_config(directory),
workers=2,
) as owned,
):
yield _Rig(owned, peers)
def _outbound(request: Request) -> tuple[str, str, str, str]:
return (
request.headers.get("host", ""),
request.method,
urlsplit(request.target).path,
request.headers.get("authorization", ""),
)
def test_a_ghe_user_connects_and_chats_with_their_own_token_on_the_tenant_hosts(rig: _Rig) -> None:
rig.peers.reset()
rig.peers.switches.grant.set()
with rig.owned.gateway.scenario() as scenario:
user_key: Final = scenario.key(user_id=scenario.user(user_role="internal_user"))
scenario.cleanups.callback(rig.disconnect, user_key)
started: Final = rig.post(f"/credentials/{_CREDENTIAL}/user_connection/start", user_key, {})
assert started.status_code == 200, started.text
flow: Final = rv.JSON_OBJECT.validate_json(started.text)
assert flow["verification_uri"] == f"https://{dl.GHE_HOST}/login/device", started.text
polled: Final = rig.post(
f"/credentials/{_CREDENTIAL}/user_connection/poll", user_key, {"flow_handle": flow["flow_handle"]}
)
assert polled.status_code == 200, polled.text
connection: Final = rv.JSON_OBJECT.validate_json(polled.text)
assert (connection["status"], connection["github_login"]) == ("connected", dl.GHE_LOGIN), polled.text
marker: Final = uuid.uuid4().hex
chat: Final = rig.post(
"/v1/chat/completions",
user_key,
{
"model": _MODEL,
"messages": [{"role": "user", "content": f"Reply to marker-{marker}"}],
"num_retries": 0,
"cache": {"no-cache": True},
},
)
assert chat.status_code == 200, chat.text
assert set(rv.MARKER.findall(chat.text)) == {marker}, chat.text
received: Final = rig.peers.auth.drain()
outbound: Final = tuple(_outbound(request) for request in received)
(forwarded,) = (request for request in received if marker.encode() in request.body)
assert _outbound(forwarded) == (
dl.GHE_COPILOT_HOST,
"POST",
"/chat/completions",
f"Bearer {dl.GHE_ACCESS}",
), outbound
assert {(host, method, path) for host, method, path, _ in outbound} == {
(dl.GHE_HOST, "POST", "/login/device/code"),
(dl.GHE_HOST, "POST", "/login/oauth/access_token"),
(dl.GHE_API_HOST, "GET", "/copilot_internal/user"),
(dl.GHE_COPILOT_HOST, "GET", "/models"),
(dl.GHE_API_HOST, "GET", "/user"),
(dl.GHE_COPILOT_HOST, "POST", "/chat/completions"),
}, outbound
assert {authorization for host, _, _, authorization in outbound if host == dl.GHE_API_HOST} == {
f"token {dl.GHE_ACCESS}"
}, outbound
assert {authorization for host, _, _, authorization in outbound if host == dl.GHE_COPILOT_HOST} == {
f"Bearer {dl.GHE_ACCESS}"
}, outbound
assert rig.peers.api.drain() == (), "the github.com GITHUB_COPILOT_API_BASE took a ghe.com call"

View file

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

View file

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

View file

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

View file

@ -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")
)
}
},

View file

@ -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"),
],
)

View file

@ -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 == []

View file

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

View file

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

View file

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

View file

@ -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},
},
)

View file

@ -32,29 +32,29 @@ def test_normalized_github_host_accepts_only_bare_supported_hosts(raw: object, e
def test_github_com_endpoints_read_url_overrides_at_call_time(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("GITHUB_COPILOT_DEVICE_CODE_URL", "https://device.example/code")
monkeypatch.setenv("GITHUB_COPILOT_ACCESS_TOKEN_URL", "https://device.example/token")
monkeypatch.setenv("GITHUB_COPILOT_API_KEY_URL", "https://api.example/copilot")
monkeypatch.setenv("GITHUB_COPILOT_API_BASE", "https://copilot.example")
monkeypatch.setenv("GITHUB_COPILOT_CLIENT_ID", "custom-client")
endpoints: Final = github_host_endpoints("github.com")
assert endpoints.device_code_url == "https://device.example/code"
assert endpoints.access_token_url == "https://device.example/token"
assert endpoints.api_key_url == "https://api.example/copilot"
assert endpoints.configured_copilot_api_base == "https://copilot.example"
assert endpoints.client_id == "custom-client"
def test_ghe_endpoints_ignore_github_com_url_overrides(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("GITHUB_COPILOT_DEVICE_CODE_URL", "https://device.example/code")
monkeypatch.setenv("GITHUB_COPILOT_ACCESS_TOKEN_URL", "https://device.example/token")
monkeypatch.setenv("GITHUB_COPILOT_API_KEY_URL", "https://api.example/copilot")
monkeypatch.setenv("GITHUB_COPILOT_API_BASE", "https://copilot.example")
monkeypatch.setenv("GITHUB_COPILOT_CLIENT_ID", "custom-client")
endpoints: Final = github_host_endpoints("octo.ghe.com")
assert endpoints.device_code_url == "https://octo.ghe.com/login/device/code"
assert endpoints.access_token_url == "https://octo.ghe.com/login/oauth/access_token"
assert endpoints.api_key_url == "https://api.octo.ghe.com/copilot_internal/v2/token"
assert endpoints.copilot_user_url == "https://api.octo.ghe.com/copilot_internal/user"
assert endpoints.configured_copilot_api_base is None
assert endpoints.user_url == "https://api.octo.ghe.com/user"
assert endpoints.default_copilot_api_base == "https://copilot-api.octo.ghe.com"
assert endpoints.client_id == "custom-client"

File diff suppressed because it is too large Load diff

View file

@ -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"},
}
):

View file

@ -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=[
{

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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