From 1c58f21d567e39ab38ec85734a5206413ee214ea Mon Sep 17 00:00:00 2001 From: Alexander Dreweke Date: Thu, 24 Sep 2026 10:50:09 +0200 Subject: [PATCH 1/3] fix(github_copilot): list models from the tenant instead of the static registry GithubCopilotConfig has no BaseLLMModelInfo in its MRO, so github_copilot is the only provider without get_models(). Copilot availability is per-tenant, so the bundled registry drifts from what a caller can actually invoke in both directions. Implement BaseLLMModelInfo for github_copilot and call the tenant's /models endpoint with the Copilot integration headers, since the catalog is scoped to the integration id. Listing refuses to run without a cached credential so it cannot fall into the blocking device-code login. --- litellm/llms/github_copilot/model_info.py | 112 +++++++++++++++ litellm/utils.py | 4 + .../test_github_copilot_model_info.py | 130 ++++++++++++++++++ 3 files changed, 246 insertions(+) create mode 100644 litellm/llms/github_copilot/model_info.py create mode 100644 tests/test_litellm/llms/github_copilot/test_github_copilot_model_info.py diff --git a/litellm/llms/github_copilot/model_info.py b/litellm/llms/github_copilot/model_info.py new file mode 100644 index 00000000000..7b7f7aa5f72 --- /dev/null +++ b/litellm/llms/github_copilot/model_info.py @@ -0,0 +1,112 @@ +import os +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.types.llms.openai import AllMessageValues + +from .authenticator import Authenticator +from .common_utils import GetAPIKeyError, get_copilot_default_headers + +MODEL_PREFIX: Final = "github_copilot/" + + +class CopilotModel(BaseModel): + id: str + + +class CopilotModelsResponse(BaseModel): + data: tuple[CopilotModel, ...] + + +def _has_cached_credentials(authenticator: Authenticator) -> bool: + for path in (authenticator.api_key_file, authenticator.access_token_file): + try: + if os.path.getsize(path) > 0: + return True + except OSError: + continue + return False + + +def _resolve_api_key(authenticator: Authenticator, api_key: str | None) -> str | None: + if api_key is not None: + return api_key + # Never fall through to Authenticator's device-code login here: it prints a user code and + # blocks for about a minute, which a /v1/models call on a proxy must never do. + if not _has_cached_credentials(authenticator): + verbose_logger.warning("GitHub Copilot has no cached credentials; 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) -> None: + self._authenticator: Final = authenticator if authenticator is not None else Authenticator() + + 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 = self.get_api_key(api_key) + 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 _resolve_api_key(Authenticator(), api_key) + + @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 + resolved_api_base: Final = self.get_api_base(api_base) + resolved_api_key: Final = _resolve_api_key(self._authenticator, api_key) + 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. + response: Final = litellm.module_level_client.get( + url=f"{resolved_api_base}/models", + headers=get_copilot_default_headers(resolved_api_key), + ) + 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 64097021dff..b79ca4ea3a5 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -9165,6 +9165,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..b04ca7c76cc --- /dev/null +++ b/tests/test_litellm/llms/github_copilot/test_github_copilot_model_info.py @@ -0,0 +1,130 @@ +from unittest.mock import patch + +import httpx +import pytest +from pydantic import ValidationError + +import litellm +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" + +# Shape verified against a live Copilot /models response on 2026-09-24: top-level keys +# ["data", "object"], every entry carrying id, capabilities, model_picker_enabled, policy. +# model_picker_enabled is deliberately not a filter: Copilot clears it on callable models +# such as gpt-4o and text-embedding-3-small. +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}, + ], +} + + +class StubAuthenticator(Authenticator): + """Authenticator with its token files pointed at a tmp path, injected instead of patched.""" + + def __init__(self, token_file: str, api_key: str | None = "gh.test-key-123456789") -> None: + self.api_key_file = token_file + self.access_token_file = token_file + self._api_key = api_key + + def get_api_key(self) -> str: + if self._api_key is None: + raise GetAPIKeyError("no cached credential") + return self._api_key + + +@pytest.fixture +def cached_token(tmp_path): + token = tmp_path / "api-key.json" + token.write_text('{"token": "gh.test-key-123456789"}') + return str(token) + + +@pytest.fixture +def missing_token(tmp_path): + return str(tmp_path / "absent.json") + + +def _response(payload, status_code=200): + request = httpx.Request("GET", f"{API_BASE}/models") + return httpx.Response(status_code=status_code, json=payload, request=request) + + +def test_get_models_returns_every_prefixed_id(cached_token): + model_info = GithubCopilotModelInfo(StubAuthenticator(cached_token)) + + with patch.object(litellm.module_level_client, "get", return_value=_response(MODELS_PAYLOAD)) as mock_get: + models = model_info.get_models(api_base=API_BASE) + + assert models == [ + "github_copilot/gpt-4o", + "github_copilot/claude-opus-5.5", + "github_copilot/text-embedding-3-small", + ] + assert mock_get.call_args.kwargs["url"] == f"{API_BASE}/models" + + +def test_get_models_sends_copilot_integration_headers(cached_token): + model_info = GithubCopilotModelInfo(StubAuthenticator(cached_token)) + + with patch.object(litellm.module_level_client, "get", return_value=_response(MODELS_PAYLOAD)) as mock_get: + model_info.get_models(api_base=API_BASE) + + headers = mock_get.call_args.kwargs["headers"] + assert headers["Authorization"] == "Bearer gh.test-key-123456789" + assert headers["copilot-integration-id"] == "vscode-chat" + + +def test_get_models_respects_explicit_api_base_and_key(missing_token): + model_info = GithubCopilotModelInfo(StubAuthenticator(missing_token, api_key=None)) + + with patch.object(litellm.module_level_client, "get", return_value=_response(MODELS_PAYLOAD)) as mock_get: + model_info.get_models(api_key="explicit-key", api_base="https://copilot.example.com") + + assert mock_get.call_args.kwargs["url"] == "https://copilot.example.com/models" + assert mock_get.call_args.kwargs["headers"]["Authorization"] == "Bearer explicit-key" + + +def test_get_models_without_cached_credentials_never_starts_device_login(missing_token): + """Listing must fail fast rather than block on the interactive device-code flow.""" + model_info = GithubCopilotModelInfo(StubAuthenticator(missing_token, api_key=None)) + + with patch.object(Authenticator, "_login") as login: + with pytest.raises(ValueError, match="not authenticated"): + model_info.get_models(api_base=API_BASE) + + login.assert_not_called() + + +def test_get_models_raises_on_http_error(cached_token): + model_info = GithubCopilotModelInfo(StubAuthenticator(cached_token)) + + with patch.object(litellm.module_level_client, "get", return_value=_response({"error": "forbidden"}, 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(cached_token): + model_info = GithubCopilotModelInfo(StubAuthenticator(cached_token)) + + with patch.object(litellm.module_level_client, "get", return_value=_response({"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) From 1425dc3574940660811674a62f0c1037fe91bae2 Mon Sep 17 00:00:00 2001 From: Alexander Dreweke Date: Thu, 24 Sep 2026 13:35:38 +0200 Subject: [PATCH 2/3] fix(github_copilot): harden model listing after review Only treat a credential as usable when it needs no login: an unexpired api key, or an access token the refresh can exchange. The previous check accepted a non-empty file, so an expired api key with no access token fell through _refresh_api_key() into the blocking device-code flow. Bound the listing request with an explicit timeout, and ignore an api_key argument, since the completion path always authenticates with the cached Copilot credential; honouring one here would advertise a catalog the caller cannot invoke. Accept an injected HTTPHandler so the tests can fake the transport instead of patching, and drop the mock-echo assertions in favour of transports that refuse the request they should never see. --- litellm/llms/github_copilot/model_info.py | 59 ++-- .../test_github_copilot_model_info.py | 269 +++++++++++++----- 2 files changed, 237 insertions(+), 91 deletions(-) diff --git a/litellm/llms/github_copilot/model_info.py b/litellm/llms/github_copilot/model_info.py index 7b7f7aa5f72..a5cbe04242a 100644 --- a/litellm/llms/github_copilot/model_info.py +++ b/litellm/llms/github_copilot/model_info.py @@ -1,4 +1,5 @@ -import os +import json +from datetime import datetime from typing import Final import httpx @@ -7,12 +8,14 @@ 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): @@ -23,23 +26,29 @@ class CopilotModelsResponse(BaseModel): data: tuple[CopilotModel, ...] -def _has_cached_credentials(authenticator: Authenticator) -> bool: - for path in (authenticator.api_key_file, authenticator.access_token_file): - try: - if os.path.getsize(path) > 0: - return True - except OSError: - continue - return False +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 _resolve_api_key(authenticator: Authenticator, api_key: str | None) -> str | None: - if api_key is not None: - return api_key - # Never fall through to Authenticator's device-code login here: it prints a user code and - # blocks for about a minute, which a /v1/models call on a proxy must never do. - if not _has_cached_credentials(authenticator): - verbose_logger.warning("GitHub Copilot has no cached credentials; skipping model listing.") +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() @@ -49,8 +58,11 @@ def _resolve_api_key(authenticator: Authenticator, api_key: str | None) -> str | class GithubCopilotModelInfo(BaseLLMModelInfo): - def __init__(self, authenticator: Authenticator | None = None) -> None: + 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, @@ -62,7 +74,7 @@ class GithubCopilotModelInfo(BaseLLMModelInfo): api_key: str | None = None, api_base: str | None = None, ) -> dict: # mutable-ok: return type fixed by the base class - resolved_api_key: Final = self.get_api_key(api_key) + 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 @@ -75,7 +87,7 @@ class GithubCopilotModelInfo(BaseLLMModelInfo): @staticmethod def get_api_key(api_key: str | None = None) -> str | None: - return _resolve_api_key(Authenticator(), api_key) + return api_key if api_key is not None else _cached_api_key(Authenticator()) @staticmethod def get_base_model(model: str) -> str | None: @@ -84,8 +96,11 @@ class GithubCopilotModelInfo(BaseLLMModelInfo): 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 = _resolve_api_key(self._authenticator, api_key) + 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 " @@ -94,9 +109,11 @@ class GithubCopilotModelInfo(BaseLLMModelInfo): # 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. - response: Final = litellm.module_level_client.get( + 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() 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 index b04ca7c76cc..f99ceef3a59 100644 --- 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 @@ -1,10 +1,11 @@ -from unittest.mock import patch +import json +from datetime import datetime, timedelta import httpx import pytest from pydantic import ValidationError -import litellm +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 @@ -12,11 +13,8 @@ from litellm.types.utils import LlmProviders from litellm.utils import ProviderConfigManager API_BASE = "https://api.githubcopilot.com" +API_KEY = "gh.test-key-123456789" -# Shape verified against a live Copilot /models response on 2026-09-24: top-level keys -# ["data", "object"], every entry carrying id, capabilities, model_picker_enabled, policy. -# model_picker_enabled is deliberately not a filter: Copilot clears it on callable models -# such as gpt-4o and text-embedding-3-small. MODELS_PAYLOAD = { "object": "list", "data": [ @@ -26,98 +24,159 @@ MODELS_PAYLOAD = { ], } +EXPECTED_MODELS = [ + "github_copilot/gpt-4o", + "github_copilot/claude-opus-5.5", + "github_copilot/text-embedding-3-small", +] + class StubAuthenticator(Authenticator): - """Authenticator with its token files pointed at a tmp path, injected instead of patched.""" + """Token files point at a tmp path, and device login raises instead of blocking on a user code.""" - def __init__(self, token_file: str, api_key: str | None = "gh.test-key-123456789") -> None: - self.api_key_file = token_file - self.access_token_file = token_file - self._api_key = api_key + 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 get_api_key(self) -> str: - if self._api_key is None: - raise GetAPIKeyError("no cached credential") - return self._api_key + 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 cached_token(tmp_path): - token = tmp_path / "api-key.json" - token.write_text('{"token": "gh.test-key-123456789"}') - return str(token) +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 missing_token(tmp_path): - return str(tmp_path / "absent.json") +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"), + ) -def _response(payload, status_code=200): - request = httpx.Request("GET", f"{API_BASE}/models") - return httpx.Response(status_code=status_code, json=payload, request=request) +@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 test_get_models_returns_every_prefixed_id(cached_token): - model_info = GithubCopilotModelInfo(StubAuthenticator(cached_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))) - with patch.object(litellm.module_level_client, "get", return_value=_response(MODELS_PAYLOAD)) as mock_get: - models = model_info.get_models(api_base=API_BASE) - assert models == [ - "github_copilot/gpt-4o", - "github_copilot/claude-opus-5.5", - "github_copilot/text-embedding-3-small", +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): + # Copilot scopes the catalog to the integration id, so listing has to send the same headers the + # completion path sends. This transport answers with whatever credentials it was handed. + 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", ] - assert mock_get.call_args.kwargs["url"] == f"{API_BASE}/models" -def test_get_models_sends_copilot_integration_headers(cached_token): - model_info = GithubCopilotModelInfo(StubAuthenticator(cached_token)) +def test_get_models_bounds_the_request_so_a_slow_copilot_cannot_stall_the_caller(valid_credential): + seen: dict = {} - with patch.object(litellm.module_level_client, "get", return_value=_response(MODELS_PAYLOAD)) as mock_get: + 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): + # The transport serves a catalog at this base only and 404s anything else, so a listing that + # ignored the argument would raise instead of returning. + 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): + # Completions always authenticate with the cached Copilot credential, so listing must too. + # This transport only serves the caller presenting that 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) - headers = mock_get.call_args.kwargs["headers"] - assert headers["Authorization"] == "Bearer gh.test-key-123456789" - assert headers["copilot-integration-id"] == "vscode-chat" + +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_respects_explicit_api_base_and_key(missing_token): - model_info = GithubCopilotModelInfo(StubAuthenticator(missing_token, api_key=None)) +def test_get_models_raises_on_http_error(valid_credential): + model_info = GithubCopilotModelInfo(valid_credential, _copilot(_serving({"error": "forbidden"}, status_code=403))) - with patch.object(litellm.module_level_client, "get", return_value=_response(MODELS_PAYLOAD)) as mock_get: - model_info.get_models(api_key="explicit-key", api_base="https://copilot.example.com") - - assert mock_get.call_args.kwargs["url"] == "https://copilot.example.com/models" - assert mock_get.call_args.kwargs["headers"]["Authorization"] == "Bearer explicit-key" + with pytest.raises(Exception, match="Failed to fetch models from GitHub Copilot"): + model_info.get_models(api_base=API_BASE) -def test_get_models_without_cached_credentials_never_starts_device_login(missing_token): - """Listing must fail fast rather than block on the interactive device-code flow.""" - model_info = GithubCopilotModelInfo(StubAuthenticator(missing_token, api_key=None)) +def test_get_models_raises_on_unexpected_payload(valid_credential): + model_info = GithubCopilotModelInfo(valid_credential, _copilot(_serving({"models": []}))) - with patch.object(Authenticator, "_login") as login: - with pytest.raises(ValueError, match="not authenticated"): - model_info.get_models(api_base=API_BASE) - - login.assert_not_called() - - -def test_get_models_raises_on_http_error(cached_token): - model_info = GithubCopilotModelInfo(StubAuthenticator(cached_token)) - - with patch.object(litellm.module_level_client, "get", return_value=_response({"error": "forbidden"}, 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(cached_token): - model_info = GithubCopilotModelInfo(StubAuthenticator(cached_token)) - - with patch.object(litellm.module_level_client, "get", return_value=_response({"models": []})): - with pytest.raises(ValidationError, match="data"): - model_info.get_models(api_base=API_BASE) + with pytest.raises(ValidationError, match="data"): + model_info.get_models(api_base=API_BASE) def test_get_base_model_strips_prefix(): @@ -128,3 +187,73 @@ def test_get_base_model_strips_prefix(): 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" From b700cc8ca0c011c51f7c06fa2f40fdd8d54e933f Mon Sep 17 00:00:00 2001 From: Alexander Dreweke Date: Thu, 24 Sep 2026 14:12:30 +0200 Subject: [PATCH 3/3] remove comments --- .../llms/github_copilot/test_github_copilot_model_info.py | 6 ------ 1 file changed, 6 deletions(-) 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 index f99ceef3a59..f6c8a325957 100644 --- 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 @@ -98,8 +98,6 @@ def test_get_models_returns_every_prefixed_id(valid_credential): def test_get_models_sends_the_copilot_integration_headers(valid_credential): - # Copilot scopes the catalog to the integration id, so listing has to send the same headers the - # completion path sends. This transport answers with whatever credentials it was handed. def echo_headers(request: httpx.Request) -> httpx.Response: return httpx.Response( 200, @@ -134,8 +132,6 @@ def test_get_models_bounds_the_request_so_a_slow_copilot_cannot_stall_the_caller def test_get_models_honours_an_explicit_api_base(valid_credential): - # The transport serves a catalog at this base only and 404s anything else, so a listing that - # ignored the argument would raise instead of returning. model_info = GithubCopilotModelInfo( valid_credential, _copilot(_serving(MODELS_PAYLOAD, base="https://copilot.example.com")) ) @@ -144,8 +140,6 @@ def test_get_models_honours_an_explicit_api_base(valid_credential): def test_get_models_ignores_an_explicit_api_key_so_listing_matches_completions(valid_credential): - # Completions always authenticate with the cached Copilot credential, so listing must too. - # This transport only serves the caller presenting that 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