This commit is contained in:
Alexander Dreweke 2026-10-09 22:32:31 -04:00 • committed by GitHub
commit 16eb28249a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 386 additions and 0 deletions

View file

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

View file

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

View file

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