diff --git a/litellm/llms/github_copilot/model_info.py b/litellm/llms/github_copilot/model_info.py new file mode 100644 index 00000000000..a5cbe04242a --- /dev/null +++ b/litellm/llms/github_copilot/model_info.py @@ -0,0 +1,129 @@ +import json +from datetime import datetime +from typing import Final + +import httpx +from pydantic import BaseModel + +import litellm +from litellm._logging import verbose_logger +from litellm.llms.base_llm.base_utils import BaseLLMModelInfo +from litellm.llms.custom_httpx.http_handler import HTTPHandler +from litellm.types.llms.openai import AllMessageValues + +from .authenticator import Authenticator +from .common_utils import GetAPIKeyError, get_copilot_default_headers + +MODEL_PREFIX: Final = "github_copilot/" +LIST_MODELS_TIMEOUT: Final = 10.0 + + +class CopilotModel(BaseModel): + id: str + + +class CopilotModelsResponse(BaseModel): + data: tuple[CopilotModel, ...] + + +def _has_unexpired_api_key(path: str) -> bool: + try: + with open(path) as f: + return json.load(f).get("expires_at", 0) > datetime.now().timestamp() + except (OSError, ValueError, AttributeError): + return False + + +def _has_access_token(path: str) -> bool: + try: + with open(path) as f: + return bool(f.read().strip()) + except OSError: + return False + + +def _cached_api_key(authenticator: Authenticator) -> str | None: + # Authenticator.get_api_key() refreshes an expired key, and that refresh falls through to the + # device-code login, which prints a user code and blocks for about a minute. A /v1/models call + # must never do that, so only call it once a credential it can use unattended is on disk: an + # unexpired api key, or an access token the refresh can exchange without logging in. + if not (_has_unexpired_api_key(authenticator.api_key_file) or _has_access_token(authenticator.access_token_file)): + verbose_logger.warning("GitHub Copilot has no usable cached credential; skipping model listing.") + return None + try: + return authenticator.get_api_key() + except GetAPIKeyError as e: + verbose_logger.warning("Failed to obtain a GitHub Copilot API key: %s", e) + return None + + +class GithubCopilotModelInfo(BaseLLMModelInfo): + def __init__(self, authenticator: Authenticator | None = None, client: HTTPHandler | None = None) -> None: + self._authenticator: Final = authenticator if authenticator is not None else Authenticator() + # Resolved lazily in get_models: litellm.module_level_client is a lazy module attribute, and + # binding it here would pin whichever client existed when the provider was first constructed. + self._client: Final = client + + def validate_environment( + self, + headers: dict, # mutable-ok: signature fixed by BaseLLMModelInfo.validate_environment + model: str, + messages: list[AllMessageValues], # mutable-ok: signature fixed by the base class + optional_params: dict, # mutable-ok: signature fixed by the base class + litellm_params: dict, # mutable-ok: signature fixed by the base class + api_key: str | None = None, + api_base: str | None = None, + ) -> dict: # mutable-ok: return type fixed by the base class + resolved_api_key: Final = api_key if api_key is not None else _cached_api_key(self._authenticator) + if resolved_api_key is None: + return headers + return {**get_copilot_default_headers(resolved_api_key), **headers} # mutable-ok: fixed by the base class + + @staticmethod + def get_api_base(api_base: str | None = None) -> str | None: + from .chat.transformation import GithubCopilotConfig + + return GithubCopilotConfig().api_base_without_login(api_base) + + @staticmethod + def get_api_key(api_key: str | None = None) -> str | None: + return api_key if api_key is not None else _cached_api_key(Authenticator()) + + @staticmethod + def get_base_model(model: str) -> str | None: + return model.removeprefix(MODEL_PREFIX) + + def get_models( + self, api_key: str | None = None, api_base: str | None = None + ) -> list[str]: # mutable-ok: base class + # api_key is accepted for the base-class signature but deliberately unused: the completion + # path authenticates with the cached Copilot credential and ignores a configured key, so + # honouring one here would advertise a catalog the caller cannot actually invoke. + resolved_api_base: Final = self.get_api_base(api_base) + resolved_api_key: Final = _cached_api_key(self._authenticator) + if resolved_api_base is None or resolved_api_key is None: + raise ValueError( + "GitHub Copilot is not authenticated. Complete the device-code login once so a token is " + "cached under GITHUB_COPILOT_TOKEN_DIR, then retry listing models." + ) + + # The catalog is scoped to the copilot-integration-id, so listing has to send the same + # headers the completion path sends or it reports models the caller cannot invoke. + client: Final = self._client if self._client is not None else litellm.module_level_client + response: Final = client.get( + url=f"{resolved_api_base}/models", + headers=get_copilot_default_headers(resolved_api_key), + timeout=LIST_MODELS_TIMEOUT, + ) + try: + response.raise_for_status() + except httpx.HTTPStatusError: + raise Exception( + f"Failed to fetch models from GitHub Copilot. " + f"Status code: {response.status_code}, Response: {response.text}" + ) + + # No filtering on model_picker_enabled: Copilot clears it for embedding models and for + # chat models it does not surface in the editor picker, both of which remain callable. + models: Final = CopilotModelsResponse.model_validate(response.json()).data + return [MODEL_PREFIX + m.id for m in models] # mutable-ok: base class diff --git a/litellm/utils.py b/litellm/utils.py index e7a171f56c6..2c3636ab7c8 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -9410,6 +9410,10 @@ class ProviderConfigManager: return litellm.TopazModelInfo() elif LlmProviders.ANTHROPIC == provider: return litellm.AnthropicModelInfo() + elif LlmProviders.GITHUB_COPILOT == provider: + from litellm.llms.github_copilot.model_info import GithubCopilotModelInfo + + return GithubCopilotModelInfo() elif LlmProviders.XAI == provider: return litellm.XAIModelInfo() elif LlmProviders.OLLAMA == provider or LlmProviders.OLLAMA_CHAT == provider: diff --git a/tests/test_litellm/llms/github_copilot/test_github_copilot_model_info.py b/tests/test_litellm/llms/github_copilot/test_github_copilot_model_info.py new file mode 100644 index 00000000000..f6c8a325957 --- /dev/null +++ b/tests/test_litellm/llms/github_copilot/test_github_copilot_model_info.py @@ -0,0 +1,253 @@ +import json +from datetime import datetime, timedelta + +import httpx +import pytest +from pydantic import ValidationError + +from litellm.llms.custom_httpx.http_handler import HTTPHandler +from litellm.llms.github_copilot.authenticator import Authenticator +from litellm.llms.github_copilot.common_utils import GetAPIKeyError +from litellm.llms.github_copilot.model_info import GithubCopilotModelInfo +from litellm.types.utils import LlmProviders +from litellm.utils import ProviderConfigManager + +API_BASE = "https://api.githubcopilot.com" +API_KEY = "gh.test-key-123456789" + +MODELS_PAYLOAD = { + "object": "list", + "data": [ + {"id": "gpt-4o", "capabilities": {"type": "chat"}, "model_picker_enabled": True}, + {"id": "claude-opus-5.5", "capabilities": {"type": "chat"}, "model_picker_enabled": True}, + {"id": "text-embedding-3-small", "capabilities": {"type": "embeddings"}, "model_picker_enabled": False}, + ], +} + +EXPECTED_MODELS = [ + "github_copilot/gpt-4o", + "github_copilot/claude-opus-5.5", + "github_copilot/text-embedding-3-small", +] + + +class StubAuthenticator(Authenticator): + """Token files point at a tmp path, and device login raises instead of blocking on a user code.""" + + def __init__(self, api_key_file: str, access_token_file: str) -> None: + self.api_key_file = api_key_file + self.access_token_file = access_token_file + + def _login(self) -> str: + raise AssertionError("device-code login must never run during model listing") + + +def _write_api_key(path, expires_in: timedelta) -> str: + path.write_text(json.dumps({"token": API_KEY, "expires_at": (datetime.now() + expires_in).timestamp()})) + return str(path) + + +@pytest.fixture +def valid_credential(tmp_path): + return StubAuthenticator( + api_key_file=_write_api_key(tmp_path / "api-key.json", timedelta(hours=1)), + access_token_file=str(tmp_path / "absent-access-token"), + ) + + +@pytest.fixture +def no_credential(tmp_path): + return StubAuthenticator( + api_key_file=str(tmp_path / "absent-api-key.json"), + access_token_file=str(tmp_path / "absent-access-token"), + ) + + +@pytest.fixture +def expired_credential(tmp_path): + return StubAuthenticator( + api_key_file=_write_api_key(tmp_path / "api-key.json", timedelta(hours=-1)), + access_token_file=str(tmp_path / "absent-access-token"), + ) + + +def _copilot(handler) -> HTTPHandler: + """A real HTTPHandler whose only fake part is the transport, so the request is built for real.""" + return HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(handler))) + + +def _serving(payload, status_code: int = 200, base: str = API_BASE, requires_key: str | None = API_KEY): + """Serves the catalog at `base` only, and only to a caller presenting `requires_key`.""" + + def handler(request: httpx.Request) -> httpx.Response: + if str(request.url) != f"{base}/models": + return httpx.Response(404, json={"error": f"no catalog at {request.url}"}) + if requires_key is not None and request.headers.get("authorization") != f"Bearer {requires_key}": + return httpx.Response(403, json={"error": "wrong account"}) + if request.headers.get("copilot-integration-id") != "vscode-chat": + return httpx.Response(403, json={"error": "catalog is scoped to the integration id"}) + return httpx.Response(status_code, json=payload) + + return handler + + +def test_get_models_returns_every_prefixed_id(valid_credential): + model_info = GithubCopilotModelInfo(valid_credential, _copilot(_serving(MODELS_PAYLOAD))) + + assert model_info.get_models(api_base=API_BASE) == EXPECTED_MODELS + + +def test_get_models_sends_the_copilot_integration_headers(valid_credential): + def echo_headers(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={ + "data": [ + {"id": request.headers.get("authorization", "no-authorization")}, + {"id": request.headers.get("copilot-integration-id", "no-integration-id")}, + ] + }, + ) + + model_info = GithubCopilotModelInfo(valid_credential, _copilot(echo_headers)) + + assert model_info.get_models(api_base=API_BASE) == [ + f"github_copilot/Bearer {API_KEY}", + "github_copilot/vscode-chat", + ] + + +def test_get_models_bounds_the_request_so_a_slow_copilot_cannot_stall_the_caller(valid_credential): + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen.update(request.extensions.get("timeout") or {}) + return httpx.Response(200, json=MODELS_PAYLOAD) + + model_info = GithubCopilotModelInfo(valid_credential, _copilot(handler)) + model_info.get_models(api_base=API_BASE) + + assert seen["read"] == pytest.approx(10.0) + assert seen["connect"] == pytest.approx(10.0) + + +def test_get_models_honours_an_explicit_api_base(valid_credential): + model_info = GithubCopilotModelInfo( + valid_credential, _copilot(_serving(MODELS_PAYLOAD, base="https://copilot.example.com")) + ) + + assert model_info.get_models(api_base="https://copilot.example.com") == EXPECTED_MODELS + + +def test_get_models_ignores_an_explicit_api_key_so_listing_matches_completions(valid_credential): + model_info = GithubCopilotModelInfo(valid_credential, _copilot(_serving(MODELS_PAYLOAD))) + + assert model_info.get_models(api_key="a-different-accounts-key", api_base=API_BASE) == EXPECTED_MODELS + + +def test_get_models_without_any_credential_never_starts_device_login(no_credential): + model_info = GithubCopilotModelInfo(no_credential, _copilot(_serving(MODELS_PAYLOAD))) + + with pytest.raises(ValueError, match="not authenticated"): + model_info.get_models(api_base=API_BASE) + + +def test_get_models_with_an_expired_key_and_no_access_token_never_starts_device_login(expired_credential): + model_info = GithubCopilotModelInfo(expired_credential, _copilot(_serving(MODELS_PAYLOAD))) + + with pytest.raises(ValueError, match="not authenticated"): + model_info.get_models(api_base=API_BASE) + + +def test_get_models_raises_on_http_error(valid_credential): + model_info = GithubCopilotModelInfo(valid_credential, _copilot(_serving({"error": "forbidden"}, status_code=403))) + + with pytest.raises(Exception, match="Failed to fetch models from GitHub Copilot"): + model_info.get_models(api_base=API_BASE) + + +def test_get_models_raises_on_unexpected_payload(valid_credential): + model_info = GithubCopilotModelInfo(valid_credential, _copilot(_serving({"models": []}))) + + with pytest.raises(ValidationError, match="data"): + model_info.get_models(api_base=API_BASE) + + +def test_get_base_model_strips_prefix(): + assert GithubCopilotModelInfo.get_base_model("github_copilot/gpt-4o") == "gpt-4o" + assert GithubCopilotModelInfo.get_base_model("gpt-4o") == "gpt-4o" + + +def test_provider_config_manager_returns_model_info(): + model_info = ProviderConfigManager.get_provider_model_info(model=None, provider=LlmProviders.GITHUB_COPILOT) + assert isinstance(model_info, GithubCopilotModelInfo) + + +class RefreshingAuthenticator(StubAuthenticator): + """Has only an access token on disk, and can exchange it for an api key without logging in.""" + + def _refresh_api_key(self) -> dict: + return {"token": API_KEY, "expires_at": (datetime.now() + timedelta(hours=1)).timestamp()} + + +class UnauthorizedAuthenticator(StubAuthenticator): + def get_api_key(self) -> str: + raise GetAPIKeyError(status_code=401, message="the cached credential was rejected") + + +def test_get_models_with_only_an_access_token_exchanges_it_without_logging_in(tmp_path): + access_token_file = tmp_path / "access-token" + access_token_file.write_text("gho.test-access-token\n") + authenticator = RefreshingAuthenticator( + api_key_file=str(tmp_path / "absent-api-key.json"), + access_token_file=str(access_token_file), + ) + + model_info = GithubCopilotModelInfo(authenticator, _copilot(_serving(MODELS_PAYLOAD))) + + assert model_info.get_models(api_base=API_BASE) == EXPECTED_MODELS + + +def test_get_models_reports_a_rejected_credential_instead_of_listing(valid_credential, tmp_path): + authenticator = UnauthorizedAuthenticator( + api_key_file=valid_credential.api_key_file, + access_token_file=str(tmp_path / "absent-access-token"), + ) + + model_info = GithubCopilotModelInfo(authenticator, _copilot(_serving(MODELS_PAYLOAD))) + + with pytest.raises(ValueError, match="not authenticated"): + model_info.get_models(api_base=API_BASE) + + +def test_validate_environment_adds_the_cached_credential(valid_credential): + headers = GithubCopilotModelInfo(valid_credential).validate_environment( + headers={}, model="github_copilot/gpt-4o", messages=[], optional_params={}, litellm_params={} + ) + + assert headers["Authorization"] == f"Bearer {API_KEY}" + assert headers["copilot-integration-id"] == "vscode-chat" + + +def test_validate_environment_leaves_caller_headers_untouched(valid_credential): + headers = GithubCopilotModelInfo(valid_credential).validate_environment( + headers={"Authorization": "Bearer caller-supplied"}, + model="github_copilot/gpt-4o", + messages=[], + optional_params={}, + litellm_params={}, + ) + + assert headers["Authorization"] == "Bearer caller-supplied" + + +def test_validate_environment_without_a_credential_returns_the_caller_headers(no_credential): + headers = GithubCopilotModelInfo(no_credential).validate_environment( + headers={"x-caller": "kept"}, model="github_copilot/gpt-4o", messages=[], optional_params={}, litellm_params={} + ) + + assert headers == {"x-caller": "kept"} + + +def test_get_api_key_returns_an_explicit_key_unchanged(): + assert GithubCopilotModelInfo.get_api_key("explicit-key") == "explicit-key"