From 9d827093c5cd220be65c8f2e3bd3979e5ede9062 Mon Sep 17 00:00:00 2001 From: giladk1221 Date: Sat, 14 Nov 2026 17:33:20 +0200 Subject: [PATCH] feat(github_copilot): support GitHub App authentication with PEM upload (#51427) * feat(github_copilot): support GitHub App installation authentication * Add GitHub App authentication to the Copilot credential form * Explain secure private-key setup beside Copilot App fields * Store uploaded Copilot App keys through encrypted named credentials * fix(github_copilot): handle App validation errors and credential replacement * fix(ui): make GitHub App field help accessible * fix(github_copilot): preserve saved App key references on edit * fix(github_copilot): trust litellm's CA settings when minting App tokens and hide App keys from /health * fix(github_copilot): keep device-login headers, mint App tokens off the shared thread pool, cache App responses Device-login requests keep the merge base header behavior, so caller headers still replace the Copilot defaults. Only App requests merge and pin their identity headers. App token refreshes are single-flight per cache key with a shared future. Only the refreshing caller uses a thread, so waiting callers no longer fill the default executor that every provider call shares. A cached App token is served before the key secret is read. Uploaded keys still rotate the token when their contents change. App deployments are deployment-owned, so they share the response cache like shared device-login deployments. App credentials are validated even when credential_info names no provider. * fix(github_copilot): finish an App token refresh even when the caller that started it is cancelled The caller that owns a refresh used to await asyncio.to_thread, so cancelling it while the job was still queued cancelled the job, left the in-flight future unfinished, and every later request for that App waited on it. The refresh now runs in the executor without being tied to the caller, and every caller, the owner included, awaits the shared future * fix(github_copilot): refuse deployments naming an unloaded credential, keep App key references, accept numeric App IDs A Copilot deployment whose litellm_credential_name is not loaded now answers 400 instead of falling back to the shared device login. The router registry keeps github_copilot_app_private_key_ref values as references, a YAML App ID can be an integer, the server-owned field error names Copilot auth for Copilot fields, and the credential edit modal shows the stored provider again * fix(github_copilot): report a deployment naming an unloaded credential as unhealthy The /health check now answers unhealthy with the same 400 the request path returns for a Copilot deployment whose litellm_credential_name is not loaded, instead of running it through the shared device login * fix(github_copilot): keep the base fallback for an unloaded credential and report the App host as api_base A Copilot deployment that names an unloaded credential uses the shared device login again, as on base. A deployment that sets github_copilot_auth_type: github_app still refuses before any GitHub call. get_api_base now returns the Copilot host an App or per-user session calls instead of an admin api_base it ignores. * fix(github_copilot): count a GitHub rejection of the App installation token as a Prometheus deployment failure * fix(github_copilot): keep per-user session api_base reporting as on base and name Copilot in the non-admin 403 Only an App deployment reports its App host as api_base before a configured api_base. Per-user OAuth sessions report the same api_base they did on base. The 403 for a non-admin attaching App credentials now names GitHub Copilot authentication. The token-dir fixture in the App api_base test is applied with usefixtures. * fix(github_copilot): report the configured api_base instead of raising when GitHub App settings are incomplete * test(github_copilot): type the incomplete App settings test parameter --------- Co-authored-by: Gilad Katz <29776916+giladk1221@users.noreply.github.com> Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com> --- litellm/exceptions.py | 4 + litellm/integrations/prometheus.py | 11 +- .../clientside_credential_handler.py | 10 + .../litellm_core_utils/credential_accessor.py | 8 +- .../litellm_core_utils/get_litellm_params.py | 3 + .../get_llm_provider_logic.py | 5 + .../llm_response_utils/get_api_base.py | 20 +- litellm/llms/github_copilot/app_auth.py | 378 +++++++ .../github_copilot/chat/transformation.py | 5 +- litellm/llms/github_copilot/common_utils.py | 5 + .../embedding/transformation.py | 2 +- .../github_copilot/messages/transformation.py | 1 + litellm/llms/github_copilot/per_user_auth.py | 66 +- .../responses/transformation.py | 2 +- litellm/main.py | 25 +- .../proxy/credential_endpoints/endpoints.py | 23 + .../model_management_endpoints.py | 3 +- .../provider_create_fields.json | 40 + litellm/router_rust/invoke/shaping.py | 12 + litellm/router_rust/surface/registry.py | 8 +- litellm/router_utils/cooldown_handlers.py | 6 +- litellm/types/litellm_params.py | 4 + litellm/types/router.py | 16 +- litellm/types/utils.py | 13 +- .../add_model/provider_auth_types.test.ts | 19 +- .../add_model/provider_auth_types.ts | 23 +- .../add_model/provider_specific_fields.tsx | 53 +- .../CredentialModal.integration.test.tsx | 192 ++++ .../components/model_add/CredentialModal.tsx | 43 +- .../shared/form/LabelWithHint.test.tsx | 21 + .../components/shared/form/LabelWithHint.tsx | 25 +- .../src/i18n/locales/en.json | 8 + .../src/lib/http/schema.d.ts | 16 + litellm/utils.py | 11 +- .../test_prometheus_invalid_key_filtering.py | 35 + .../llm_response_utils/test_get_api_base.py | 49 + ...github_copilot_responses_transformation.py | 5 +- .../unit/llms/github_copilot/test_app_auth.py | 988 ++++++++++++++++++ tests/unit/proxy/auth/test_auth_utils.py | 5 +- .../credential_endpoints/test_endpoints.py | 163 ++- .../health_endpoints/test_health_endpoints.py | 2 + .../test_model_management_endpoints.py | 1 + .../proxy/test_credential_slot_registry.py | 2 + tests/unit/types/test_litellm_params.py | 8 + tests/unit/types/test_router.py | 9 +- 45 files changed, 2266 insertions(+), 82 deletions(-) create mode 100644 litellm/llms/github_copilot/app_auth.py create mode 100644 litellm/ui/litellm-dashboard/src/components/shared/form/LabelWithHint.test.tsx create mode 100644 tests/unit/llms/github_copilot/test_app_auth.py 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