mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-16 04:29:31 +00:00
Merge b700cc8ca0 into 546402c98c
This commit is contained in:
commit
16eb28249a
3 changed files with 386 additions and 0 deletions
129
litellm/llms/github_copilot/model_info.py
Normal file
129
litellm/llms/github_copilot/model_info.py
Normal 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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
Loading…
Add table
Reference in a new issue