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:
giladk1221 2026-11-14 17:33:20 +02:00 • committed by GitHub
parent 05518975a5
commit 9d827093c5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
45 changed files with 2266 additions and 82 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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