mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-15 09:12:18 +00:00
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>
This commit is contained in:
parent
05518975a5
commit
9d827093c5
45 changed files with 2266 additions and 82 deletions
|
|
@ -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__(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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]):
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
378
litellm/llms/github_copilot/app_auth.py
Normal file
378
litellm/llms/github_copilot/app_auth.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -35,6 +35,22 @@ export const PROVIDER_AUTH_TYPES: Partial<Record<keyof typeof Providers, readonl
|
|||
requiredFieldKeys: [GITHUB_COPILOT_AUTH_TYPE_KEY],
|
||||
fixedValues: { [GITHUB_COPILOT_AUTH_TYPE_KEY]: GITHUB_COPILOT_PER_USER_AUTH_TYPE },
|
||||
},
|
||||
{
|
||||
id: "github_app",
|
||||
labelKey: "addModel.providerAuthTypes.githubApp.label",
|
||||
descriptionKey: "addModel.providerAuthTypes.githubApp.description",
|
||||
credentialOnly: true,
|
||||
fieldKeys: [
|
||||
GITHUB_COPILOT_AUTH_TYPE_KEY,
|
||||
GITHUB_COPILOT_HOST_KEY,
|
||||
"github_copilot_app_id",
|
||||
"github_copilot_app_installation_id",
|
||||
"github_copilot_app_private_key_ref",
|
||||
"github_copilot_app_private_key",
|
||||
],
|
||||
requiredFieldKeys: ["github_copilot_app_id", "github_copilot_app_installation_id"],
|
||||
fixedValues: { [GITHUB_COPILOT_AUTH_TYPE_KEY]: "github_app" },
|
||||
},
|
||||
],
|
||||
MICROSOFT_365_COPILOT: [
|
||||
{
|
||||
|
|
@ -65,7 +81,9 @@ export const PROVIDER_AUTH_TYPES: Partial<Record<keyof typeof Providers, readonl
|
|||
export const authTypesFor = (provider: string | null): readonly ProviderAuthType[] => {
|
||||
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, unknown>): 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] !== "",
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ const ProviderSpecificFieldsContent: React.FC<ProviderSpecificFieldsProps> = ({
|
|||
const { t } = useTranslation();
|
||||
const selectedProviderEnum = Providers[selectedProvider as keyof typeof Providers] as Providers;
|
||||
const form = useFormContext<MountedFormValues>();
|
||||
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<ProviderSpecificFieldsProps> = ({
|
|||
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<HTMLInputElement>) => {
|
||||
(field: ProviderCredentialField, onLoaded: (contents: string) => void) =>
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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<ProviderSpecificFieldsProps> = ({
|
|||
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<ProviderSpecificFieldsProps> = ({
|
|||
authTypeHiddenFieldKeys,
|
||||
selectedAuthType,
|
||||
credentialOnlySelected,
|
||||
hasAppKeyReference,
|
||||
]);
|
||||
|
||||
const hasApiVersionField = React.useMemo(() => allFields.some((field) => field.key === "api_version"), [allFields]);
|
||||
|
|
@ -280,6 +305,7 @@ const ProviderSpecificFieldsContent: React.FC<ProviderSpecificFieldsProps> = ({
|
|||
}
|
||||
|
||||
if (field.type === "upload") {
|
||||
const hasAppKey = Boolean(control.value) || hasAppKeyReference;
|
||||
return (
|
||||
<>
|
||||
<Button type="button" variant="outline" className="w-fit" onClick={() => credentialsFileRef.current?.click()}>
|
||||
|
|
@ -290,11 +316,15 @@ const ProviderSpecificFieldsContent: React.FC<ProviderSpecificFieldsProps> = ({
|
|||
ref={credentialsFileRef}
|
||||
id={control.id}
|
||||
type="file"
|
||||
accept=".json"
|
||||
aria-label={field.label}
|
||||
accept={field.key === "github_copilot_app_private_key" ? ".pem" : ".json"}
|
||||
className="sr-only"
|
||||
onBlur={control.onBlur}
|
||||
onChange={pickCredentialsFile(control.onChange)}
|
||||
onChange={pickCredentialsFile(field, control.onChange)}
|
||||
/>
|
||||
{field.key === "github_copilot_app_private_key" && hasAppKey && (
|
||||
<p className="text-sm text-muted-foreground">{t("addModel.providerAuthTypes.githubApp.keyProvided")}</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -328,6 +358,7 @@ const ProviderSpecificFieldsContent: React.FC<ProviderSpecificFieldsProps> = ({
|
|||
return (
|
||||
<Input
|
||||
id={control.id}
|
||||
aria-label={field.label}
|
||||
value={(control.value as string | undefined) ?? field.defaultValue ?? ""}
|
||||
onBlur={control.onBlur}
|
||||
placeholder={field.placeholder}
|
||||
|
|
@ -396,7 +427,11 @@ const ProviderSpecificFieldsContent: React.FC<ProviderSpecificFieldsProps> = ({
|
|||
{allFields.map((field) => (
|
||||
<React.Fragment key={`${selectedProvider}:${field.key}`}>
|
||||
<MountedFormField
|
||||
label={field.tooltip ? labelWithHint(field.label, field.tooltip) : field.label}
|
||||
label={
|
||||
field.tooltip
|
||||
? labelWithHint(field.label, field.tooltip, selectedAuthType?.id === "github_app" ? "info" : "help")
|
||||
: field.label
|
||||
}
|
||||
name={field.key}
|
||||
required={field.required}
|
||||
rules={{
|
||||
|
|
@ -410,6 +445,10 @@ const ProviderSpecificFieldsContent: React.FC<ProviderSpecificFieldsProps> = ({
|
|||
{(control) => renderFieldControl(field, control)}
|
||||
</MountedFormField>
|
||||
|
||||
{field.key === "github_copilot_app_private_key" && (
|
||||
<p className="mb-4 text-sm text-muted-foreground">{t("addModel.providerAuthTypes.githubApp.uploadHelp")}</p>
|
||||
)}
|
||||
|
||||
{/* Special case for Vertex Credentials help text */}
|
||||
{field.key === "vertex_credentials" && (
|
||||
<p className="text-sm mb-3 mt-1">{t("addModel.providerSpecificFields.vertexCredentialsHelp")}</p>
|
||||
|
|
|
|||
|
|
@ -71,6 +71,11 @@ vi.mock("../networking", async () => {
|
|||
provider_display_name: Providers.GITHUB_COPILOT,
|
||||
litellm_provider: "github_copilot",
|
||||
credential_fields: [
|
||||
{ key: "github_copilot_app_private_key", label: "Private key (PEM)", field_type: "upload" },
|
||||
{ key: "github_copilot_app_id", label: "App client ID", field_type: "text" },
|
||||
{ key: "github_copilot_app_installation_id", label: "Installation ID", field_type: "text" },
|
||||
{ key: "github_copilot_app_private_key_ref", label: "Private key secret reference", field_type: "text" },
|
||||
|
||||
{ key: "api_base", label: "API Base", field_type: "text" },
|
||||
{ key: "api_key", label: "API Key", field_type: "password" },
|
||||
{
|
||||
|
|
@ -604,6 +609,174 @@ describe("CredentialModal with GitHub Copilot auth types", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("uploads a PEM through the shared credential file control without displaying its contents", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = renderModal({ initialProvider: "GITHUB_COPILOT" });
|
||||
await screen.findByRole("combobox", { name: "Auth Type:" });
|
||||
await chooseOption(user, /^Auth Type:/, "GitHub App");
|
||||
expect(screen.getByRole("button", { name: "GitHub host information" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("textbox", { name: "GitHub host", exact: true })).toBeInTheDocument();
|
||||
fill("Credential Name:", "copilot-upload");
|
||||
fill("App client ID", "test-client");
|
||||
fill("Installation ID", "123");
|
||||
const pem = "-----BEGIN PRIVATE KEY-----\nsynthetic-upload-content\n-----END PRIVATE KEY-----";
|
||||
await user.upload(
|
||||
screen.getByLabelText("Private key (PEM)"),
|
||||
new File([pem], "app.pem", { type: "application/x-pem-file" }),
|
||||
);
|
||||
expect(await screen.findByText("Private key supplied. Upload a replacement to change it.")).toBeInTheDocument();
|
||||
expect(screen.queryByText(pem)).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Private key secret reference")).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Add Credential" }));
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
{
|
||||
credential_name: "copilot-upload",
|
||||
custom_llm_provider: "GITHUB_COPILOT",
|
||||
credential_values: {
|
||||
github_copilot_auth_type: "github_app",
|
||||
github_copilot_app_id: "test-client",
|
||||
github_copilot_app_installation_id: "123",
|
||||
github_copilot_app_private_key: pem,
|
||||
},
|
||||
},
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["GITHUB_COPILOT", "github_copilot"])(
|
||||
"edits a stored %s App credential without replacing its saved key reference",
|
||||
async (provider) => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = renderModal({
|
||||
mode: "edit",
|
||||
existingCredential: {
|
||||
credential_name: "copilot-service",
|
||||
credential_info: { custom_llm_provider: provider },
|
||||
credential_values: {
|
||||
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.e****",
|
||||
},
|
||||
},
|
||||
});
|
||||
await screen.findByLabelText("Installation ID");
|
||||
fill("Installation ID", "456");
|
||||
expect(screen.getByRole("button", { name: "Click to Upload" })).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Private key secret reference")).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Update Credential" }));
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
{
|
||||
credential_name: "copilot-service",
|
||||
custom_llm_provider: provider,
|
||||
credential_values: { github_copilot_app_installation_id: "456" },
|
||||
},
|
||||
[],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("requires a PEM when creating a new App credential", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = renderModal({ initialProvider: "GITHUB_COPILOT", initialAuthTypeId: "github_app" });
|
||||
await screen.findByLabelText("Installation ID");
|
||||
fill("Credential Name:", "copilot-new");
|
||||
fill("App client ID", "test-client");
|
||||
fill("Installation ID", "123");
|
||||
await user.click(screen.getByRole("button", { name: "Add Credential" }));
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each(["GITHUB_COPILOT", "github_copilot"])(
|
||||
"replaces a stored %s secret reference with an uploaded PEM and deletes the reference",
|
||||
async (provider) => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = renderModal({
|
||||
mode: "edit",
|
||||
existingCredential: {
|
||||
credential_name: "copilot-service",
|
||||
credential_info: { custom_llm_provider: provider },
|
||||
credential_values: {
|
||||
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.e****",
|
||||
},
|
||||
},
|
||||
});
|
||||
const pem = "-----BEGIN PRIVATE KEY-----\nsynthetic-replacement\n-----END PRIVATE KEY-----";
|
||||
expect(await screen.findByRole("combobox", { name: "Provider:" })).toHaveValue(Providers.GITHUB_COPILOT);
|
||||
expect(screen.getByRole("img", { name: `${Providers.GITHUB_COPILOT} logo` })).toBeInTheDocument();
|
||||
await user.upload(
|
||||
await screen.findByLabelText("Private key (PEM)"),
|
||||
new File([pem], "replacement.pem", { type: "application/x-pem-file" }),
|
||||
);
|
||||
await screen.findByText("Private key supplied. Upload a replacement to change it.");
|
||||
await user.click(screen.getByRole("button", { name: "Update Credential" }));
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
{
|
||||
credential_name: "copilot-service",
|
||||
custom_llm_provider: provider,
|
||||
credential_values: { github_copilot_app_private_key: pem },
|
||||
},
|
||||
["github_copilot_app_private_key_ref"],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves a masked uploaded key on edit", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = renderModal({
|
||||
mode: "edit",
|
||||
existingCredential: {
|
||||
credential_name: "copilot-upload",
|
||||
credential_info: { custom_llm_provider: "GITHUB_COPILOT" },
|
||||
credential_values: {
|
||||
github_copilot_auth_type: "github_app",
|
||||
github_copilot_app_id: "test-client",
|
||||
github_copilot_app_installation_id: "123",
|
||||
github_copilot_app_private_key: "--****--",
|
||||
},
|
||||
},
|
||||
});
|
||||
await screen.findByText("Private key supplied. Upload a replacement to change it.");
|
||||
await user.click(screen.getByRole("button", { name: "Update Credential" }));
|
||||
expect(onSubmit).toHaveBeenLastCalledWith(
|
||||
{ credential_name: "copilot-upload", custom_llm_provider: "GITHUB_COPILOT", credential_values: {} },
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it("reopens an App credential and removes App fields when switching to per-user OAuth", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = renderModal({
|
||||
mode: "edit",
|
||||
existingCredential: {
|
||||
credential_name: "copilot-service",
|
||||
credential_values: {
|
||||
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",
|
||||
},
|
||||
credential_info: { custom_llm_provider: "GITHUB_COPILOT" },
|
||||
},
|
||||
});
|
||||
expect(await screen.findByRole("combobox", { name: "Auth Type:" })).toHaveTextContent("GitHub App");
|
||||
expect(screen.getByLabelText("App client ID")).toHaveValue("test-client");
|
||||
await chooseOption(user, /^Auth Type:/, "Per-user GitHub OAuth");
|
||||
await user.click(screen.getByRole("button", { name: "Update Credential" }));
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
{
|
||||
credential_name: "copilot-service",
|
||||
custom_llm_provider: "GITHUB_COPILOT",
|
||||
credential_values: { github_copilot_auth_type: "per_user_oauth" },
|
||||
},
|
||||
["github_copilot_app_id", "github_copilot_app_installation_id", "github_copilot_app_private_key_ref"],
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the shared device login credential free of the per-user auth type", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = renderModal({ initialProvider: "GITHUB_COPILOT" });
|
||||
|
|
@ -881,6 +1054,25 @@ describe("CredentialModal with OpenAI workload identity federation", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("CredentialModal stored provider", () => {
|
||||
it.each([
|
||||
["SageMaker", "AWS SageMaker"],
|
||||
["openai", "OpenAI"],
|
||||
["text-completion-openai", "OpenAI Text Completion"],
|
||||
])("opens a credential stored as %s under the %s provider", async (storedProvider, shownProvider) => {
|
||||
renderModal({
|
||||
mode: "edit",
|
||||
existingCredential: {
|
||||
credential_name: "stored-provider",
|
||||
credential_values: {},
|
||||
credential_info: { custom_llm_provider: storedProvider },
|
||||
},
|
||||
});
|
||||
|
||||
expect(await screen.findByRole("combobox", { name: /Provider/ })).toHaveValue(shownProvider);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CredentialModal display name wiring", () => {
|
||||
it("submits the typed label alongside the credential name and stored values", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import {
|
|||
type MountedFormValues,
|
||||
} from "../common_components/MountedFormField";
|
||||
import { CredentialItem } from "../networking";
|
||||
import { Providers } from "../provider_info_helpers";
|
||||
import { getProviderLogoAndName, Providers } from "../provider_info_helpers";
|
||||
import { Logo } from "@/components/molecules/logo/Logo";
|
||||
import {
|
||||
displayNameChange,
|
||||
|
|
@ -150,8 +150,14 @@ export default function CredentialModal({
|
|||
const patch = sameStoredProvider
|
||||
? buildCredentialPatch(storedValues, projectedValues, storedSelection, selection)
|
||||
: buildProviderChangePatch(storedValues, projectedValues, selection);
|
||||
const preservedKeyReference =
|
||||
projectedValues.github_copilot_auth_type === "github_app" && !projectedValues.github_copilot_app_private_key
|
||||
? "github_copilot_app_private_key_ref"
|
||||
: undefined;
|
||||
const authTypeValuesToDelete = sameStoredProvider
|
||||
? authTypeFieldKeys(selectedProvider).filter((key) => key in storedValues && !(key in projectedValues))
|
||||
? authTypeFieldKeys(selectedProvider).filter(
|
||||
(key) => key in storedValues && !(key in projectedValues) && key !== preservedKeyReference,
|
||||
)
|
||||
: [];
|
||||
const valuesToDelete = Array.from(new Set([...patch.credential_values_to_delete, ...authTypeValuesToDelete]));
|
||||
onSubmit({ ...meta, credential_values: patch.credential_values }, valuesToDelete);
|
||||
|
|
@ -219,19 +225,26 @@ export default function CredentialModal({
|
|||
rules={{ validate: { required: requiredRule(t("modelAdd.credentialModal.required")) } }}
|
||||
className="mb-4"
|
||||
>
|
||||
{(control) => (
|
||||
<SearchSelect
|
||||
inputId={control.id}
|
||||
placeholder={t("modelAdd.credentialModal.selectProvider")}
|
||||
options={providerOptions}
|
||||
value={typeof control.value === "string" ? control.value : null}
|
||||
disabled={providerLocked}
|
||||
onValueChange={(value) => {
|
||||
control.onChange(value);
|
||||
changeProvider(value);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{(control) => {
|
||||
const provider = typeof control.value === "string" ? control.value : null;
|
||||
const displayName = getProviderLogoAndName(provider ?? "").displayName;
|
||||
const selectedOption =
|
||||
providerOptions.find(({ value }) => value === provider) ??
|
||||
providerOptions.find(({ label }) => label === displayName);
|
||||
return (
|
||||
<SearchSelect
|
||||
inputId={control.id}
|
||||
placeholder={t("modelAdd.credentialModal.selectProvider")}
|
||||
options={providerOptions}
|
||||
value={selectedOption?.value ?? provider}
|
||||
disabled={providerLocked}
|
||||
onValueChange={(value) => {
|
||||
control.onChange(value);
|
||||
changeProvider(value);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</MountedFormField>
|
||||
|
||||
{federatedProviderOf(selectedProvider) !== null && (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { labelWithHint } from "./LabelWithHint";
|
||||
|
||||
describe("labelWithHint", () => {
|
||||
it("exposes named information help without submitting the surrounding form", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn((event) => event.preventDefault());
|
||||
render(<form onSubmit={onSubmit}>{labelWithHint("App client ID", "Use the public client ID.", "info")}</form>);
|
||||
|
||||
const help = screen.getByRole("button", { name: "App client ID information" });
|
||||
await user.tab();
|
||||
expect(help).toHaveFocus();
|
||||
expect(await screen.findByText("Use the public client ID.")).toBeInTheDocument();
|
||||
|
||||
await user.click(help);
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,17 +1,36 @@
|
|||
"use client";
|
||||
|
||||
import { CircleHelp } from "lucide-react";
|
||||
import { CircleHelp, Info } from "lucide-react";
|
||||
import React from "react";
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import i18n from "@/i18n/i18n";
|
||||
|
||||
const hintIconClassName = "size-3.5 shrink-0 cursor-help text-muted-foreground";
|
||||
|
||||
export const labelWithHint = (label: React.ReactNode, hint: React.ReactNode): React.ReactNode => (
|
||||
export const labelWithHint = (
|
||||
label: React.ReactNode,
|
||||
hint: React.ReactNode,
|
||||
icon: "help" | "info" = "help",
|
||||
): React.ReactNode => (
|
||||
<>
|
||||
{label}
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<CircleHelp className={hintIconClassName} />} />
|
||||
<TooltipTrigger
|
||||
render={
|
||||
icon === "info" ? (
|
||||
<button
|
||||
type="button"
|
||||
className={hintIconClassName}
|
||||
aria-label={typeof label === "string" ? i18n.t("shared.fieldInformation", { label }) : undefined}
|
||||
>
|
||||
<Info className="size-full" />
|
||||
</button>
|
||||
) : (
|
||||
<CircleHelp className={hintIconClassName} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<TooltipContent>{hint}</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
16
litellm/ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
16
litellm/ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
988
tests/unit/llms/github_copilot/test_app_auth.py
Normal file
988
tests/unit/llms/github_copilot/test_app_auth.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue