diff --git a/litellm/exceptions.py b/litellm/exceptions.py index 9350396ee88..583276148cb 100644 --- a/litellm/exceptions.py +++ b/litellm/exceptions.py @@ -229,6 +229,10 @@ class CallerCredentialAuthenticationError(AuthenticationError): caller's credential, so routers must not cool down the shared deployment.""" +class DeploymentCredentialAuthenticationError(AuthenticationError): + pass + + # raise when invalid models passed, example gpt-8 class NotFoundError(openai.NotFoundError): def __init__( diff --git a/litellm/integrations/prometheus.py b/litellm/integrations/prometheus.py index 6f56693b77e..43abc08d2df 100644 --- a/litellm/integrations/prometheus.py +++ b/litellm/integrations/prometheus.py @@ -31,6 +31,7 @@ from litellm.constants import ( ) from litellm.exceptions import ( CallerCredentialAuthenticationError, + DeploymentCredentialAuthenticationError, MalformedRequestFieldError, validate_rate_limit_category, validate_rate_limit_type, @@ -56,7 +57,7 @@ from litellm.litellm_core_utils.service_tier_utils import ( get_service_tier_from_standard_logging_payload, ) from litellm.llms.azure.common_utils import caused_by_entra_rejection -from litellm.llms.github_copilot.per_user_auth import github_copilot_user_session_from +from litellm.llms.github_copilot.per_user_auth import is_github_copilot_per_user_request from litellm.llms.openai_like.json_loader import JSONProviderRegistry from litellm.models.end_user import LiteLLM_EndUserTable from litellm.models.team import LiteLLM_TeamTable @@ -544,7 +545,7 @@ def _used_a_caller_credential(logged_call: Mapping[str, object]) -> bool: or _sent_a_provider_credential_or_endpoint(_logged_fields(proxy_server_request.get("body"))) or bool(litellm_params.get("token_exchange_endpoint")) or logged_metadata.get("used_client_oauth_token") is True - or github_copilot_user_session_from(litellm_params) is not None + or is_github_copilot_per_user_request(litellm_params) ) @@ -555,7 +556,11 @@ def _is_deployment_failure(exception: openai.APIError, logged_call: Mapping[str, return not ( isinstance(exception, CallerCredentialAuthenticationError) or bool(logged_call.get(LITELLM_LOGGING_NO_UPSTREAM_LLM_CALL)) - or (logged_call.get("api_call_start_time") is None and not caused_by_entra_rejection(exception)) + or ( + logged_call.get("api_call_start_time") is None + and not isinstance(exception, DeploymentCredentialAuthenticationError) + and not caused_by_entra_rejection(exception) + ) or _used_a_caller_credential(logged_call) ) diff --git a/litellm/litellm_core_utils/clientside_credential_handler.py b/litellm/litellm_core_utils/clientside_credential_handler.py index 2bd58405693..db506e8a8e5 100644 --- a/litellm/litellm_core_utils/clientside_credential_handler.py +++ b/litellm/litellm_core_utils/clientside_credential_handler.py @@ -100,6 +100,16 @@ def get_dynamic_litellm_params(litellm_params: dict, request_kwargs: dict) -> di for generating a unique model_id. """ + from litellm.llms.github_copilot.app_auth import app_config + + app_params: Final = cast("dict[str, object]", litellm_params) # cast-ok: the existing request boundary is untyped + if app_config(app_params) is not None and is_clientside_credential(request_kwargs): + raise BadRequestError( + message="GitHub App deployments do not accept client-side credential or endpoint overrides.", + llm_provider="github_copilot", + model=None, + ) + # update litellm_params with clientside credentials for key in clientside_credential_keys: if key in request_kwargs: diff --git a/litellm/litellm_core_utils/credential_accessor.py b/litellm/litellm_core_utils/credential_accessor.py index bab250fe583..0d7a64c2dc3 100644 --- a/litellm/litellm_core_utils/credential_accessor.py +++ b/litellm/litellm_core_utils/credential_accessor.py @@ -30,7 +30,13 @@ class CredentialAccessor: credential: Final = CredentialAccessor.find_credential(credential_name) if credential is None: return {} - return deepcopy(dict(credential_api_key_secret_reference(credential.credential_values))) + return deepcopy( + { + key: value + for key, value in credential_api_key_secret_reference(credential.credential_values).items() + if key != "github_copilot_app_private_key" + } + ) @staticmethod def upsert_credentials(credentials: list[CredentialItem]): diff --git a/litellm/litellm_core_utils/get_litellm_params.py b/litellm/litellm_core_utils/get_litellm_params.py index 198a3cd0234..107db751036 100644 --- a/litellm/litellm_core_utils/get_litellm_params.py +++ b/litellm/litellm_core_utils/get_litellm_params.py @@ -89,6 +89,9 @@ OPTIONAL_KWARGS_KEYS: Final = ( "chatgpt_auth_file", "github_copilot_auth_type", "github_copilot_host", + "github_copilot_app_id", + "github_copilot_app_installation_id", + "github_copilot_app_private_key_ref", "github_copilot_user_session", "fireworks_forward_user_id", "thought_signature_in_tool_call_id", diff --git a/litellm/litellm_core_utils/get_llm_provider_logic.py b/litellm/litellm_core_utils/get_llm_provider_logic.py index bbb1b19819f..c7410367455 100644 --- a/litellm/litellm_core_utils/get_llm_provider_logic.py +++ b/litellm/litellm_core_utils/get_llm_provider_logic.py @@ -849,6 +849,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.app_auth import app_config from litellm.llms.github_copilot.common_utils import DEFAULT_GITHUB_COPILOT_API_BASE from litellm.llms.github_copilot.per_user_auth import ( github_copilot_per_user_credential_name, @@ -858,9 +859,13 @@ def _get_openai_compatible_provider_info( user_session: Final = github_copilot_user_session_from( cast("dict[str, object]", litellm_params) # cast-ok: litellm_params arrives as an untyped request dict ) + github_app: Final = app_config(litellm_params) if user_session is None else None if user_session is not None: api_base = user_session.api_base # rebind-ok: resolves provider args in place dynamic_api_key = user_session.token # rebind-ok: resolves provider args in place + elif github_app is not None: + api_base = github_app.api_base # rebind-ok: resolves provider args in place + dynamic_api_key = None # rebind-ok: token resolution runs in the request wrapper elif ( github_copilot_per_user_credential_name( cast("dict[str, object]", litellm_params) # cast-ok: untyped params dict diff --git a/litellm/litellm_core_utils/llm_response_utils/get_api_base.py b/litellm/litellm_core_utils/llm_response_utils/get_api_base.py index 4d731b5e63a..ee495b3a091 100644 --- a/litellm/litellm_core_utils/llm_response_utils/get_api_base.py +++ b/litellm/litellm_core_utils/llm_response_utils/get_api_base.py @@ -2,6 +2,7 @@ from typing import Final import litellm from litellm import verbose_logger +from litellm.exceptions import BadRequestError from ...litellm_core_utils.get_llm_provider_logic import ( declared_authenticating_provider, @@ -10,6 +11,16 @@ from ...litellm_core_utils.get_llm_provider_logic import ( from ...types.router import LiteLLM_Params +def _github_app_api_base(params: LiteLLM_Params) -> str | None: + from litellm.llms.github_copilot.app_auth import app_config + + try: + github_app: Final = app_config(params) + except BadRequestError: + return None + return github_app.api_base if github_app is not None else None + + def _api_base_without_login(provider: str) -> str | None: if provider == "github_copilot": return litellm.GithubCopilotConfig().api_base_without_login() @@ -57,6 +68,12 @@ def get_api_base(model: str, optional_params: dict | LiteLLM_Params) -> str | No return None # get llm provider + if litellm.model_alias_map and model in litellm.model_alias_map: + model = litellm.model_alias_map[model] + declared: Final = declared_authenticating_provider(model, _optional_params.custom_llm_provider) + github_app_api_base: Final = _github_app_api_base(_optional_params) if declared == "github_copilot" else None + if github_app_api_base is not None: + return github_app_api_base if _optional_params.api_base is not None: return _optional_params.api_base extra_params: Final = _optional_params.model_extra @@ -64,9 +81,6 @@ def get_api_base(model: str, optional_params: dict | LiteLLM_Params) -> str | No if isinstance(base_url_alias, str) and base_url_alias: return base_url_alias - if litellm.model_alias_map and model in litellm.model_alias_map: - 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) try: diff --git a/litellm/llms/github_copilot/app_auth.py b/litellm/llms/github_copilot/app_auth.py new file mode 100644 index 00000000000..3b8e4fc7f2b --- /dev/null +++ b/litellm/llms/github_copilot/app_auth.py @@ -0,0 +1,378 @@ +import asyncio +import hashlib +import json +import math +import threading +import time +from collections import OrderedDict +from collections.abc import Callable, Mapping +from concurrent.futures import Future +from dataclasses import dataclass, field +from typing import Final, NoReturn, cast # noqa: TID251 # runtime Mapping keys and values are objects + +import httpx +from pydantic import AwareDatetime, BaseModel, ConfigDict, SecretStr, ValidationError + +from litellm.exceptions import ( + BadRequestError, + DeploymentCredentialAuthenticationError, + RateLimitError, + ServiceUnavailableError, +) +from litellm.litellm_core_utils.credential_accessor import CredentialAccessor +from litellm.llms.custom_httpx.http_handler import get_ssl_configuration +from litellm.secret_managers.main import get_secret_str + +from .github_host import github_host_endpoints, normalized_github_host +from .per_user_auth import GithubCopilotUserSession + +APP_AUTH_TYPE: Final = "github_app" +APP_PRIVATE_KEY: Final = "github_copilot_app_private_key" +APP_FIELDS: Final = ( + "github_copilot_app_id", + "github_copilot_app_installation_id", + "github_copilot_app_private_key_ref", + APP_PRIVATE_KEY, +) +_REFRESH_MARGIN: Final = 120 +_CACHE_SIZE: Final = 128 + + +@dataclass(frozen=True, slots=True) +class AppConfig: + app_id: str + installation_id: int + private_key_ref: str = field(repr=False) + host: str = "github.com" + credential_name: str | None = field(default=None, repr=False) + + @property + def token_url(self) -> str: + api_host: Final = "api.github.com" if self.host == "github.com" else f"api.{self.host}" + return f"https://{api_host}/app/installations/{self.installation_id}/access_tokens" + + @property + def api_base(self) -> str: + return github_host_endpoints(self.host).default_copilot_api_base + + +def _value(source: object, key: str) -> object: + if isinstance(source, Mapping): + return cast(Mapping[object, object], source).get(key) # cast-ok: Mapping values are objects + return getattr(source, key, None) + + +def app_config(source: object, *, validate_stored_key: bool = False) -> AppConfig | None: + name: Final = _value(source, "litellm_credential_name") + credential: Final = CredentialAccessor.find_credential(name) if isinstance(name, str) else None + values: Final = credential.credential_values if credential is not None else source + mode: Final = _value(values, "github_copilot_auth_type") + configured: Final = any(_value(values, key) is not None for key in APP_FIELDS) + if mode != APP_AUTH_TYPE and not configured: + return None + app_id: Final = _value(values, APP_FIELDS[0]) + installation: Final = _value(values, APP_FIELDS[1]) + key_ref: Final = _value(values, APP_FIELDS[2]) + stored_key: Final = _value(values, APP_PRIVATE_KEY) + stored_key_selected: Final = isinstance(stored_key, str) and bool(stored_key) + valid_key_source: Final = ( + stored_key_selected and not key_ref and (credential is not None or validate_stored_key) + ) or ( + stored_key is None + and isinstance(key_ref, str) + and bool(key_ref.strip()) + and "-----BEGIN" not in key_ref + and not any(c.isspace() for c in key_ref) + and (not key_ref.startswith("oidc/") or key_ref.startswith("oidc/file/")) + ) + host: Final = normalized_github_host(_value(values, "github_copilot_host")) + app_id_text: Final = _id_text(app_id).strip() + installation_text: Final = _id_text(installation) + if ( + mode != APP_AUTH_TYPE + or not app_id_text + or not installation_text.isascii() + or not installation_text.isdecimal() + or int(installation_text) <= 0 + or not valid_key_source + or host is None + ): + raise BadRequestError( + message="GitHub App authentication requires github_app auth type, an App/client ID, a positive " + "installation ID, a stored credential private key or secret reference, and a supported GitHub host.", + llm_provider="github_copilot", + model="", + ) + if stored_key_selected and validate_stored_key: + validate_private_key(stored_key) + return AppConfig( + app_id_text, + int(installation_text), + key_ref if isinstance(key_ref, str) else "", + host, + credential.credential_name if stored_key_selected and credential is not None else None, + ) + + +def _id_text(value: object) -> str: + if isinstance(value, bool): + return "" + return str(value) if isinstance(value, (str, int)) else "" + + +def validate_private_key(value: object) -> None: + """Validate uploaded keys without echoing their contents in errors.""" + from cryptography.exceptions import UnsupportedAlgorithm + from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey + from cryptography.hazmat.primitives.serialization import load_pem_private_key + + try: + if not isinstance(value, str) or len(value) > 16384: + raise ValueError("Invalid key size") + key: Final = load_pem_private_key(value.encode(), password=None) + if not isinstance(key, RSAPrivateKey) or key.key_size < 2048: + raise ValueError("Unsupported key") + except (ValueError, TypeError, UnsupportedAlgorithm): + raise BadRequestError( + message="Upload an unencrypted RSA PEM private key of at least 2048 bits, no larger than 16 KiB.", + llm_provider="github_copilot", + model="", + ) from None + + +def _stored_private_key(name: str) -> str | None: + credential: Final = CredentialAccessor.find_credential(name) + value: Final = credential.credential_values.get(APP_PRIVATE_KEY) if credential is not None else None + return value if isinstance(value, str) else None + + +class _InstallationToken(BaseModel): + model_config = ConfigDict(frozen=True, hide_input_in_errors=True) + token: SecretStr + expires_at: AwareDatetime + permissions: Mapping[str, str] + + +@dataclass(frozen=True, slots=True) +class _Token: + value: str = field(repr=False) + expires_at: float + deadline: float + + +@dataclass(frozen=True, slots=True) +class _Failure: + message: str + status: int + deadline: float + + +def _raise_failure(failure: _Failure, now: float) -> NoReturn: + if failure.status == 429: + raise RateLimitError( + message=failure.message, + llm_provider="github_copilot", + model="", + headers={"retry-after": str(max(1, math.ceil(failure.deadline - now)))}, + ) + if failure.status in (401, 403, 404): + raise DeploymentCredentialAuthenticationError(message=failure.message, llm_provider="github_copilot", model="") + if failure.status == 422: + raise BadRequestError(message=failure.message, llm_provider="github_copilot", model="") + raise ServiceUnavailableError(message=failure.message, llm_provider="github_copilot", model="") + + +def _session(config: AppConfig, result: _Token | _Failure, now: float) -> GithubCopilotUserSession: + if isinstance(result, _Failure): + _raise_failure(result, now) + return GithubCopilotUserSession(token=result.value, api_base=config.api_base, is_app=True) + + +def _new_client() -> httpx.Client: + return httpx.Client(timeout=httpx.Timeout(15, connect=5), follow_redirects=False, verify=get_ssl_configuration()) + + +class AppTokenManager: + def __init__( + self, + client_factory: Callable[[], httpx.Client] = _new_client, + secret_reader: Callable[[str], str | None] = get_secret_str, + clock: Callable[[], float] = time.time, + monotonic: Callable[[], float] = time.monotonic, + ) -> None: + self._client_factory = client_factory + self._secret_reader = secret_reader + self._clock = clock + self._monotonic = monotonic + self._cache: OrderedDict[str, _Token | _Failure] = OrderedDict() # mutable-ok: locked token cache + self._in_flight: dict[str, Future[_Token | _Failure]] = {} # mutable-ok: locked single-flight registry + self._cache_lock = threading.Lock() + + def resolve(self, config: AppConfig) -> GithubCopilotUserSession: + key: Final = self._cache_key(config) + cached: Final = self._get(key) + if cached is not None: + return _session(config, cached, self._monotonic()) + future, owner = self._claim(key) + if owner: + self._fill(key, config, future) + return _session(config, future.result(), self._monotonic()) + + async def aresolve(self, config: AppConfig) -> GithubCopilotUserSession: + key: Final = self._cache_key(config) + cached: Final = self._get(key) + if cached is not None: + return _session(config, cached, self._monotonic()) + future, owner = self._claim(key) + if owner: + _ = asyncio.get_running_loop().run_in_executor(None, self._fill, key, config, future) + return _session(config, await asyncio.wrap_future(future), self._monotonic()) + + def _cache_key(self, config: AppConfig) -> str: + stored_key: Final = _stored_private_key(config.credential_name) if config.credential_name is not None else None + return hashlib.sha256( + json.dumps( + ( + config.host, + config.app_id, + config.installation_id, + config.private_key_ref, + config.credential_name, + stored_key, + ) + ).encode() + ).hexdigest() + + def _claim(self, key: str) -> tuple[Future[_Token | _Failure], bool]: + with self._cache_lock: + existing: Final = self._in_flight.get(key) + if existing is not None: + return existing, False + future: Final = Future[_Token | _Failure]() + _ = future.set_running_or_notify_cancel() + self._in_flight[key] = future + return future, True + + def _fill(self, key: str, config: AppConfig, future: Future[_Token | _Failure]) -> None: + try: + cached: Final = self._get(key) + result: Final = cached if cached is not None else self._fetch(config) + except BaseException as error: # noqa: BLE001 # every waiter receives the error through the future + self._settle(key, None) + future.set_exception(error) + return + self._settle(key, result) + future.set_result(result) + + def _fetch(self, config: AppConfig) -> _Token | _Failure: + try: + private_key: Final = ( + _stored_private_key(config.credential_name) + if config.credential_name is not None + else self._secret_reader(config.private_key_ref) + ) + except Exception: # noqa: BLE001 # secret backends can raise arbitrary errors containing credentials + return self._failure("Unable to read the GitHub App private-key secret.", 401, cooldown=0) + if not private_key: + return self._failure("GitHub App private-key secret is empty or unavailable.", 401, cooldown=0) + return self._mint(config, private_key) + + def _get(self, key: str) -> _Token | _Failure | None: + with self._cache_lock: + cached: Final = self._cache.get(key) + if cached is None: + return None + if cached.deadline <= self._monotonic() or ( + isinstance(cached, _Token) and cached.expires_at - _REFRESH_MARGIN <= self._clock() + ): + del self._cache[key] + return None + self._cache.move_to_end(key) + return cached + + def _settle(self, key: str, value: _Token | _Failure | None) -> None: + with self._cache_lock: + _ = self._in_flight.pop(key, None) + if value is None: + return + self._cache[key] = value + self._cache.move_to_end(key) + while len(self._cache) > _CACHE_SIZE: + _ = self._cache.popitem(last=False) + + def _failure(self, message: str, status: int, cooldown: float = 5) -> _Failure: + return _Failure(message, status, self._monotonic() + cooldown) + + def _rate_limit_cooldown(self, response: httpx.Response) -> float: + retry_after: Final = next(iter(response.headers.get_list("retry-after")), "") + reset: Final = next(iter(response.headers.get_list("x-ratelimit-reset")), "") + try: + if retry_after.isascii() and retry_after.isdecimal(): + return max(1, min(3600, float(retry_after))) + if reset.isascii() and reset.isdecimal(): + return max(1, min(3600, float(reset) - self._clock())) + except (ValueError, OverflowError): + return 60 + return 60 + + def _mint(self, config: AppConfig, private_key: str) -> _Token | _Failure: + try: + import jwt + except ImportError: + return self._failure("Install PyJWT and cryptography to use GitHub App authentication.", 401) + now: Final = self._clock() + started: Final = self._monotonic() + try: + assertion: Final = jwt.encode( + {"iat": int(now) - 60, "exp": int(now) + 540, "iss": config.app_id}, + private_key, + algorithm="RS256", + ) + except (ValueError, TypeError, jwt.PyJWTError, NotImplementedError): + return self._failure("Unable to sign a GitHub App JWT. Check the RSA private key.", 401) + try: + with self._client_factory() as client: + response: Final = client.post( + config.token_url, + headers={ + "Authorization": f"Bearer {assertion}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2026-03-10", + }, + json={"permissions": {"organization_copilot_requests": "read"}}, + follow_redirects=False, + ) + except (httpx.HTTPError, ValueError): + return self._failure("GitHub App installation token request failed.", 503) + if response.status_code == 429 or ( + response.status_code == 403 + and ("retry-after" in response.headers or response.headers.get("x-ratelimit-remaining") == "0") + ): + return self._failure( + "GitHub rate limited installation token creation. Retry later.", + 429, + self._rate_limit_cooldown(response), + ) + if response.status_code != 201: + return self._failure( + f"GitHub App installation token request returned HTTP {response.status_code}. " + "Check the App installation and Copilot Requests permission.", + response.status_code, + ) + try: + payload: Final = _InstallationToken.model_validate_json(response.content) + except ValidationError: + return self._failure("GitHub returned an invalid installation token response.", 503) + expires_at: Final = min(payload.expires_at.timestamp(), now + 3600) + token: Final = payload.token.get_secret_value() + if ( + not token + or any(c.isspace() for c in token) + or expires_at <= self._clock() + _REFRESH_MARGIN + or payload.permissions.get("organization_copilot_requests") != "read" + ): + return self._failure("GitHub returned an expired token or missing Copilot Requests permission.", 401) + return _Token(token, expires_at, started + expires_at - now - _REFRESH_MARGIN) + + +APP_TOKEN_MANAGER: Final = AppTokenManager() diff --git a/litellm/llms/github_copilot/chat/transformation.py b/litellm/llms/github_copilot/chat/transformation.py index 0285bde2a87..c2ac178228f 100644 --- a/litellm/llms/github_copilot/chat/transformation.py +++ b/litellm/llms/github_copilot/chat/transformation.py @@ -18,6 +18,7 @@ from ..common_utils import ( DEFAULT_GITHUB_COPILOT_API_BASE, GetAccessTokenError, get_copilot_default_headers, + pin_session_authorization, ) from ..per_user_auth import require_github_copilot_user_session @@ -126,10 +127,10 @@ class GithubCopilotConfig(OpenAIConfig): ) session_token: Final = user_session.token if user_session is not None else None validated_headers: Final = {**self._copilot_headers(session_token), **parent_headers} - if session_token is not None: + if user_session is not None: # the caller's stored session token wins unconditionally, even over a # caller-supplied api_key or the parent's placeholder Authorization - validated_headers["Authorization"] = f"Bearer {session_token}" + pin_session_authorization(validated_headers, user_session.token, user_session.integration_id) # Add X-Initiator header based on message roles initiator: Final = self._determine_initiator(messages) diff --git a/litellm/llms/github_copilot/common_utils.py b/litellm/llms/github_copilot/common_utils.py index fcadb0cb2ef..140757ade1a 100644 --- a/litellm/llms/github_copilot/common_utils.py +++ b/litellm/llms/github_copilot/common_utils.py @@ -71,6 +71,7 @@ def get_copilot_default_headers( def pin_session_authorization( headers: MutableMapping[str, str], # mutable-ok: the helper fixes caller-owned headers in place session_token: str, + integration_id: str | None = None, ) -> None: """The stored per-user session token owns Authorization outright: drop every caller-supplied bearer header (any casing) so it can never displace the @@ -78,3 +79,7 @@ def pin_session_authorization( for key in tuple(k for k in headers if k.lower() == "authorization"): del headers[key] headers["Authorization"] = f"Bearer {session_token}" + if integration_id is not None: + for key in tuple(k for k in headers if k.lower() == "copilot-integration-id"): + del headers[key] # rebind-ok: pin the authenticated identity in caller-owned headers + headers["copilot-integration-id"] = integration_id # rebind-ok: pin the authenticated identity diff --git a/litellm/llms/github_copilot/embedding/transformation.py b/litellm/llms/github_copilot/embedding/transformation.py index 740397f0419..f817eaa0749 100644 --- a/litellm/llms/github_copilot/embedding/transformation.py +++ b/litellm/llms/github_copilot/embedding/transformation.py @@ -77,7 +77,7 @@ class GithubCopilotEmbeddingConfig(BaseEmbeddingConfig): session_headers: Final = cast( # cast-ok: both spreads are str-valued header dicts "dict[str, str]", {**get_copilot_default_headers(user_session.token), **headers} ) - pin_session_authorization(session_headers, user_session.token) + pin_session_authorization(session_headers, user_session.token, user_session.integration_id) return session_headers try: # Get GitHub Copilot API key via OAuth diff --git a/litellm/llms/github_copilot/messages/transformation.py b/litellm/llms/github_copilot/messages/transformation.py index bed855ca178..9e66c4c0f57 100644 --- a/litellm/llms/github_copilot/messages/transformation.py +++ b/litellm/llms/github_copilot/messages/transformation.py @@ -100,6 +100,7 @@ class GithubCopilotAnthropicMessagesConfig(AnthropicMessagesConfig): pin_session_authorization( cast("dict[str, str]", headers), # cast-ok: headers is a str-valued request dict at runtime user_session.token, + user_session.integration_id, ) headers["openai-intent"] = "messages-proxy" diff --git a/litellm/llms/github_copilot/per_user_auth.py b/litellm/llms/github_copilot/per_user_auth.py index 54c64c48084..98d336ae89b 100644 --- a/litellm/llms/github_copilot/per_user_auth.py +++ b/litellm/llms/github_copilot/per_user_auth.py @@ -19,7 +19,7 @@ from typing import ( from urllib.parse import urlparse import httpx -from pydantic import ConfigDict, TypeAdapter, ValidationError, with_config +from pydantic import ConfigDict, SecretStr, TypeAdapter, ValidationError, with_config from typing_extensions import NotRequired, ReadOnly, TypedDict from litellm.caching.in_memory_cache import InMemoryCache @@ -138,14 +138,26 @@ _COPILOT_ENDPOINTS: Final = TypeAdapter(_CopilotEndpointsPayload) _CACHED_SESSION_ENTRY: Final = TypeAdapter(tuple[str, str, str, float]) -@dataclass(frozen=True, slots=True, repr=False) +@dataclass(frozen=True, slots=True, repr=False, init=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.""" + """Resolved caller or App credentials, never accepted from request JSON.""" - token: str = field(repr=False) + _token: SecretStr = field(repr=False) api_base: str + is_app: bool = False + + def __init__(self, token: str, api_base: str, is_app: bool = False) -> None: + object.__setattr__(self, "_token", SecretStr(token)) + object.__setattr__(self, "api_base", api_base) + object.__setattr__(self, "is_app", is_app) + + @property + def token(self) -> str: + return self._token.get_secret_value() + + @property + def integration_id(self) -> str | None: + return "copilot-developer-cli" if self.is_app else None def __repr__(self) -> str: return f"GithubCopilotUserSession(api_base={self.api_base!r}, token=REDACTED)" @@ -701,22 +713,32 @@ def require_github_copilot_user_session(litellm_params: object) -> GithubCopilot session: Final = github_copilot_user_session_from(litellm_params) if session is not None: return session + from .app_auth import app_config + + if app_config(litellm_params) is not None: + raise BadRequestError( + message="GitHub App authentication requires a resolved installation session.", + llm_provider=_LLM_PROVIDER, + model="", + ) credential_name: Final = github_copilot_per_user_credential_name(litellm_params) if credential_name is not None: raise _connect_error(credential_name) return None -def is_github_copilot_per_user_request(kwargs: Mapping[str, object]) -> bool: +def is_github_copilot_per_user_request(kwargs: 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). The session kwarg is attached in ``utils.wrapper``/``wrapper_async`` after ``load_credentials_from_list``, before the cache lookup runs.""" - return isinstance(kwargs.get(GITHUB_COPILOT_USER_SESSION_KWARG_KEY), GithubCopilotUserSession) + session: Final = github_copilot_user_session_from(kwargs) + return session is not None and not session.is_app -def _without_authorization(headers: Mapping[str, object]) -> dict[str, object]: - return {k: v for k, v in headers.items() if k.lower() != "authorization"} +def _without_authorization(headers: Mapping[str, object], integration_id: str | None = None) -> dict[str, object]: + protected: Final = frozenset({"authorization", "copilot-integration-id"} if integration_id else {"authorization"}) + return {k: v for k, v in headers.items() if k.lower() not in protected} def _str_keyed_mapping(value: object) -> Mapping[str, object] | None: @@ -729,6 +751,7 @@ def _str_keyed_mapping(value: object) -> Mapping[str, object] | None: def _strip_caller_authorization( kwargs: dict[str, object], # mutable-ok: kwargs is the request mutation channel + integration_id: str | None = None, ) -> None: """Once a per-user session is attached, its token owns Authorization on the wire. Caller-supplied bearer headers would be re-merged by the HTTP handler @@ -738,7 +761,8 @@ def _strip_caller_authorization( value = kwargs.get(key) if isinstance(value, Mapping): stripped = _without_authorization( - cast("Mapping[str, object]", value) # cast-ok: Mapping-checked request header dict + cast("Mapping[str, object]", value), # cast-ok: Mapping-checked request header dict + integration_id, ) if len(stripped) != len( cast("Mapping[object, object]", value) # cast-ok: Mapping-checked request header dict @@ -754,7 +778,7 @@ def _strip_caller_authorization( if eh is not None: kwargs["optional_params"] = { # rebind-ok: kwargs is the request mutation channel **optional_params, - "extra_headers": _without_authorization(eh), + "extra_headers": _without_authorization(eh, integration_id), } litellm_params: Final = kwargs.get("litellm_params") if isinstance(litellm_params, dict): @@ -766,13 +790,21 @@ def _strip_caller_authorization( if eh2 is not None: kwargs["litellm_params"] = { # rebind-ok: kwargs is the request mutation channel **litellm_params, - "extra_headers": _without_authorization(eh2), + "extra_headers": _without_authorization(eh2, integration_id), } def attach_github_copilot_user_session( kwargs: dict[str, object], # mutable-ok: kwargs is the request mutation channel ) -> None: + from .app_auth import APP_TOKEN_MANAGER, app_config + + config: Final = app_config(kwargs) + if config is not None: + app_session: Final = APP_TOKEN_MANAGER.resolve(config) + kwargs[GITHUB_COPILOT_USER_SESSION_KWARG_KEY] = app_session # rebind-ok: kwargs is the request mutation channel + _strip_caller_authorization(kwargs, app_session.integration_id) + return 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): @@ -795,6 +827,14 @@ def attach_github_copilot_user_session( async def aattach_github_copilot_user_session( kwargs: dict[str, object], # mutable-ok: kwargs is the request mutation channel ) -> None: + from .app_auth import APP_TOKEN_MANAGER, app_config + + config: Final = app_config(kwargs) + if config is not None: + app_session: Final = await APP_TOKEN_MANAGER.aresolve(config) + kwargs[GITHUB_COPILOT_USER_SESSION_KWARG_KEY] = app_session # rebind-ok: kwargs is the request mutation channel + _strip_caller_authorization(kwargs, app_session.integration_id) + return 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): diff --git a/litellm/llms/github_copilot/responses/transformation.py b/litellm/llms/github_copilot/responses/transformation.py index f337d4ae0d3..e1cfab76db0 100644 --- a/litellm/llms/github_copilot/responses/transformation.py +++ b/litellm/llms/github_copilot/responses/transformation.py @@ -236,7 +236,7 @@ class GithubCopilotResponsesAPIConfig(OpenAIResponsesAPIConfig): "dict[str, str]", {**copilot_headers, **headers} ) if user_session is not None: - pin_session_authorization(merged_headers, user_session.token) + pin_session_authorization(merged_headers, user_session.token, user_session.integration_id) # Analyze input to determine additional headers input_param: Final = self._get_input_from_params(litellm_params) diff --git a/litellm/main.py b/litellm/main.py index 595612cb508..83ad140e504 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -2833,11 +2833,6 @@ def _complete_custom_openai( ) headers = headers or litellm.headers - outbound_headers: Final = ( - headers - if provider_config is None - else with_attribution_headers(provider_config.get_attribution_headers(), headers) - ) # Add GitHub Copilot headers (same as /responses endpoint does) if custom_llm_provider == "github_copilot": @@ -2854,13 +2849,27 @@ def _complete_custom_openai( copilot_headers: Final = get_copilot_default_headers( user_session.token if user_session is not None else Authenticator().get_api_key() ) + is_app_session: Final = user_session is not None and user_session.is_app + if headers and is_app_session: + copilot_headers.update( + cast("dict[str, str]", headers) # cast-ok: HTTP request headers have string names and values + ) if extra_headers: copilot_headers.update( cast("dict[str, str]", extra_headers) # cast-ok: extra_headers is a str-valued request dict ) if user_session is not None: - pin_session_authorization(copilot_headers, user_session.token) + pin_session_authorization(copilot_headers, user_session.token, user_session.integration_id) extra_headers = copilot_headers + if is_app_session: + headers = dict[str, object](copilot_headers) # rebind-ok: pin App headers in both transports + + request_headers: Final = cast("dict[str, object] | None", headers) # cast-ok: untyped request header boundary + outbound_headers: Final = ( + request_headers + if provider_config is None + else with_attribution_headers(provider_config.get_attribution_headers(), request_headers) + ) use_base_llm_http_handler: Final = get_secret_bool("EXPERIMENTAL_OPENAI_BASE_LLM_HTTP_HANDLER") @@ -6307,6 +6316,9 @@ def completion( *AGENTIC_LOOP_CONTROL_KWARGS_KEYS, "github_copilot_auth_type", "github_copilot_host", + "github_copilot_app_id", + "github_copilot_app_installation_id", + "github_copilot_app_private_key_ref", "github_copilot_user_session", PROVIDER_AFFINITY_HEADER_KWARG_KEY, "fireworks_forward_user_id", @@ -6904,6 +6916,7 @@ async def aembedding(*args, **kwargs) -> EmbeddingResponse: model=model, custom_llm_provider=custom_llm_provider, api_base=kwargs.get("api_base", None), + litellm_params=GenericLiteLLMParams(**kwargs), # pyright: ignore[reportUnknownArgumentType] # kwargs is the existing untyped SDK boundary ) # Await normally diff --git a/litellm/proxy/credential_endpoints/endpoints.py b/litellm/proxy/credential_endpoints/endpoints.py index 3026f2706ac..9175616bb57 100644 --- a/litellm/proxy/credential_endpoints/endpoints.py +++ b/litellm/proxy/credential_endpoints/endpoints.py @@ -107,6 +107,25 @@ def _normalized_github_copilot_values( return {**credential_values, GITHUB_COPILOT_HOST_KEY: host} +def _validate_github_app_values(values: Mapping[str, object]) -> None: + from litellm.exceptions import BadRequestError + from litellm.llms.github_copilot.app_auth import app_config + + try: + app_config( + {key: value for key, value in values.items() if key != "litellm_credential_name"}, + validate_stored_key=True, + ) + except BadRequestError: + raise HTTPException( + status_code=400, + detail=( + "Invalid GitHub App configuration. Use an App/client ID, positive installation ID, " + "and a valid RSA PEM private key or private-key secret reference." + ), + ) from None + + def _reject_non_admin_wif_fields( wif_fields: tuple[str, ...], user_api_key_dict: UserAPIKeyAuth, @@ -350,6 +369,7 @@ async def create_credential( credential_info, ) _reject_non_admin_wif_fields(server_owned_wif_fields_written(normalized_values), user_api_key_dict) + _validate_github_app_values(normalized_values) _reject_non_admin_wif_fields( await named_credential_wif_fields(credential.credential_name, prisma_client), user_api_key_dict ) @@ -1047,6 +1067,9 @@ async def update_credential( else patch ) merged_credential: Final = update_db_credential(db_credential, normalized_patch) + _validate_github_app_values( + _CREDENTIAL_DICT_ADAPTER.validate_python(decrypted_credential(merged_credential).credential_values) + ) credential_object_jsonified: Final = cast( # cast-ok: deep-copies a model_dump, so keys are str "dict[str, object]", jsonify_object(merged_credential.model_dump(exclude_none=True)) ) diff --git a/litellm/proxy/management_endpoints/model_management_endpoints.py b/litellm/proxy/management_endpoints/model_management_endpoints.py index 6d4dc01a8c1..6823edb767a 100644 --- a/litellm/proxy/management_endpoints/model_management_endpoints.py +++ b/litellm/proxy/management_endpoints/model_management_endpoints.py @@ -187,6 +187,7 @@ from litellm.types.router import ( GenericLiteLLMParams, LiteLLM_Params, ModelInfo, + server_owned_param_kind, updateDeployment, updateLiteLLMParams, ) @@ -2799,7 +2800,7 @@ class ModelManagementAuthChecks: raise ProxyException( message=( f"Only proxy admins can change the credentials of a deployment configured for " - f"workload identity federation or OAuth token exchange, or holding an oidc/ api_key secret " + f"{server_owned_param_kind(wif_fields[0])}, or holding an oidc/ api_key secret " f"reference ({wif_fields[0]!r})." ), type=ProxyErrorTypes.auth_error.value, diff --git a/litellm/proxy/public_endpoints/provider_create_fields.json b/litellm/proxy/public_endpoints/provider_create_fields.json index ecb6df00da0..42f2c0022c1 100644 --- a/litellm/proxy/public_endpoints/provider_create_fields.json +++ b/litellm/proxy/public_endpoints/provider_create_fields.json @@ -1881,6 +1881,46 @@ "field_type": "text", "options": null, "default_value": null + }, + { + "key": "github_copilot_app_id", + "label": "App client ID", + "placeholder": "Iv23...", + "tooltip": "The public client ID shown in GitHub Settings > Developer settings > GitHub Apps > your App. It usually starts with Iv. A numeric App ID also works as text. This is not the client secret.", + "required": false, + "field_type": "text", + "options": null, + "default_value": null + }, + { + "key": "github_copilot_app_installation_id", + "label": "Installation ID", + "placeholder": "12345678", + "tooltip": "The numeric ID in the URL when you open organization Settings > GitHub Apps > Installed GitHub Apps > Configure. It identifies this App installation, not the organization or App itself. The App needs Copilot Requests read permission.", + "required": false, + "field_type": "text", + "options": null, + "default_value": null + }, + { + "key": "github_copilot_app_private_key", + "label": "Private key (PEM)", + "placeholder": null, + "tooltip": "Upload the PEM downloaded from your GitHub App Credentials > Key pairs. LiteLLM encrypts it in its credential database. The saved key is never returned to this form. Upload a replacement here to rotate it. Use HTTPS for your dashboard.", + "required": false, + "field_type": "upload", + "options": null, + "default_value": null + }, + { + "key": "github_copilot_app_private_key_ref", + "label": "Private key secret reference", + "placeholder": "oidc/file//run/secrets/copilot-app.pem", + "tooltip": "Generate a PEM in your GitHub App Credentials > Key pairs. Store it securely on the server, then enter a reference such as oidc/file//run/secrets/copilot-app.pem or a secret name in your configured LiteLLM secret manager. os.environ/VARIABLE_NAME is also supported. Never paste PEM contents or a path on your laptop.", + "required": false, + "field_type": "text", + "options": null, + "default_value": null } ], "default_model_placeholder": "gpt-3.5-turbo" diff --git a/litellm/router_rust/invoke/shaping.py b/litellm/router_rust/invoke/shaping.py index d3e00d67c5b..fec11c3d642 100644 --- a/litellm/router_rust/invoke/shaping.py +++ b/litellm/router_rust/invoke/shaping.py @@ -491,8 +491,20 @@ def update_kwargs_with_deployment( existing_tags.append(credential_tag) # pyright: ignore[reportUnknownMemberType] # the request's own tags list metadata["tags"] = existing_tags + from litellm.llms.github_copilot.app_auth import app_config from litellm.llms.github_copilot.per_user_auth import github_copilot_per_user_credential_name + if ( + app_config(deployment_params) is not None + and "litellm_credential_name" in kwargs + and kwargs["litellm_credential_name"] != credential_name + ): + raise litellm_exceptions.BadRequestError( + message="litellm_credential_name cannot be overridden on a GitHub App deployment", + model=str(deployment_model_name), + llm_provider="github_copilot", + ) + configured_per_user_name: Final = github_copilot_per_user_credential_name(deployment_params) caller_credential_name: Final = kwargs.get("litellm_credential_name") if ( diff --git a/litellm/router_rust/surface/registry.py b/litellm/router_rust/surface/registry.py index 0392e81a9df..48387c5e605 100644 --- a/litellm/router_rust/surface/registry.py +++ b/litellm/router_rust/surface/registry.py @@ -119,6 +119,7 @@ from litellm.types.router import ( RoutingGroup, RoutingPlugin, TaggedPreRoutingStrategy, + holds_secret_pointer, validate_litellm_params_strictly, ) from litellm.types.utils import ( @@ -1123,7 +1124,12 @@ class HostRegistry(HostModelInfo): found, written into the entry's params.""" entry: Final = self._build_entry(build, index) references: Final = tuple(as_str_mapping(ref) or {} for ref in refs) - values: Final = tuple(get_secret(str(reference["name"])) for reference in references) + values: Final = tuple( + str(reference["name"]) + if holds_secret_pointer(str(reference["key"])) + else get_secret(str(reference["name"])) + for reference in references + ) entry.litellm_params.update( {str(reference["key"]): value for reference, value in zip(references, values, strict=True)} ) diff --git a/litellm/router_utils/cooldown_handlers.py b/litellm/router_utils/cooldown_handlers.py index 9e3e0189fd0..26558a7e1a4 100644 --- a/litellm/router_utils/cooldown_handlers.py +++ b/litellm/router_utils/cooldown_handlers.py @@ -333,13 +333,11 @@ def _is_cooldown_required( def _is_per_user_provider_request(request_kwargs: Mapping[str, object]) -> bool: from litellm.llms.github_copilot.per_user_auth import ( - github_copilot_user_session_from, is_github_copilot_per_user_request, ) - return ( - is_github_copilot_per_user_request(request_kwargs) - or github_copilot_user_session_from(request_kwargs.get("litellm_params")) is not None + return is_github_copilot_per_user_request(request_kwargs) or is_github_copilot_per_user_request( + request_kwargs.get("litellm_params") ) diff --git a/litellm/types/litellm_params.py b/litellm/types/litellm_params.py index 87f8e210782..26c5bd4237c 100644 --- a/litellm/types/litellm_params.py +++ b/litellm/types/litellm_params.py @@ -96,6 +96,10 @@ class ProviderConnection: chatgpt_auth_file: str | None = None github_copilot_auth_type: str | None = None github_copilot_host: str | None = None + github_copilot_app_id: str | int | None = None + github_copilot_app_installation_id: str | int | None = None + github_copilot_app_private_key_ref: str | None = None + github_copilot_app_private_key: str | None = None github_copilot_user_session: object | None = None token_exchange_endpoint: str | None = None token_exchange_profile: str | None = None diff --git a/litellm/types/router.py b/litellm/types/router.py index 90431543b8c..fe046a3b305 100644 --- a/litellm/types/router.py +++ b/litellm/types/router.py @@ -75,6 +75,9 @@ from .utils import ( ModelResponse, StandardLoggingRoutingDecision, ) +from .utils import ( + github_copilot_oauth_litellm_params as _github_copilot_oauth_litellm_params, +) from .utils import ( server_owned_wif_litellm_params as _server_owned_wif_litellm_params, ) @@ -496,6 +499,10 @@ class CredentialLiteLLMParams(LiteLLMBaseModel): token_exchange_profile: str | None = None token_exchange_scope: str | None = None token_exchange_audience: str | None = None + github_copilot_app_id: str | int | None = None + github_copilot_app_installation_id: str | int | None = None + github_copilot_app_private_key_ref: str | None = None + github_copilot_app_private_key: str | None = None def _api_key_secret_reference(fields: Mapping[str, object]) -> tuple[str, ...]: @@ -1588,8 +1595,13 @@ def reject_server_owned_wif_fields(body: Mapping[str, object]) -> None: for param in _server_owned_wif_litellm_params: if param in submitted_names: raise ValueError( - f"Rejected Request: {param} is a server-owned workload identity federation or OAuth token exchange " - "parameter " + f"Rejected Request: {param} is a server-owned {server_owned_param_kind(param)} parameter " "and cannot be set in a request body. A proxy admin configures it on the deployment " "or on a stored credential." ) + + +def server_owned_param_kind(param: str) -> str: + if param in _github_copilot_oauth_litellm_params: + return "GitHub Copilot authentication" + return "workload identity federation or OAuth token exchange" diff --git a/litellm/types/utils.py b/litellm/types/utils.py index b8baa07a8bf..99ab0f1fb4b 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -4494,7 +4494,14 @@ oauth_token_exchange_litellm_params: Final = ( "token_exchange_profile", "token_exchange_scope", ) -github_copilot_oauth_litellm_params: Final = ("github_copilot_auth_type", "github_copilot_host") +github_copilot_oauth_litellm_params: Final = ( + "github_copilot_auth_type", + "github_copilot_host", + "github_copilot_app_id", + "github_copilot_app_installation_id", + "github_copilot_app_private_key_ref", + "github_copilot_app_private_key", +) OIDC_SECRET_REFERENCE_PREFIX: Final = "oidc/" API_KEY_SECRET_REFERENCE_REJECTION: Final = ( "Rejected Request: api_key holds an oidc/ secret reference, which only a deployment or credential a proxy " @@ -4605,7 +4612,9 @@ server_owned_wif_litellm_params: Final = ( + github_copilot_oauth_litellm_params + ("chatgpt_auth_file",) ) -secret_bearing_wif_litellm_params: Final = tuple(sorted(WIF_SECRET_BEARING_KEYS)) +secret_bearing_wif_litellm_params: Final = tuple( + sorted(WIF_SECRET_BEARING_KEYS | {"github_copilot_app_private_key_ref", "github_copilot_app_private_key"}) +) all_litellm_params = [ # rebind-ok: two star imports in litellm/__init__.py re-bind it *OWNED_KWARG_NAMES, diff --git a/litellm/ui/litellm-dashboard/src/components/add_model/provider_auth_types.test.ts b/litellm/ui/litellm-dashboard/src/components/add_model/provider_auth_types.test.ts index f0b739b877d..a3073b170d5 100644 --- a/litellm/ui/litellm-dashboard/src/components/add_model/provider_auth_types.test.ts +++ b/litellm/ui/litellm-dashboard/src/components/add_model/provider_auth_types.test.ts @@ -11,8 +11,10 @@ import { import { Providers } from "../provider_info_helpers"; describe("provider auth types", () => { - it("resolves provider enum keys and display names", () => { + it("resolves provider enum keys, canonical names, and display names", () => { expect(authTypesFor("MICROSOFT_365_COPILOT")).toEqual(PROVIDER_AUTH_TYPES.MICROSOFT_365_COPILOT); + expect(authTypesFor("microsoft_365_copilot")).toEqual(PROVIDER_AUTH_TYPES.MICROSOFT_365_COPILOT); + expect(authTypesFor("github_copilot")).toEqual(PROVIDER_AUTH_TYPES.GITHUB_COPILOT); expect(authTypesFor(Providers.MICROSOFT_365_COPILOT)).toEqual(PROVIDER_AUTH_TYPES.MICROSOFT_365_COPILOT); expect(authTypesFor(null)).toEqual([]); expect(authTypesFor("unknown")).toEqual([]); @@ -84,11 +86,24 @@ describe("provider auth types", () => { const authTypes = authTypesFor("GITHUB_COPILOT"); expect(inferAuthTypeId(authTypes, { api_key: "" })).toBe("shared_device_login"); + expect(inferAuthTypeId(authTypes, { github_copilot_auth_type: "github_app" })).toBe("github_app"); + expect(hiddenAuthFieldKeys(authTypes, "github_app")).toEqual(["api_base", "api_key"]); expect(inferAuthTypeId(authTypes, { github_copilot_auth_type: "per_user_oauth" })).toBe("per_user_oauth"); - expect(hiddenAuthFieldKeys(authTypes, "per_user_oauth")).toEqual(["api_base", "api_key"]); + expect(hiddenAuthFieldKeys(authTypes, "per_user_oauth")).toEqual([ + "api_base", + "api_key", + "github_copilot_app_id", + "github_copilot_app_installation_id", + "github_copilot_app_private_key_ref", + "github_copilot_app_private_key", + ]); expect(hiddenAuthFieldKeys(authTypes, "shared_device_login")).toEqual([ "github_copilot_auth_type", "github_copilot_host", + "github_copilot_app_id", + "github_copilot_app_installation_id", + "github_copilot_app_private_key_ref", + "github_copilot_app_private_key", ]); }); }); diff --git a/litellm/ui/litellm-dashboard/src/components/add_model/provider_auth_types.ts b/litellm/ui/litellm-dashboard/src/components/add_model/provider_auth_types.ts index 77a08c45393..c2ef4b3924d 100644 --- a/litellm/ui/litellm-dashboard/src/components/add_model/provider_auth_types.ts +++ b/litellm/ui/litellm-dashboard/src/components/add_model/provider_auth_types.ts @@ -35,6 +35,22 @@ export const PROVIDER_AUTH_TYPES: Partial { const providerKeys: readonly (keyof typeof Providers)[] = Object.keys(Providers) as (keyof typeof Providers)[]; const providerKey = - provider === null ? undefined : providerKeys.find((key) => key === provider || Providers[key] === provider); + provider === null + ? undefined + : providerKeys.find((key) => key.toLowerCase() === provider.toLowerCase() || Providers[key] === provider); return providerKey === undefined ? EMPTY_PROVIDER_AUTH_TYPES @@ -78,6 +96,9 @@ export const authTypeFieldKeys = (provider: string | null): readonly string[] => .filter((fieldKey, index, fieldKeys) => fieldKeys.indexOf(fieldKey) === index); export const inferAuthTypeId = (authTypes: readonly ProviderAuthType[], values: Record): string => + authTypes.find( + ({ fixedValues }) => fixedValues && Object.entries(fixedValues).every(([key, value]) => values[key] === value), + )?.id ?? authTypes.find(({ requiredFieldKeys }) => requiredFieldKeys.some( (fieldKey) => values[fieldKey] !== undefined && values[fieldKey] !== null && values[fieldKey] !== "", diff --git a/litellm/ui/litellm-dashboard/src/components/add_model/provider_specific_fields.tsx b/litellm/ui/litellm-dashboard/src/components/add_model/provider_specific_fields.tsx index e787e9df20c..4f52375518e 100644 --- a/litellm/ui/litellm-dashboard/src/components/add_model/provider_specific_fields.tsx +++ b/litellm/ui/litellm-dashboard/src/components/add_model/provider_specific_fields.tsx @@ -101,6 +101,7 @@ const ProviderSpecificFieldsContent: React.FC = ({ const { t } = useTranslation(); const selectedProviderEnum = Providers[selectedProvider as keyof typeof Providers] as Providers; const form = useFormContext(); + const hasAppKeyReference = Boolean(form.watch("github_copilot_app_private_key_ref")); const authTypes = authTypesFor(selectedProvider); const availableAuthTypes = React.useMemo( () => @@ -124,11 +125,28 @@ const ProviderSpecificFieldsContent: React.FC = ({ onAuthTypeChange?.(selectedAuthType.id); } }, [onAuthTypeChange, selectedAuthType]); + React.useEffect(() => { + if (!credentialOnlySelected) { + Object.entries(selectedAuthType?.fixedValues ?? {}).forEach(([key, value]) => form.setValue(key, value)); + } + }, [credentialOnlySelected, selectedAuthType, form]); + const pickCredentialsFile = - (onLoaded: (contents: string) => void) => (event: React.ChangeEvent) => { + (field: ProviderCredentialField, onLoaded: (contents: string) => void) => + (event: React.ChangeEvent) => { const file = event.target.files?.[0]; event.target.value = ""; - if (file?.type === "application/json") { + if (!file) return; + if (field.key === "github_copilot_app_private_key") { + if (!file.name.toLowerCase().endsWith(".pem") || file.size > 16384) { + form.setError(field.key, { message: t("addModel.providerAuthTypes.githubApp.invalidFile") }); + return; + } + readTextFile(file, (contents) => { + form.clearErrors(field.key); + onLoaded(contents); + }); + } else if (file.type === "application/json") { readTextFile(file, onLoaded); } }; @@ -219,11 +237,17 @@ const ProviderSpecificFieldsContent: React.FC = ({ return hiddenFieldKeys ? providerFields.filter((field) => !hiddenFieldKeys.includes(field.key)) : providerFields; } - const fieldsToHide = [...(hiddenFieldKeys ?? []), ...authTypeHiddenFieldKeys]; + const fieldsToHide = [...(hiddenFieldKeys ?? []), ...authTypeHiddenFieldKeys, "github_copilot_app_private_key_ref"]; + const requiresAppKey = selectedAuthType?.id === "github_app" && !hasAppKeyReference; return providerFields .filter((field) => !fieldsToHide.includes(field.key)) - .map((field) => (selectedAuthType?.requiredFieldKeys.includes(field.key) ? { ...field, required: true } : field)); + .map((field) => + selectedAuthType?.requiredFieldKeys.includes(field.key) || + (requiresAppKey && field.key === "github_copilot_app_private_key") + ? { ...field, required: true } + : field, + ); }, [ providerFields, hiddenFieldKeys, @@ -231,6 +255,7 @@ const ProviderSpecificFieldsContent: React.FC = ({ authTypeHiddenFieldKeys, selectedAuthType, credentialOnlySelected, + hasAppKeyReference, ]); const hasApiVersionField = React.useMemo(() => allFields.some((field) => field.key === "api_version"), [allFields]); @@ -280,6 +305,7 @@ const ProviderSpecificFieldsContent: React.FC = ({ } if (field.type === "upload") { + const hasAppKey = Boolean(control.value) || hasAppKeyReference; return ( <> + ) : ( + + ) + } + /> {hint} diff --git a/litellm/ui/litellm-dashboard/src/i18n/locales/en.json b/litellm/ui/litellm-dashboard/src/i18n/locales/en.json index f4e30c873ee..4b8539ac0e2 100644 --- a/litellm/ui/litellm-dashboard/src/i18n/locales/en.json +++ b/litellm/ui/litellm-dashboard/src/i18n/locales/en.json @@ -8772,6 +8772,7 @@ } }, "shared": { + "fieldInformation": "{{label}} information", "resourceActivity": { "title": "Activity", "ownUsage": "Showing your usage only.", @@ -17588,6 +17589,13 @@ "staticToken": { "label": "Static delegated access token", "description": "Sends one pre-acquired delegated Microsoft Graph token for every caller." + }, + "githubApp": { + "label": "GitHub App", + "description": "Authenticate a service with a GitHub App installation. Usage is billed to the organization. LiteLLM refreshes installation tokens automatically.", + "uploadHelp": "Upload your GitHub App PEM below. LiteLLM encrypts it in its credential database and only shows a saved-key status. Use HTTPS and protect your proxy encryption key. No Enterprise license is required.", + "invalidFile": "Choose a .pem file no larger than 16 KiB.", + "keyProvided": "Private key supplied. Upload a replacement to change it." } }, "classificationMethodConfig": { diff --git a/litellm/ui/litellm-dashboard/src/lib/http/schema.d.ts b/litellm/ui/litellm-dashboard/src/lib/http/schema.d.ts index 4244073f72d..a2e9578d5f2 100644 --- a/litellm/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/litellm/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -37905,6 +37905,14 @@ export interface components { fireworks_forward_user_id?: boolean | null; /** Gcs Bucket Name */ gcs_bucket_name?: string | null; + /** Github Copilot App Id */ + github_copilot_app_id?: string | number | null; + /** Github Copilot App Installation Id */ + github_copilot_app_installation_id?: string | number | null; + /** Github Copilot App Private Key */ + github_copilot_app_private_key?: string | null; + /** Github Copilot App Private Key Ref */ + github_copilot_app_private_key_ref?: string | null; /** Google Maps Grounding Cost Per Query */ google_maps_grounding_cost_per_query?: number | null; /** Input Cost Per Audio Per Second */ @@ -54610,6 +54618,14 @@ export interface components { fireworks_forward_user_id?: boolean | null; /** Gcs Bucket Name */ gcs_bucket_name?: string | null; + /** Github Copilot App Id */ + github_copilot_app_id?: string | number | null; + /** Github Copilot App Installation Id */ + github_copilot_app_installation_id?: string | number | null; + /** Github Copilot App Private Key */ + github_copilot_app_private_key?: string | null; + /** Github Copilot App Private Key Ref */ + github_copilot_app_private_key_ref?: string | null; /** Google Maps Grounding Cost Per Query */ google_maps_grounding_cost_per_query?: number | null; /** Input Cost Per Audio Per Second */ diff --git a/litellm/utils.py b/litellm/utils.py index eae9addc8e0..9e9868f1100 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -830,7 +830,7 @@ def _named_credential_values(kwargs: Mapping[str, object]) -> Mapping[str, objec { key: copy.deepcopy(value) for key, value in credential_api_key_secret_reference(credential.credential_values).items() - if key not in kwargs + if key not in kwargs and key != "github_copilot_app_private_key" } ) @@ -1493,7 +1493,7 @@ def function_setup( if isinstance(messages, list) and len(messages) > 0: try: from litellm.litellm_core_utils.get_llm_provider_logic import ( - get_llm_provider, + resolve_model_and_provider_for_metadata, ) from litellm.litellm_core_utils.prompt_templates.factory import ( THOUGHT_SIGNATURE_SEPARATOR, @@ -1503,10 +1503,11 @@ def function_setup( custom_llm_provider = kwargs.get("custom_llm_provider") # If custom_llm_provider not in kwargs, try to determine it from the model - if not custom_llm_provider and model: + metadata_model: Final = _OPAQUE_VALUE.validate_python(model) + if not custom_llm_provider and isinstance(metadata_model, str) and metadata_model: try: - _, custom_llm_provider, _, _ = get_llm_provider( - model=model, + _, custom_llm_provider = resolve_model_and_provider_for_metadata( + model=metadata_model, custom_llm_provider=custom_llm_provider, ) except Exception: diff --git a/tests/unit/integrations/test_prometheus_invalid_key_filtering.py b/tests/unit/integrations/test_prometheus_invalid_key_filtering.py index 3f3e2e0d465..b3136ddfcb5 100644 --- a/tests/unit/integrations/test_prometheus_invalid_key_filtering.py +++ b/tests/unit/integrations/test_prometheus_invalid_key_filtering.py @@ -11,7 +11,10 @@ from datetime import datetime from typing import Final from unittest.mock import Mock, patch +import httpx import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa from fastapi import HTTPException from prometheus_client import REGISTRY from prometheus_client.samples import Sample @@ -19,6 +22,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.app_auth import AppConfig, AppTokenManager from litellm.llms.github_copilot.per_user_auth import GithubCopilotUserSession from litellm.proxy._types import ProxyErrorTypes, ProxyException, UserAPIKeyAuth @@ -526,6 +530,23 @@ def _deployment_entra_rejection_kwargs() -> dict[str, object]: } | {"exception": raised} +def _deployment_github_app_rejection_kwargs() -> dict[str, object]: + private_key: Final = ( + rsa.generate_private_key(public_exponent=65537, key_size=2048) + .private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()) + .decode() + ) + github: Final = httpx.MockTransport(lambda _: httpx.Response(404, json={"message": "Not Found"})) + tokens: Final = AppTokenManager( + client_factory=lambda: httpx.Client(transport=github), secret_reader=lambda _: private_key + ) + with pytest.raises(litellm.AuthenticationError) as rejected: + tokens.resolve(AppConfig("test-client", 123, "os.environ/TEST_APP_KEY")) + return { + key: value for key, value in _provider_auth_failure_kwargs().items() if key not in CALLED_THE_PROVIDER + } | {"exception": rejected.value} + + def _metric_values(name: str, model_id: str) -> list[float]: return [ sample.value @@ -569,6 +590,20 @@ class TestProviderAuthFailures: assert _metric_values("litellm_llm_api_failed_requests_metric_total", "deployment-1") == [1.0] assert _metric_values("litellm_deployment_failure_responses_total", "deployment-1") == [1.0] + @pytest.mark.asyncio + async def test_log_failure_event_records_github_rejecting_the_app_installation_token_before_the_call( + self, prometheus_logger + ): + await prometheus_logger.async_log_failure_event( + kwargs=_deployment_github_app_rejection_kwargs(), + response_obj=None, + start_time=None, + end_time=None, + ) + + assert _metric_values("litellm_llm_api_failed_requests_metric_total", "deployment-1") == [1.0] + assert _metric_values("litellm_deployment_failure_responses_total", "deployment-1") == [1.0] + def test_deployment_failure_metrics_record_a_provider_401(self, prometheus_logger): prometheus_logger.set_llm_deployment_failure_metrics(_provider_auth_failure_kwargs()) diff --git a/tests/unit/litellm_core_utils/llm_response_utils/test_get_api_base.py b/tests/unit/litellm_core_utils/llm_response_utils/test_get_api_base.py index 07e153f537b..f01b7a08258 100644 --- a/tests/unit/litellm_core_utils/llm_response_utils/test_get_api_base.py +++ b/tests/unit/litellm_core_utils/llm_response_utils/test_get_api_base.py @@ -1,3 +1,6 @@ +from collections.abc import Mapping +from typing import Final + import pytest import respx @@ -5,6 +8,8 @@ import litellm from litellm.litellm_core_utils.llm_response_utils import get_api_base as get_api_base_module from litellm.llms.chatgpt.common_utils import CHATGPT_API_BASE from litellm.llms.github_copilot.common_utils import DEFAULT_GITHUB_COPILOT_API_BASE +from litellm.llms.github_copilot.per_user_auth import GithubCopilotUserSession +from litellm.types.router import GenericLiteLLMParams @pytest.fixture @@ -112,3 +117,47 @@ def test_base_url_alias_is_reported_as_the_api_base(): litellm.get_api_base(model="groq/whisper-large-v3", optional_params={"base_url": ""}) == "https://api.groq.com/openai/v1" ) + + +@pytest.mark.usefixtures("isolated_token_dirs") +def test_github_app_deployment_reports_the_copilot_host_it_calls_over_a_configured_api_base(): + model: Final = "github_copilot/gpt-5.5" + params: Final = { + "api_base": "https://admin-set.internal/v1", + "github_copilot_auth_type": "github_app", + "github_copilot_app_id": "test-client", + "github_copilot_app_installation_id": 123, + "github_copilot_app_private_key_ref": "os.environ/TEST_APP_KEY", + } + called_api_base: Final = litellm.get_llm_provider( + model=model, api_base=params["api_base"], litellm_params=GenericLiteLLMParams(**params) + )[3] + + assert called_api_base != params["api_base"] + assert litellm.get_api_base(model=model, optional_params=params) == called_api_base + + +@pytest.mark.usefixtures("isolated_token_dirs") +@pytest.mark.parametrize( + "app_settings", + [ + {"github_copilot_auth_type": "github_app", "litellm_credential_name": "never-loaded"}, + {"github_copilot_app_id": "left-over-client"}, + ], +) +def test_incomplete_github_app_settings_report_the_configured_api_base_instead_of_raising( + app_settings: Mapping[str, str], +): + params: Final = {"api_base": "https://admin-set.internal/v1", **app_settings} + + assert litellm.get_api_base(model="github_copilot/gpt-5.5", optional_params=params) == params["api_base"] + + +@pytest.mark.usefixtures("isolated_token_dirs") +def test_per_user_session_still_reports_the_configured_api_base(): + params: Final = { + "api_base": "https://admin-set.internal/v1", + "github_copilot_user_session": GithubCopilotUserSession("user-token", "https://api.business.githubcopilot.com"), + } + + assert litellm.get_api_base(model="github_copilot/gpt-5.5", optional_params=params) == params["api_base"] diff --git a/tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py b/tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py index c3a62592039..b6cd1dd792d 100644 --- a/tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py +++ b/tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py @@ -7,6 +7,7 @@ transformations for the Responses API. Source: litellm/llms/github_copilot/responses/transformation.py """ +from types import SimpleNamespace from unittest.mock import patch, MagicMock import httpx @@ -216,7 +217,7 @@ class TestGithubCopilotResponsesAPITransformation: config = GithubCopilotResponsesAPIConfig() # Create mock litellm_params with input attribute - mock_litellm_params = MagicMock() + mock_litellm_params = SimpleNamespace() mock_litellm_params.input = [ { "role": "user", @@ -240,7 +241,7 @@ class TestGithubCopilotResponsesAPITransformation: config = GithubCopilotResponsesAPIConfig() # Create mock litellm_params with input attribute - mock_litellm_params = MagicMock() + mock_litellm_params = SimpleNamespace() mock_litellm_params.input = [ {"role": "user", "content": "Hello"}, {"role": "assistant", "content": "Hi"}, diff --git a/tests/unit/llms/github_copilot/test_app_auth.py b/tests/unit/llms/github_copilot/test_app_auth.py new file mode 100644 index 00000000000..bc2d0149fda --- /dev/null +++ b/tests/unit/llms/github_copilot/test_app_auth.py @@ -0,0 +1,988 @@ +import asyncio +import ipaddress +import json +import ssl +import threading +from collections.abc import Callable, Iterator, Mapping +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path +from typing import Final + +import httpx +import jwt +import pytest +import respx +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import NameOID + +import litellm +from litellm.exceptions import AuthenticationError, BadRequestError, RateLimitError, ServiceUnavailableError +from litellm.litellm_core_utils.clientside_credential_handler import get_dynamic_litellm_params +from litellm.llms.github_copilot import app_auth +from litellm.llms.github_copilot.app_auth import APP_FIELDS, AppConfig, AppTokenManager, app_config +from litellm.llms.github_copilot.per_user_auth import ( + GithubCopilotUserSession, + is_github_copilot_per_user_request, + require_github_copilot_user_session, +) +from litellm.types.router import holds_secret_pointer, reject_server_owned_wif_fields +from litellm.types.utils import CredentialItem + +_NOW: Final = 1_800_000_000.0 +_CONFIG: Final = AppConfig("test-client", 123, "os.environ/TEST_APP_KEY") + + +@dataclass +class Clock: + now: float = _NOW + elapsed: float = 0 + + def wall(self) -> float: + return self.now + + def monotonic(self) -> float: + return self.elapsed + + def advance(self, seconds: float) -> None: + self.now += seconds + self.elapsed += seconds + + +@pytest.fixture +def private_key() -> str: + key: Final = rsa.generate_private_key(public_exponent=65537, key_size=2048) + return key.private_bytes( + serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption() + ).decode() + + +def token_payload(token: str = "installation-token", expires: float = _NOW + 3600) -> Mapping[str, object]: + return { + "token": token, + "expires_at": datetime.fromtimestamp(expires, timezone.utc).isoformat(), + "permissions": {"organization_copilot_requests": "read"}, + } + + +def manager( + private_key: str, + respond: Callable[[httpx.Request], httpx.Response], + clock: Clock, + secret_reader: Callable[[str], str | None] | None = None, +) -> AppTokenManager: + return AppTokenManager( + client_factory=lambda: httpx.Client(transport=httpx.MockTransport(respond)), + secret_reader=secret_reader or (lambda _: private_key), + clock=clock.wall, + monotonic=clock.monotonic, + ) + + +def test_jwt_signature_and_minimum_permissions(private_key: str) -> None: + requests: Final[list[httpx.Request]] = [] + + def respond(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(201, json=token_payload()) + + session: Final = manager(private_key, respond, Clock()).resolve(_CONFIG) + request: Final = requests[0] + signing_key: Final = serialization.load_pem_private_key(private_key.encode(), password=None) + assert isinstance(signing_key, rsa.RSAPrivateKey) + claims: Final = jwt.decode( + request.headers["Authorization"].removeprefix("Bearer "), + signing_key.public_key(), + algorithms=["RS256"], + options={"verify_exp": False, "verify_iat": False}, + ) + assert claims == {"iss": _CONFIG.app_id, "iat": int(_NOW) - 60, "exp": int(_NOW) + 540} + assert str(request.url) == _CONFIG.token_url + assert json.loads(request.content) == {"permissions": {"organization_copilot_requests": "read"}} + assert session.token == "installation-token" + assert session.api_base == _CONFIG.api_base + assert "installation-token" not in repr(session) + + +def test_refreshes_before_expiry_and_reuses_new_token(private_key: str) -> None: + clock: Final = Clock() + requests: Final[list[httpx.Request]] = [] + + def respond(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(201, json=token_payload(f"token-{len(requests)}", clock.now + 3600)) + + auth: Final = manager(private_key, respond, clock) + assert auth.resolve(_CONFIG).token == "token-1" + clock.advance(3479) + assert auth.resolve(_CONFIG).token == "token-1" + clock.advance(1) + assert auth.resolve(_CONFIG).token == "token-2" + assert auth.resolve(_CONFIG).token == "token-2" + assert len(requests) == 2 + + +def test_monotonic_expiry_survives_wall_clock_rollback(private_key: str) -> None: + clock: Final = Clock() + requests: Final[list[httpx.Request]] = [] + + def respond(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(201, json=token_payload(f"token-{len(requests)}", clock.now + 3600)) + + auth: Final = manager(private_key, respond, clock) + assert auth.resolve(_CONFIG).token == "token-1" + clock.elapsed = 3480 + clock.now -= 600 + assert auth.resolve(_CONFIG).token == "token-2" + + +@pytest.mark.parametrize("async_calls", [False, True]) +async def test_concurrent_callers_mint_one_token(private_key: str, async_calls: bool) -> None: + requests: Final[list[httpx.Request]] = [] + + def respond(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(201, json=token_payload()) + + auth: Final = manager(private_key, respond, Clock()) + if async_calls: + sessions: Final = await asyncio.gather(*(auth.aresolve(_CONFIG) for _ in range(20))) + assert {session.token for session in sessions} == {"installation-token"} + else: + with ThreadPoolExecutor(max_workers=20) as pool: + tokens: Final = tuple(pool.map(lambda _: auth.resolve(_CONFIG).token, range(20))) + assert set(tokens) == {"installation-token"} + assert len(requests) == 1 + + +def test_app_installation_host_and_rotated_key_never_share_tokens( + private_key: str, monkeypatch: pytest.MonkeyPatch +) -> None: + requests: Final[list[httpx.Request]] = [] + + def respond(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(201, json=token_payload(f"token-{len(requests)}")) + + def upload(key: str) -> None: + monkeypatch.setattr( + litellm, + "credential_list", + [ + CredentialItem( + credential_name="uploaded", + credential_values={ + "github_copilot_auth_type": "github_app", + "github_copilot_app_id": _CONFIG.app_id, + "github_copilot_app_installation_id": "123", + "github_copilot_app_private_key": key, + }, + credential_info={"custom_llm_provider": "github_copilot"}, + ) + ], + ) + + auth: Final = manager(private_key, respond, Clock()) + configs: Final = ( + _CONFIG, + AppConfig("other-app", 123, _CONFIG.private_key_ref), + AppConfig(_CONFIG.app_id, 456, _CONFIG.private_key_ref), + AppConfig(_CONFIG.app_id, 123, _CONFIG.private_key_ref, "example.ghe.com"), + ) + assert tuple(auth.resolve(config).token for config in configs) == ("token-1", "token-2", "token-3", "token-4") + upload(private_key) + uploaded: Final = app_config({"litellm_credential_name": "uploaded"}) + assert uploaded is not None + assert auth.resolve(uploaded).token == "token-5" + upload(private_key + "\n") + assert auth.resolve(uploaded).token == "token-6" + assert auth.resolve(configs[1]).token == "token-2" + + +async def test_cached_token_is_served_without_reading_the_key_secret_again(private_key: str) -> None: + reads: Final[list[str]] = [] + mints: Final[list[httpx.Request]] = [] + + def read_secret(ref: str) -> str: + reads.append(ref) + if len(reads) > 1: + raise RuntimeError("secret backend is down") + return private_key + + def respond(request: httpx.Request) -> httpx.Response: + mints.append(request) + return httpx.Response(201, json=token_payload()) + + auth: Final = manager(private_key, respond, Clock(), read_secret) + assert auth.resolve(_CONFIG).token == "installation-token" + assert {(await auth.aresolve(_CONFIG)).token for _ in range(3)} == {"installation-token"} + assert auth.resolve(_CONFIG).token == "installation-token" + assert (len(reads), len(mints)) == (1, 1) + + +async def test_callers_waiting_on_a_mint_leave_the_default_thread_pool_free(private_key: str) -> None: + started: Final = threading.Event() + release: Final = threading.Event() + mints: Final[list[httpx.Request]] = [] + + def respond(request: httpx.Request) -> httpx.Response: + mints.append(request) + started.set() + _ = release.wait(10) + return httpx.Response(201, json=token_payload()) + + auth: Final = manager(private_key, respond, Clock()) + loop: Final = asyncio.get_running_loop() + loop.set_default_executor(ThreadPoolExecutor(max_workers=2)) + try: + callers: Final = tuple(asyncio.create_task(auth.aresolve(_CONFIG)) for _ in range(10)) + while not started.is_set(): + await asyncio.sleep(0.01) + assert await asyncio.wait_for(loop.run_in_executor(None, lambda: "unrelated job ran"), 2) == ( + "unrelated job ran" + ) + finally: + release.set() + assert {session.token for session in await asyncio.gather(*callers)} == {"installation-token"} + assert len(mints) == 1 + + +async def test_cancelled_caller_still_finishes_the_refresh_for_later_callers(private_key: str) -> None: + pool_busy: Final = threading.Event() + mints: Final[list[httpx.Request]] = [] + + def respond(request: httpx.Request) -> httpx.Response: + mints.append(request) + return httpx.Response(201, json=token_payload()) + + auth: Final = manager(private_key, respond, Clock()) + loop: Final = asyncio.get_running_loop() + loop.set_default_executor(ThreadPoolExecutor(max_workers=1)) + blocker: Final = loop.run_in_executor(None, pool_busy.wait, 10) + first_caller: Final = asyncio.create_task(auth.aresolve(_CONFIG)) + await asyncio.sleep(0.05) + _ = first_caller.cancel() + pool_busy.set() + _ = await blocker + with pytest.raises(asyncio.CancelledError): + await first_caller + assert (await asyncio.wait_for(auth.aresolve(_CONFIG), 2)).token == "installation-token" + assert len(mints) == 1 + + +@pytest.mark.parametrize( + ("status", "error_type"), + [ + (401, AuthenticationError), + (403, AuthenticationError), + (404, AuthenticationError), + (422, BadRequestError), + (429, RateLimitError), + (500, ServiceUnavailableError), + ], +) +def test_failed_refresh_never_returns_old_token_or_leaks_response( + private_key: str, status: int, error_type: type[Exception] +) -> None: + clock: Final = Clock() + requests: Final[list[httpx.Request]] = [] + + def respond(request: httpx.Request) -> httpx.Response: + requests.append(request) + if len(requests) == 1: + return httpx.Response(201, json=token_payload()) + return httpx.Response(status, json={"message": private_key + "installation-token"}) + + auth: Final = manager(private_key, respond, clock) + assert auth.resolve(_CONFIG).token == "installation-token" + clock.advance(3480) + for _ in range(2): + with pytest.raises(error_type) as error: + auth.resolve(_CONFIG) + assert "installation-token" not in str(error.value) + assert "PRIVATE KEY" not in str(error.value) + assert error.value.__cause__ is None + assert len(requests) == 2 + + +@pytest.mark.parametrize( + "payload", + [ + {}, + {**token_payload(), "token": ""}, + {**token_payload(), "token": "secret\r\ninjected-header"}, + {**token_payload(), "expires_at": "not-a-date"}, + {**token_payload(), "expires_at": "2027-01-15T08:00:00"}, + token_payload(expires=_NOW), + {**token_payload(), "permissions": {"contents": "write"}}, + ], +) +def test_rejects_invalid_tokens_without_logging_payload(private_key: str, payload: Mapping[str, object]) -> None: + auth: Final = manager(private_key, lambda _: httpx.Response(201, json=payload), Clock()) + with pytest.raises((AuthenticationError, ServiceUnavailableError)) as error: + auth.resolve(_CONFIG) + assert "installation-token" not in str(error.value) + + +def test_redirect_does_not_forward_app_jwt(private_key: str) -> None: + requests: Final[list[httpx.Request]] = [] + + def respond(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(307, headers={"Location": "https://attacker.example/token"}) + + auth: Final = manager(private_key, respond, Clock()) + with pytest.raises(ServiceUnavailableError, match="HTTP 307"): + auth.resolve(_CONFIG) + assert tuple(str(request.url) for request in requests) == (_CONFIG.token_url,) + + +@pytest.mark.parametrize("secret", [None, "", "invalid-private-key"]) +def test_missing_or_invalid_key_never_contacts_github(secret: str | None) -> None: + def unexpected_request(request: httpx.Request) -> httpx.Response: + pytest.fail(f"Unexpected request to {request.url}") + + auth: Final = manager("", unexpected_request, Clock(), lambda _: secret) + with pytest.raises(AuthenticationError): + auth.resolve(_CONFIG) + + +def test_named_credential_owns_auth_configuration(monkeypatch: pytest.MonkeyPatch) -> None: + values: Final = { + "github_copilot_auth_type": "github_app", + "github_copilot_app_id": "stored-app", + "github_copilot_app_installation_id": "123", + "github_copilot_app_private_key_ref": "os.environ/STORED_KEY", + } + monkeypatch.setattr( + litellm, + "credential_list", + [CredentialItem(credential_name="app", credential_values=values, credential_info={})], + ) + assert app_config({"litellm_credential_name": "app", **values, "github_copilot_app_id": "attacker"}) == AppConfig( + "stored-app", 123, "os.environ/STORED_KEY" + ) + + +@pytest.mark.parametrize("field", APP_FIELDS) +def test_app_configuration_is_server_owned(field: str) -> None: + with pytest.raises(ValueError, match="server-owned GitHub Copilot authentication parameter"): + reject_server_owned_wif_fields({field: "attacker"}) + assert holds_secret_pointer("github_copilot_app_private_key_ref") + + +@pytest.mark.parametrize("override", [{"api_base": "https://attacker.example"}, {"api_key": "attacker"}]) +def test_clientside_override_cannot_disable_app_auth(override: Mapping[str, str]) -> None: + with pytest.raises(BadRequestError, match="do not accept"): + get_dynamic_litellm_params( + { + "github_copilot_auth_type": "github_app", + "github_copilot_app_id": "app", + "github_copilot_app_installation_id": 123, + "github_copilot_app_private_key_ref": "os.environ/KEY", + }, + dict(override), + ) + + +@pytest.mark.parametrize( + "surface,async_call", + [ + ("chat", False), + ("chat", True), + ("responses", False), + ("responses", True), + ("messages", True), + ("embeddings", False), + ("embeddings", True), + ], +) +@pytest.mark.parametrize("uploaded", [False, True]) +async def test_public_calls_pin_app_identity_and_keep_credentials_out_of_body( + private_key: str, + monkeypatch: pytest.MonkeyPatch, + respx_mock: respx.MockRouter, + surface: str, + async_call: bool, + uploaded: bool, +) -> None: + clock: Final = Clock() + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + monkeypatch.setattr(app_auth, "APP_TOKEN_MANAGER", AppTokenManager(clock=clock.wall, monotonic=clock.monotonic)) + monkeypatch.setenv("TEST_APP_KEY", private_key) + token_route: Final = respx_mock.post(_CONFIG.token_url).respond(201, json=token_payload()) + upstream: Final = respx_mock.post(host="api.githubcopilot.com").respond(200, json=_model_payload(surface)) + auth_fields: Final = { + "github_copilot_auth_type": "github_app", + "github_copilot_app_id": _CONFIG.app_id, + "github_copilot_app_installation_id": str(_CONFIG.installation_id), + **( + {"github_copilot_app_private_key": private_key} + if uploaded + else {"github_copilot_app_private_key_ref": _CONFIG.private_key_ref} + ), + } + if uploaded: + monkeypatch.setattr( + litellm, + "credential_list", + [ + CredentialItem( + credential_name="uploaded", + credential_values=auth_fields, + credential_info={"custom_llm_provider": "github_copilot"}, + ) + ], + ) + kwargs: Final = { + **({"litellm_credential_name": "uploaded"} if uploaded else auth_fields), + "api_base": "https://attacker.example", + "api_key": "attacker-key", + "extra_headers": {"aUtHoRiZaTiOn": "Bearer attacker", "Copilot-Integration-ID": "attacker", "x-keep": "ok"}, + } + if surface == "chat": + chat_args: Final = { + "model": "github_copilot/claude-sonnet-5.5", + "messages": [{"role": "user", "content": "hi"}], + **kwargs, + } + chat: Final = await litellm.acompletion(**chat_args) if async_call else litellm.completion(**chat_args) + assert chat.choices[0].message.content == "OK" + elif surface == "responses": + response_args: Final = {"model": "github_copilot/gpt-6.1-sol", "input": "hi", **kwargs} + response: Final = ( + await litellm.aresponses(**response_args) if async_call else litellm.responses(**response_args) + ) + assert response.status == "completed" + elif surface == "messages": + message_args: Final = { + "model": "github_copilot/claude-sonnet-5.5", + "messages": [{"role": "user", "content": "hi"}], + "max_tokens": 16, + **kwargs, + } + message: Final = ( + await litellm.anthropic.messages.acreate(**message_args) + if async_call + else litellm.anthropic.messages.create(**message_args) + ) + assert message["content"] == [{"type": "text", "text": "OK"}] + else: + embedding_args: Final = {"model": "github_copilot/text-embedding-3-small", "input": ["hi"], **kwargs} + embedding: Final = ( + await litellm.aembedding(**embedding_args) if async_call else litellm.embedding(**embedding_args) + ) + assert embedding.data[0]["embedding"] == [0.1, 0.2] + assert token_route.call_count == 1 + assert upstream.call_count == 1 + request: Final = upstream.calls.last.request + assert request.headers["authorization"] == "Bearer installation-token" + assert request.headers["copilot-integration-id"] == "copilot-developer-cli" + if surface != "embeddings": + assert request.headers["x-keep"] == "ok" + assert not any(field in json.loads(request.content) for field in APP_FIELDS) + assert private_key.encode() not in request.content + assert kwargs["extra_headers"]["aUtHoRiZaTiOn"] == "Bearer attacker" + + +def _model_payload(surface: str) -> Mapping[str, object]: + if surface == "chat": + return { + "id": "chatcmpl-app", + "object": "chat.completion", + "created": 1, + "model": "claude-sonnet-5.5", + "choices": [{"index": 0, "message": {"role": "assistant", "content": "OK"}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 4, "completion_tokens": 2, "total_tokens": 6}, + } + if surface == "responses": + return { + "id": "response-app", + "object": "response", + "created_at": 1, + "status": "completed", + "model": "gpt-6.1-sol", + "output": [], + "usage": {"input_tokens": 4, "output_tokens": 2, "total_tokens": 6}, + } + if surface == "messages": + return { + "id": "msg-app", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-5.5", + "content": [{"type": "text", "text": "OK"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 4, "output_tokens": 2}, + } + return { + "object": "list", + "model": "text-embedding-3-small", + "data": [{"object": "embedding", "index": 0, "embedding": [0.1, 0.2]}], + "usage": {"prompt_tokens": 4, "total_tokens": 4}, + } + + +def test_app_session_is_deployment_owned_and_shares_the_response_cache() -> None: + from litellm.caching.caching_handler import _is_response_cache_excluded + from litellm.router_utils.cooldown_handlers import _is_per_user_provider_request + + params: Final = { + "github_copilot_user_session": GithubCopilotUserSession("app-token", _CONFIG.api_base, is_app=True) + } + per_user: Final = {"github_copilot_user_session": GithubCopilotUserSession("user-token", _CONFIG.api_base)} + assert not is_github_copilot_per_user_request(params) + assert not _is_per_user_provider_request({"litellm_params": params}) + assert not _is_response_cache_excluded("github_copilot/claude-sonnet-5.5", params) + assert _is_response_cache_excluded("github_copilot/claude-sonnet-5.5", per_user) + + +@pytest.mark.parametrize("async_call", [False, True]) +@pytest.mark.parametrize("app", [False, True]) +async def test_caller_headers_keep_device_login_wire_headers_and_cannot_unpin_app_identity( + private_key: str, + monkeypatch: pytest.MonkeyPatch, + respx_mock: respx.MockRouter, + tmp_path: Path, + app: bool, + async_call: bool, +) -> None: + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + monkeypatch.setattr(app_auth, "APP_TOKEN_MANAGER", AppTokenManager()) + monkeypatch.setenv("GITHUB_COPILOT_TOKEN_DIR", str(tmp_path)) + monkeypatch.setenv("TEST_APP_KEY", private_key) + _ = (tmp_path / "access-token").write_text("gho_device_token") + _ = respx_mock.post(_CONFIG.token_url).respond(201, json=token_payload()) + upstream: Final = respx_mock.post(host="api.githubcopilot.com").respond(200, json=_model_payload("chat")) + app_fields: Final = { + "github_copilot_auth_type": "github_app", + "github_copilot_app_id": _CONFIG.app_id, + "github_copilot_app_installation_id": str(_CONFIG.installation_id), + "github_copilot_app_private_key_ref": _CONFIG.private_key_ref, + } + args: Final = { + "model": "github_copilot/claude-sonnet-5.5", + "messages": [{"role": "user", "content": "hi"}], + "headers": {"x-forwarded": "client", "Copilot-Integration-ID": "client-integration"}, + **(app_fields if app else {}), + } + _ = await litellm.acompletion(**args) if async_call else litellm.completion(**args) + sent: Final = upstream.calls.last.request.headers + assert sent["x-forwarded"] == "client" + if app: + assert sent["authorization"] == "Bearer installation-token" + assert sent.get_list("copilot-integration-id") == ["copilot-developer-cli"] + assert "editor-version" in sent + else: + assert sent.get_list("copilot-integration-id") == ["client-integration"] + assert "editor-version" not in sent + + +@pytest.mark.parametrize("async_call", [False, True]) +async def test_app_deployment_whose_credential_is_not_loaded_fails_closed_before_any_github_call( + monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter, async_call: bool +) -> None: + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + monkeypatch.setattr(litellm, "credential_list", []) + router: Final = litellm.Router( + model_list=[ + { + "model_name": "copilot", + "litellm_params": { + "model": "github_copilot/claude-sonnet-5.5", + "litellm_credential_name": "copilot-service-a", + "github_copilot_auth_type": "github_app", + }, + } + ], + num_retries=0, + ) + messages: Final = [{"role": "user", "content": "hi"}] + + async def call() -> object: + if async_call: + return await router.acompletion(model="copilot", messages=messages) + return router.completion(model="copilot", messages=messages) + + with pytest.raises(BadRequestError, match="GitHub App authentication requires"): + await call() + assert respx_mock.calls.call_count == 0 + + +async def test_unloaded_credential_name_keeps_inline_app_auth_and_other_providers_working( + private_key: str, monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter +) -> None: + clock: Final = Clock() + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + monkeypatch.setattr(litellm, "credential_list", []) + monkeypatch.setattr(app_auth, "APP_TOKEN_MANAGER", AppTokenManager(clock=clock.wall, monotonic=clock.monotonic)) + monkeypatch.setenv("TEST_APP_KEY", private_key) + respx_mock.post(_CONFIG.token_url).respond(201, json=token_payload()) + copilot: Final = respx_mock.post(host="api.githubcopilot.com").respond(200, json=_model_payload("chat")) + openai: Final = respx_mock.post(host="api.openai.com").respond(200, json=_model_payload("chat")) + router: Final = litellm.Router( + model_list=[ + { + "model_name": "copilot", + "litellm_params": { + "model": "github_copilot/claude-sonnet-5.5", + "litellm_credential_name": "not-loaded", + "github_copilot_auth_type": "github_app", + "github_copilot_app_id": _CONFIG.app_id, + "github_copilot_app_installation_id": _CONFIG.installation_id, + "github_copilot_app_private_key_ref": _CONFIG.private_key_ref, + }, + }, + { + "model_name": "openai", + "litellm_params": { + "model": "openai/gpt-5.5", + "litellm_credential_name": "not-loaded", + "api_key": "sk-test", + }, + }, + ], + num_retries=0, + ) + messages: Final = [{"role": "user", "content": "hi"}] + + await router.acompletion(model="copilot", messages=messages) + await router.acompletion(model="openai", messages=messages) + + assert copilot.calls.last.request.headers["authorization"] == "Bearer installation-token" + assert openai.calls.last.request.headers["authorization"] == "Bearer sk-test" + + +async def test_numeric_app_id_from_yaml_boots_and_signs_as_that_app( + private_key: str, monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter +) -> None: + clock: Final = Clock() + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + monkeypatch.setattr(app_auth, "APP_TOKEN_MANAGER", AppTokenManager(clock=clock.wall, monotonic=clock.monotonic)) + monkeypatch.setenv("TEST_APP_KEY", private_key) + mint: Final = respx_mock.post(_CONFIG.token_url).respond(201, json=token_payload()) + upstream: Final = respx_mock.post(host="api.githubcopilot.com").respond(200, json=_model_payload("chat")) + router: Final = litellm.Router( + model_list=[ + { + "model_name": "copilot", + "litellm_params": { + "model": "github_copilot/claude-sonnet-5.5", + "github_copilot_auth_type": "github_app", + "github_copilot_app_id": 424242, + "github_copilot_app_installation_id": _CONFIG.installation_id, + "github_copilot_app_private_key_ref": _CONFIG.private_key_ref, + }, + } + ], + num_retries=0, + ) + + await router.acompletion(model="copilot", messages=[{"role": "user", "content": "hi"}]) + + app_jwt: Final = mint.calls.last.request.headers["authorization"].removeprefix("Bearer ") + assert jwt.decode(app_jwt, options={"verify_signature": False})["iss"] == "424242" + assert upstream.calls.last.request.headers["authorization"] == "Bearer installation-token" + + +def test_app_mode_without_session_cannot_fall_back_to_user_login() -> None: + with pytest.raises(BadRequestError, match="resolved installation session"): + require_github_copilot_user_session( + { + "github_copilot_auth_type": "github_app", + "github_copilot_app_id": _CONFIG.app_id, + "github_copilot_app_installation_id": _CONFIG.installation_id, + "github_copilot_app_private_key_ref": _CONFIG.private_key_ref, + } + ) + + +async def test_router_isolates_two_apps_and_rejects_credential_switching( + private_key: str, monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter +) -> None: + clock: Final = Clock() + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + monkeypatch.setattr(app_auth, "APP_TOKEN_MANAGER", AppTokenManager(clock=clock.wall, monotonic=clock.monotonic)) + monkeypatch.setenv("TEST_APP_KEY", private_key) + credentials: Final = [ + CredentialItem( + credential_name=name, + credential_values={ + "github_copilot_auth_type": "github_app", + "github_copilot_app_id": name, + "github_copilot_app_installation_id": installation, + "github_copilot_app_private_key_ref": "os.environ/TEST_APP_KEY", + }, + credential_info={}, + ) + for name, installation in (("service-a", 123), ("service-b", 456)) + ] + monkeypatch.setattr(litellm, "credential_list", credentials) + app_a: Final = respx_mock.post(_CONFIG.token_url).respond(201, json=token_payload("token-a")) + app_b: Final = respx_mock.post("https://api.github.com/app/installations/456/access_tokens").respond( + 201, json=token_payload("token-b") + ) + upstream: Final = respx_mock.post(host="api.githubcopilot.com").respond(200, json=_model_payload("chat")) + router: Final = litellm.Router( + model_list=[ + { + "model_name": name, + "litellm_params": { + "model": "github_copilot/claude-sonnet-5.5", + "litellm_credential_name": name, + }, + "model_info": {"id": name}, + } + for name in ("service-a", "service-b") + ], + num_retries=0, + ) + for name, token in (("service-a", "token-a"), ("service-b", "token-b"), ("service-a", "token-a")): + response: Final = await router.acompletion(model=name, messages=[{"role": "user", "content": "hi"}]) + assert response.choices[0].message.content == "OK" + assert response._hidden_params["model_id"] == name + assert upstream.calls.last.request.headers["authorization"] == f"Bearer {token}" + assert app_a.call_count == app_b.call_count == 1 + for override in ("service-b", None): + with pytest.raises(BadRequestError, match="litellm_credential_name cannot be overridden"): + await router.acompletion( + model="service-a", + messages=[{"role": "user", "content": "hi"}], + litellm_credential_name=override, + ) + assert upstream.call_count == 3 + + +@pytest.mark.parametrize( + "status,headers,cooldown", + [ + (429, {"retry-after": "90"}, 90), + (403, {"x-ratelimit-remaining": "0", "x-ratelimit-reset": str(int(_NOW + 120))}, 120), + (429, {"retry-after": "invalid"}, 60), + (429, {"retry-after": "9" * 400}, 3600), + ], +) +def test_mint_rate_limits_are_cached_and_expose_retry_after( + private_key: str, status: int, headers: Mapping[str, str], cooldown: int +) -> None: + clock: Final = Clock() + requests: Final[list[httpx.Request]] = [] + + def respond(request: httpx.Request) -> httpx.Response: + requests.append(request) + if len(requests) == 1: + return httpx.Response(status, headers=headers) + return httpx.Response(201, json=token_payload(expires=clock.now + 3600)) + + auth: Final = manager(private_key, respond, clock) + with pytest.raises(RateLimitError) as error: + auth.resolve(_CONFIG) + assert error.value.headers == {"retry-after": str(cooldown)} + clock.advance(cooldown - 1) + with pytest.raises(RateLimitError) as cached: + auth.resolve(_CONFIG) + assert cached.value.headers == {"retry-after": "1"} + assert len(requests) == 1 + clock.advance(1) + assert auth.resolve(_CONFIG).token == "installation-token" + assert len(requests) == 2 + + +def test_secret_backend_failure_is_sanitized() -> None: + def failing_secret_reader(ref: str) -> str: + raise ValueError("sensitive-backend-details") + + auth: Final = AppTokenManager(secret_reader=failing_secret_reader) + with pytest.raises(AuthenticationError) as error: + auth.resolve(_CONFIG) + assert "sensitive-backend-details" not in str(error.value) + assert error.value.__cause__ is None + assert error.value.__suppress_context__ or error.value.__context__ is None + + +def test_token_cache_is_bounded(private_key: str, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(app_auth, "_CACHE_SIZE", 2) + requests: Final[list[httpx.Request]] = [] + + def respond(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(201, json=token_payload(f"token-{len(requests)}")) + + auth: Final = manager(private_key, respond, Clock()) + for installation in (123, 456, 789): + auth.resolve(AppConfig(_CONFIG.app_id, installation, _CONFIG.private_key_ref)) + assert auth.resolve(_CONFIG).token == "token-4" + assert len(requests) == 4 + + +@pytest.mark.parametrize( + "override", + [ + {"github_copilot_auth_type": "per_user"}, + {"github_copilot_app_id": ""}, + {"github_copilot_app_installation_id": "../123"}, + {"github_copilot_app_installation_id": 0}, + {"github_copilot_app_installation_id": True}, + {"github_copilot_app_private_key_ref": "-----BEGIN PRIVATE KEY-----"}, + {"github_copilot_app_private_key_ref": "oidc/https://attacker.example"}, + {"github_copilot_host": "attacker.example"}, + ], +) +def test_invalid_app_configuration_fails_closed(override: Mapping[str, object]) -> None: + with pytest.raises(BadRequestError, match="GitHub App authentication requires"): + app_config( + { + "github_copilot_auth_type": "github_app", + "github_copilot_app_id": _CONFIG.app_id, + "github_copilot_app_installation_id": _CONFIG.installation_id, + "github_copilot_app_private_key_ref": _CONFIG.private_key_ref, + **override, + } + ) + + +def test_network_failure_does_not_expose_jwt(private_key: str) -> None: + def fail(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError(request.headers["authorization"], request=request) + + auth: Final = manager(private_key, fail, Clock()) + with pytest.raises(ServiceUnavailableError) as error: + auth.resolve(_CONFIG) + assert "Bearer" not in str(error.value) + assert error.value.__context__ is None + + +class _CreatedHandler(BaseHTTPRequestHandler): + def do_POST(self) -> None: + self.send_response(201) + self.send_header("Content-Length", "0") + self.end_headers() + + def log_message(self, format: str, *args: object) -> None: + pass + + +@pytest.fixture +def private_ca_server(tmp_path: Path) -> Iterator[tuple[str, Path]]: + key: Final = rsa.generate_private_key(public_exponent=65537, key_size=2048) + name: Final = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "127.0.0.1")]) + now: Final = datetime.now(timezone.utc) + cert: Final = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - timedelta(minutes=1)) + .not_valid_after(now + timedelta(hours=1)) + .add_extension(x509.SubjectAlternativeName([x509.IPAddress(ipaddress.ip_address("127.0.0.1"))]), critical=False) + .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True) + .sign(key, hashes.SHA256()) + ) + ca_file: Final = tmp_path / "ca.pem" + ca_file.write_bytes(cert.public_bytes(serialization.Encoding.PEM)) + key_file: Final = tmp_path / "key.pem" + key_file.write_bytes( + key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()) + ) + context: Final = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(ca_file, key_file) + server: Final = HTTPServer(("127.0.0.1", 0), _CreatedHandler) + server.socket = context.wrap_socket(server.socket, server_side=True) + thread: Final = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + yield f"https://127.0.0.1:{server.server_port}/app/installations/123/access_tokens", ca_file + server.shutdown() + server.server_close() + + +def test_installation_token_requests_trust_the_configured_ca_bundle( + private_ca_server: tuple[str, Path], monkeypatch: pytest.MonkeyPatch +) -> None: + url, ca_file = private_ca_server + monkeypatch.setenv("SSL_VERIFY", str(ca_file)) + with app_auth._new_client() as client: + assert client.post(url).status_code == 201 + + +@pytest.mark.parametrize("is_app", [False, True]) +def test_session_serialization_redacts_bearer_token(is_app: bool) -> None: + from litellm.types.router import GenericLiteLLMParams + + session: Final = GithubCopilotUserSession("sensitive-bearer", _CONFIG.api_base, is_app=is_app) + params: Final = GenericLiteLLMParams(github_copilot_user_session=session) + assert session.token == "sensitive-bearer" + assert "sensitive-bearer" not in repr(session) + assert "sensitive-bearer" not in str(params.model_dump()) + assert "sensitive-bearer" not in params.model_dump_json() + + +def test_uploaded_key_is_kept_out_of_model_kwargs_and_used_for_signing( + private_key: str, monkeypatch: pytest.MonkeyPatch +) -> None: + from litellm.litellm_core_utils.credential_accessor import CredentialAccessor + from litellm.utils import with_named_credential + + values: Final = { + "github_copilot_auth_type": "github_app", + "github_copilot_app_id": "test-client", + "github_copilot_app_installation_id": "123", + "github_copilot_app_private_key": private_key, + } + monkeypatch.setattr( + litellm, + "credential_list", + [ + CredentialItem( + credential_name="uploaded", + credential_values=values, + credential_info={"custom_llm_provider": "github_copilot"}, + ) + ], + ) + assert "github_copilot_app_private_key" not in CredentialAccessor.get_credential_values("uploaded") + assert "github_copilot_app_private_key" not in with_named_credential({"litellm_credential_name": "uploaded"}) + config: Final = app_config({"litellm_credential_name": "uploaded"}) + assert config is not None + assert private_key not in repr(config) + assert config.private_key_ref == "" + with pytest.raises(BadRequestError): + app_config(values) + app_config(values, validate_stored_key=True) + + def respond(request: httpx.Request) -> httpx.Response: + jwt.decode( + request.headers["Authorization"].split()[1], + serialization.load_pem_private_key(private_key.encode(), None).public_key(), + algorithms=["RS256"], + options={"verify_exp": False, "verify_iat": False}, + ) + return httpx.Response(201, json=token_payload()) + + assert manager("unused", respond, Clock()).resolve(config).token == "installation-token" + + +@pytest.mark.parametrize("kind", ["weak-rsa", "ec", "encrypted"]) +def test_uploaded_key_rejects_unsupported_key_types(kind: str) -> None: + from cryptography.hazmat.primitives.asymmetric import ec + + key: Final = ( + ec.generate_private_key(ec.SECP256R1()) + if kind == "ec" + else rsa.generate_private_key(public_exponent=65537, key_size=1024 if kind == "weak-rsa" else 2048) + ) + pem: Final = key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.BestAvailableEncryption(b"test-password") + if kind == "encrypted" + else serialization.NoEncryption(), + ).decode() + with pytest.raises(BadRequestError, match="unencrypted RSA PEM") as error: + app_auth.validate_private_key(pem) + assert pem not in str(error.value) diff --git a/tests/unit/proxy/auth/test_auth_utils.py b/tests/unit/proxy/auth/test_auth_utils.py index bcdacd0996e..09f168032bf 100644 --- a/tests/unit/proxy/auth/test_auth_utils.py +++ b/tests/unit/proxy/auth/test_auth_utils.py @@ -474,10 +474,7 @@ def _client_router_config(placement: str, litellm_params: dict[str, str]) -> dic @pytest.mark.parametrize("placement", ["model_list", "default_litellm_params"]) def test_every_server_owned_auth_param_is_refused_from_a_client_router_config(param: str, placement: str): user_config: Final = _client_router_config(placement, {"model": "openai/gpt-5.4-nano", param: "attacker-chosen"}) - with pytest.raises( - ValueError, - match="server-owned workload identity federation or OAuth token exchange parameter", - ): + with pytest.raises(ValueError, match=f"{param} is a server-owned .* parameter and cannot be set in a request body"): is_request_body_safe( request_body={"model": "gpt-5.4-nano", "user_config": user_config}, general_settings={"allow_client_side_credentials": True}, diff --git a/tests/unit/proxy/credential_endpoints/test_endpoints.py b/tests/unit/proxy/credential_endpoints/test_endpoints.py index f08fab25c57..6e8474c8e02 100644 --- a/tests/unit/proxy/credential_endpoints/test_endpoints.py +++ b/tests/unit/proxy/credential_endpoints/test_endpoints.py @@ -2076,10 +2076,10 @@ def test_update_credential_still_accepts_a_body_without_credential_values(creden from contextlib import contextmanager as _ctx -from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache -from tests.unit.redis.stores.auth_cache_double import shared_cache from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler +from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache from litellm.proxy.credential_endpoints import user_provider_credentials as upc +from tests.unit.redis.stores.auth_cache_double import shared_cache _DEVICE_CODE_URL = "https://github.com/login/device/code" _ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token" @@ -3456,3 +3456,162 @@ class TestCredentialWritesHoldTheReconcileLock: assert response["success"] is True assert litellm.credential_list == [] + + +def _github_app_values() -> dict[str, str]: + return { + "github_copilot_auth_type": "github_app", + "github_copilot_app_id": "test-client", + "github_copilot_app_installation_id": "123", + "github_copilot_app_private_key_ref": "oidc/file//run/secrets/copilot-app.pem", + } + + +@pytest.mark.parametrize("credential_info", [{"custom_llm_provider": "GITHUB_COPILOT"}, {}]) +@pytest.mark.parametrize("invalid", ["-----BEGIN PRIVATE KEY-----", "oidc/https://attacker.example", ""]) +def test_create_github_app_rejects_invalid_key_reference_before_storage( + credential_store: _InstallCredentialStore, invalid: str, credential_info: dict[str, str] +) -> None: + create: Final = AsyncMock() + credential_store(create=create) + response: Final = _create_credential( + { + "credential_name": "copilot-app", + "credential_values": {**_github_app_values(), "github_copilot_app_private_key_ref": invalid}, + "credential_info": credential_info, + } + ) + assert response.status_code == 400 + create.assert_not_awaited() + + +def test_create_github_app_preserves_the_secret_reference(credential_store: _InstallCredentialStore) -> None: + create: Final = AsyncMock() + credential_store(create=create) + response: Final = _create_credential( + { + "credential_name": "copilot-app", + "credential_values": _github_app_values(), + "credential_info": {"custom_llm_provider": "GITHUB_COPILOT"}, + } + ) + assert response.status_code == 200, response.text + assert CredentialAccessor.find_credential("copilot-app").credential_values == _github_app_values() + create.assert_awaited_once() + + +def test_update_github_app_validates_merged_values_before_storage(credential_store: _InstallCredentialStore) -> None: + stored: Final = CredentialItem( + credential_name="copilot-app", + credential_values=_github_app_values(), + credential_info={"custom_llm_provider": "github_copilot"}, + ) + update: Final = AsyncMock() + credential_store(find_by_name=AsyncMock(return_value=stored), update_by_name=update) + response: Final = _patch_credential( + "copilot-app", + { + "credential_info": {}, + "credential_values": {"github_copilot_app_private_key_ref": "-----BEGIN PRIVATE KEY-----"}, + }, + ) + assert response.status_code == 400 + update.assert_not_awaited() + rotated: Final = _patch_credential( + "copilot-app", + { + "credential_info": {}, + "credential_values": {"github_copilot_app_private_key_ref": "oidc/file//run/secrets/rotated.pem"}, + }, + ) + assert rotated.status_code == 200, rotated.text + update.assert_awaited_once() + + +@pytest.fixture +def app_pem() -> str: + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import rsa + + return ( + rsa.generate_private_key(public_exponent=65537, key_size=2048) + .private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()) + .decode() + ) + + +def test_uploaded_app_key_is_encrypted_at_rest_and_masked_on_read( + credential_store: _InstallCredentialStore, app_pem: str +) -> None: + from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper + + create: Final = AsyncMock() + credential_store(create=create) + values: Final = {key: value for key, value in _github_app_values().items() if not key.endswith("_ref")} + response: Final = _create_credential( + { + "credential_name": "copilot-upload", + "credential_values": {**values, "github_copilot_app_private_key": app_pem}, + "credential_info": {"custom_llm_provider": "github_copilot"}, + } + ) + assert response.status_code == 200, response.text + encrypted: Final = json.loads(create.call_args.kwargs["data"]["credential_values"])[ + "github_copilot_app_private_key" + ] + assert app_pem not in encrypted + assert decrypt_value_helper(encrypted, "github_copilot_app_private_key") == app_pem + listed: Final = _list_credentials() + assert listed.status_code == 200 + assert app_pem not in listed.text + assert app_pem.splitlines()[1] not in listed.text + assert "****" in listed.json()["credentials"][0]["credential_values"]["github_copilot_app_private_key"] + + +def test_uploaded_app_key_replaces_reference_with_explicit_deletion( + credential_store: _InstallCredentialStore, app_pem: str +) -> None: + stored: Final = _encrypted_row( + CredentialItem( + credential_name="copilot-app", + credential_values=_github_app_values(), + credential_info={"custom_llm_provider": "github_copilot"}, + ) + ) + update: Final = AsyncMock() + credential_store(find_by_name=AsyncMock(return_value=stored), update_by_name=update) + response: Final = _patch_credential( + "copilot-app", + { + "credential_info": {"custom_llm_provider": "github_copilot"}, + "credential_values": {"github_copilot_app_private_key": app_pem}, + "credential_values_to_delete": ["github_copilot_app_private_key_ref"], + }, + ) + assert response.status_code == 200, response.text + assert _written_values(update) == { + "github_copilot_auth_type": "github_app", + "github_copilot_app_id": "test-client", + "github_copilot_app_installation_id": "123", + "github_copilot_app_private_key": app_pem, + } + assert app_pem not in update.await_args.kwargs["data"]["credential_values"] + + +@pytest.mark.parametrize("pem", ["not-a-private-key", "x" * 16385, "-----BEGIN PRIVATE KEY----- secret-invalid"]) +def test_invalid_uploaded_app_key_is_rejected_without_echoing_it( + credential_store: _InstallCredentialStore, pem: str +) -> None: + create: Final = AsyncMock() + credential_store(create=create) + values: Final = {key: value for key, value in _github_app_values().items() if not key.endswith("_ref")} + response: Final = _create_credential( + { + "credential_name": "copilot-upload", + "credential_values": {**values, "github_copilot_app_private_key": pem}, + "credential_info": {"custom_llm_provider": "github_copilot"}, + } + ) + assert response.status_code == 400 + assert pem not in response.text + create.assert_not_awaited() diff --git a/tests/unit/proxy/health_endpoints/test_health_endpoints.py b/tests/unit/proxy/health_endpoints/test_health_endpoints.py index 4aff6adadd3..3dcf5db6af6 100644 --- a/tests/unit/proxy/health_endpoints/test_health_endpoints.py +++ b/tests/unit/proxy/health_endpoints/test_health_endpoints.py @@ -3789,6 +3789,8 @@ def test_clean_endpoint_data_strips_extra_headers_and_aws_session_token(): "anthropic_keycloak_client_secret_ref", "anthropic_identity_token_file", "openai_identity_token_file", + "github_copilot_app_private_key", + "github_copilot_app_private_key_ref", "vertex_credentials", "vertex_ai_credentials", "extra_headers", diff --git a/tests/unit/proxy/management_endpoints/test_model_management_endpoints.py b/tests/unit/proxy/management_endpoints/test_model_management_endpoints.py index 2f8456c6d0d..be72e0f1fae 100644 --- a/tests/unit/proxy/management_endpoints/test_model_management_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_model_management_endpoints.py @@ -11325,6 +11325,7 @@ class TestNonAdminCannotSetPerUserOauthOnModel: user_api_key_dict=non_admin, ) assert "proxy admin" in str(exc_info.value.message).lower() + assert "GitHub Copilot authentication" in str(exc_info.value.message) assert exc_info.value.param == "github_copilot_auth_type" mock_prisma.db.litellm_proxymodeltable.create.assert_not_called() diff --git a/tests/unit/proxy/test_credential_slot_registry.py b/tests/unit/proxy/test_credential_slot_registry.py index 47566b853a0..a846b2b90e4 100644 --- a/tests/unit/proxy/test_credential_slot_registry.py +++ b/tests/unit/proxy/test_credential_slot_registry.py @@ -138,6 +138,8 @@ DEPLOYMENT_PARAM_CLASSIFICATION: Final[Mapping[str, Classification]] = MappingPr "anthropic_keycloak_client_secret_ref": Unplanted(), "anthropic_keycloak_scope": NotSecret("OAuth scope string"), "openai_identity_token_file": Unplanted(), + "github_copilot_app_private_key": Unplanted(), + "github_copilot_app_private_key_ref": Unplanted(), } ) diff --git a/tests/unit/types/test_litellm_params.py b/tests/unit/types/test_litellm_params.py index 6bedc240029..044005620fd 100644 --- a/tests/unit/types/test_litellm_params.py +++ b/tests/unit/types/test_litellm_params.py @@ -93,6 +93,10 @@ CONNECTION_NAMES: Final = ( "litellm_credential_name", "github_copilot_auth_type", "github_copilot_host", + "github_copilot_app_id", + "github_copilot_app_installation_id", + "github_copilot_app_private_key_ref", + "github_copilot_app_private_key", "configurable_clientside_auth_params", "use_xai_oauth", "chatgpt_auth_file", @@ -680,6 +684,10 @@ def test_routing_options_accept_every_strategy_the_router_accepts(strategy: str) NAMES_SHARED_WITH_TYPED_MODELS: Final[Mapping[str, tuple[str, ...]]] = MappingProxyType( { "credentials": ( + "github_copilot_app_id", + "github_copilot_app_installation_id", + "github_copilot_app_private_key_ref", + "github_copilot_app_private_key", "anthropic_disable_workload_identity_federation", "anthropic_federation_rule_id", "anthropic_federation_workspace_id", diff --git a/tests/unit/types/test_router.py b/tests/unit/types/test_router.py index 7855eba0042..94d2ececf6d 100644 --- a/tests/unit/types/test_router.py +++ b/tests/unit/types/test_router.py @@ -361,7 +361,14 @@ def test_server_owned_registry_includes_anthropic_openai_and_oauth_token_exchang "token_exchange_profile", "token_exchange_scope", ) - assert github_copilot_oauth_litellm_params == ("github_copilot_auth_type", "github_copilot_host") + assert github_copilot_oauth_litellm_params == ( + "github_copilot_auth_type", + "github_copilot_host", + "github_copilot_app_id", + "github_copilot_app_installation_id", + "github_copilot_app_private_key_ref", + "github_copilot_app_private_key", + ) assert server_owned_wif_litellm_params == ( anthropic_wif_litellm_params + openai_wif_litellm_params