feat(cli): add lite credentials connect for per-user GitHub Copilot

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
yassin 2026-10-21 03:04:53 +00:00
parent dddbaa803d
commit c53db59174
7 changed files with 1109 additions and 36 deletions

View file

@ -6,6 +6,7 @@ touched on the per-user path.
import asyncio
import hmac
import shlex
import threading
import time
from collections.abc import Callable, Mapping
@ -244,7 +245,8 @@ def _reject_connection(credential_name: str) -> CallerCredentialAuthenticationEr
return CallerCredentialAuthenticationError(
message=(
f"GitHub rejected the GitHub Copilot connection for credential '{credential_name}'. "
f"Reconnect GitHub Copilot for credential '{credential_name}' in the LiteLLM UI (LLM Credentials)"
f"Reconnect GitHub Copilot for credential '{credential_name}' in the LiteLLM UI (LLM Credentials) "
f"or run `lite credentials connect {shlex.quote(credential_name)}`"
),
llm_provider=_LLM_PROVIDER,
model="",
@ -601,7 +603,10 @@ def github_copilot_auth_mode(litellm_credential_name: object, auth_type_value: o
def _connect_error(credential_name: str) -> CallerCredentialAuthenticationError:
return CallerCredentialAuthenticationError(
message=(f"Connect GitHub Copilot for credential '{credential_name}' in the LiteLLM UI (LLM Credentials)"),
message=(
f"Connect GitHub Copilot for credential '{credential_name}' in the LiteLLM UI (LLM Credentials) "
f"or run `lite credentials connect {shlex.quote(credential_name)}`"
),
llm_provider=_LLM_PROVIDER,
model="",
)

View file

@ -1,16 +1,33 @@
import json
from collections.abc import Sequence
from typing import Final, Literal
import re
import shlex
import threading
import time
import webbrowser
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from typing import Final, Literal, NoReturn, Protocol
from urllib.parse import urlparse
import click
import requests
import rich
from pydantic import BaseModel, ConfigDict
from rich.table import Table
from typing_extensions import NotRequired, ReadOnly, TypedDict
from ...credentials import CredentialsManagementClient
from ...credentials import (
CredentialsManagementClient,
UserConnection,
UserConnectionPoll,
UserConnectionStart,
)
from ...exceptions import UnauthorizedError
from ._cli_context import cli_context_values
SLOW_DOWN_INCREMENT_SECONDS: Final = 5
GITHUB_DEVICE_LOGIN_URL: Final = "https://github.com/login/device"
class _CredentialInfo(TypedDict):
custom_llm_provider: ReadOnly[NotRequired[str]]
@ -35,15 +52,320 @@ class _JsonBodyView(TypedDict):
body: ReadOnly[object]
class _ProxyErrorMessage(BaseModel):
model_config = ConfigDict(frozen=True)
message: str
type: str | None = None
param: str | None = None
code: str | None = None
class _FastAPIErrorDetail(BaseModel):
model_config = ConfigDict(frozen=True)
error: str
class _ProxyErrorBody(BaseModel):
model_config = ConfigDict(frozen=True)
detail: str | _FastAPIErrorDetail | None = None
error: _ProxyErrorMessage | None = None
class UserConnectionClient(Protocol):
def start_user_connection(self, credential_name: str) -> UserConnectionStart: ...
def poll_user_connection(self, credential_name: str, flow_handle: str) -> UserConnectionPoll: ...
@dataclass(frozen=True, slots=True)
class UserConnectionConnected:
status: Literal["connected"]
github_login: str | None
@dataclass(frozen=True, slots=True)
class UserConnectionFailed:
status: Literal["failed"]
reason: Literal["expired", "denied", "no_copilot_seat"]
message: str
def _safe_verification_uri(uri: str) -> str:
try:
parsed: Final = urlparse(uri)
host: Final = parsed.hostname
has_credentials: Final = parsed.username is not None or parsed.password is not None
has_no_port: Final = parsed.port is None
host_is_allowed: Final = host == "github.com" or (
host is not None and re.fullmatch(r"[a-z0-9-]+\.ghe\.com", host) is not None
)
if parsed.scheme == "https" and not has_credentials and has_no_port and host_is_allowed:
return uri
except ValueError:
pass
return GITHUB_DEVICE_LOGIN_URL
def _user_connection_failure(
reason: Literal["expired", "denied", "no_copilot_seat"],
credential_name: str,
) -> UserConnectionFailed:
command: Final = f"lite credentials connect {shlex.quote(credential_name)}"
match reason:
case "expired":
return UserConnectionFailed(
status="failed",
reason=reason,
message=f"The device code expired before it was approved. Run `{command}` again to get a new code",
)
case "denied":
return UserConnectionFailed(
status="failed",
reason=reason,
message=f"The request was denied on GitHub. Run `{command}` again if this was a mistake",
)
case "no_copilot_seat":
return UserConnectionFailed(
status="failed",
reason=reason,
message="This GitHub account does not have GitHub Copilot access, so nothing was saved",
)
class _ConnectionPoller:
def __init__(
self,
client: UserConnectionClient,
credential_name: str,
flow_handle: str,
interval: int,
deadline: float,
sleep: Callable[[float], None],
clock: Callable[[], float],
) -> None:
self._client = client
self._credential_name = credential_name
self._flow_handle = flow_handle
self._interval = interval
self._deadline = deadline
self._sleep = sleep
self._clock = clock
def poll(self) -> UserConnectionPoll | None:
if self._clock() >= self._deadline:
return None
self._sleep(self._interval)
if self._clock() >= self._deadline:
return None
response: Final = self._client.poll_user_connection(self._credential_name, self._flow_handle)
if response.status == "slow_down":
self._interval = (
response.interval if response.interval is not None else self._interval + SLOW_DOWN_INCREMENT_SECONDS
)
return response
def run_user_connection(
client: UserConnectionClient,
credential_name: str,
*,
open_browser: bool,
launch_browser: Callable[[str], object],
sleep: Callable[[float], None],
clock: Callable[[], float],
echo: Callable[[str], None],
) -> UserConnectionConnected | UserConnectionFailed:
start: Final = client.start_user_connection(credential_name)
verification_uri: Final = _safe_verification_uri(start.verification_uri)
echo(
f"To connect GitHub Copilot for credential {credential_name!r}, open {verification_uri} "
f"and enter code {start.user_code}"
)
minutes: Final = max(1, round(start.expires_in / 60))
minute_unit: Final = "minute" if minutes == 1 else "minutes"
echo(f"Waiting for approval. The code expires in {minutes} {minute_unit}")
if open_browser:
launch_browser(verification_uri)
poller: Final = _ConnectionPoller(
client,
credential_name,
start.flow_handle,
start.interval,
clock() + start.expires_in,
sleep,
clock,
)
for response in iter(poller.poll, None):
match response.status:
case "pending" | "slow_down":
continue
case "connected":
if response.github_login is not None:
echo(f"Connected GitHub Copilot for credential {credential_name!r} as {response.github_login}")
else:
echo(f"Connected GitHub Copilot for credential {credential_name!r}")
return UserConnectionConnected(status="connected", github_login=response.github_login)
case "expired" | "denied" | "no_copilot_seat":
return _user_connection_failure(response.status, credential_name)
return _user_connection_failure("expired", credential_name)
def _launch_browser(uri: str) -> object:
browser_thread: Final = threading.Thread(target=webbrowser.open, args=(uri,), daemon=True)
browser_thread.start()
return browser_thread
def _http_error_detail(error: requests.exceptions.HTTPError) -> str:
response: Final = error.response
if response is None:
return str(error)[:500]
try:
body: Final = _ProxyErrorBody.model_validate(response.json())
except ValueError:
return response.text[:500]
if body.error is not None:
return body.error.message[:500]
detail: Final = body.detail
if isinstance(detail, str):
return detail[:500]
if detail is not None:
return detail.error[:500]
return response.text[:500]
def _raise_http_error(error: requests.exceptions.HTTPError) -> NoReturn:
response: Final = error.response
status: Final = response.status_code if response is not None else "unknown"
raise click.ClickException(f"HTTP {status}: {_http_error_detail(error)}") from error
def _raise_proxy_error(
error: requests.exceptions.HTTPError | requests.exceptions.ConnectionError | UnauthorizedError,
) -> NoReturn:
if isinstance(error, UnauthorizedError):
original: Final = error.orig_exception
if isinstance(original, requests.exceptions.HTTPError):
_raise_http_error(original)
raise click.ClickException(str(error)) from error
if isinstance(error, requests.exceptions.HTTPError):
_raise_http_error(error)
raise click.ClickException(f"Could not connect to the LiteLLM proxy: {error}") from error
def _print_json(data: object) -> None:
rich.print_json(data=data)
def _render_table(
title: str,
columns: tuple[tuple[str, str | None], ...],
rows: Sequence[tuple[str, ...]],
) -> None:
table: Final = Table(title=title)
for column_name, style in columns:
if style is None:
table.add_column(column_name)
else:
table.add_column(column_name, style=style)
for row in rows:
table.add_row(*row)
rich.print(table)
def _credential_table_row(credential: _CredentialItem) -> tuple[str, str, str, str]:
info: Final = credential.get("credential_info", {})
return (
str(credential.get("credential_name", "")),
credential.get("display_name") or "",
str(credential.get("source", "")),
str(info.get("custom_llm_provider", "")),
)
def _connection_table_row(connection: UserConnection) -> tuple[str, str, str, str]:
return (
connection.credential_name,
"yes" if connection.connected else "no",
connection.github_login or "",
connection.connected_at or "",
)
@click.group()
def credentials():
"""Manage credentials for the LiteLLM proxy server"""
@credentials.command()
@click.argument("credential_name")
@click.option("--no-browser", is_flag=True, help="Do not open the verification page in a browser")
@click.pass_context
def connect(ctx: click.Context, credential_name: str, no_browser: bool) -> None:
"""Connect a GitHub Copilot account to a per-user credential"""
context: Final = cli_context_values(ctx)
client: Final = CredentialsManagementClient(context["base_url"], context["api_key"])
try:
result: Final = run_user_connection(
client,
credential_name,
open_browser=not no_browser,
launch_browser=_launch_browser,
sleep=time.sleep,
clock=time.monotonic,
echo=click.echo,
)
except (requests.exceptions.HTTPError, requests.exceptions.ConnectionError, UnauthorizedError) as error:
_raise_proxy_error(error)
if isinstance(result, UserConnectionFailed):
raise click.ClickException(result.message)
@credentials.command()
@click.option(
"--format",
"output_format",
type=click.Choice(["table", "json"]),
default="table",
help="Output format (table or json)",
)
@click.pass_context
def connections(ctx: click.Context, output_format: Literal["table", "json"]) -> None:
"""List GitHub Copilot connections for the authenticated user"""
context: Final = cli_context_values(ctx)
client: Final = CredentialsManagementClient(context["base_url"], context["api_key"])
try:
response: Final = client.list_user_connections()
except (requests.exceptions.HTTPError, requests.exceptions.ConnectionError, UnauthorizedError) as error:
_raise_proxy_error(error)
if output_format == "json":
_print_json(response.model_dump(mode="json"))
return
rows: Final = tuple(_connection_table_row(connection) for connection in response.connections)
_render_table(
"GitHub Copilot connections",
(("Credential Name", "cyan"), ("Connected", None), ("GitHub Login", None), ("Connected At", None)),
rows,
)
@credentials.command()
@click.argument("credential_name")
@click.pass_context
def disconnect(ctx: click.Context, credential_name: str) -> None:
"""Disconnect a GitHub Copilot account from a per-user credential"""
context: Final = cli_context_values(ctx)
client: Final = CredentialsManagementClient(context["base_url"], context["api_key"])
try:
client.delete_user_connection(credential_name)
except (requests.exceptions.HTTPError, requests.exceptions.ConnectionError, UnauthorizedError) as error:
_raise_proxy_error(error)
click.echo(f"Disconnected GitHub Copilot for credential {credential_name!r}")
@credentials.command()
@click.option(
"--format",
@ -62,27 +384,19 @@ def list(ctx: click.Context, output_format: Literal["table", "json"]):
if output_format == "json":
_print_json(response)
else: # table format
table: Final = Table(title="Credentials")
# Add columns
table.add_column("Credential Name", style="cyan")
table.add_column("Display Name")
table.add_column("Source")
table.add_column("Custom LLM Provider", style="green")
# Add rows
else:
listed: Final[_CredentialsListView] = {"credentials": response.get("credentials", [])}
for cred in listed["credentials"]:
info = cred.get("credential_info", {})
table.add_row(
str(cred.get("credential_name", "")),
cred.get("display_name") or "",
str(cred.get("source", "")),
str(info.get("custom_llm_provider", "")),
)
rich.print(table)
rows: Final = tuple(_credential_table_row(credential) for credential in listed["credentials"])
_render_table(
"Credentials",
(
("Credential Name", "cyan"),
("Display Name", None),
("Source", None),
("Custom LLM Provider", "green"),
),
rows,
)
@credentials.command()

View file

@ -1,12 +1,53 @@
from collections.abc import Mapping
from typing import Any, Final
from typing import Any, Final, Literal
from urllib.parse import quote
import requests
from pydantic import BaseModel, ConfigDict, Field
from .exceptions import UnauthorizedError
class UserConnectionStart(BaseModel):
model_config = ConfigDict(frozen=True)
user_code: str
verification_uri: str
expires_in: int
interval: int
flow_handle: str = Field(repr=False)
class UserConnectionPoll(BaseModel):
model_config = ConfigDict(frozen=True)
status: Literal["pending", "slow_down", "expired", "denied", "connected", "no_copilot_seat"]
interval: int | None = None
github_login: str | None = None
class UserConnection(BaseModel):
model_config = ConfigDict(frozen=True)
credential_name: str
provider: str
connected: bool
github_login: str | None
connected_at: str | None
class UserConnections(BaseModel):
model_config = ConfigDict(frozen=True)
connections: tuple[UserConnection, ...]
class UserConnectionDelete(BaseModel):
model_config = ConfigDict(frozen=True)
status: Literal["disconnected"]
class CredentialsManagementClient:
def __init__(self, base_url: str, api_key: str | None = None, timeout: int = 30):
"""
@ -214,3 +255,43 @@ class CredentialsManagementClient:
if e.response.status_code == 401:
raise UnauthorizedError(e)
raise
def _send_user_connection_request(self, request: requests.Request) -> requests.Response:
with requests.Session() as session:
response: Final = session.send(request.prepare(), timeout=self._timeout)
try:
response.raise_for_status()
except requests.exceptions.HTTPError as error:
if error.response is not None and error.response.status_code == 401:
raise UnauthorizedError(error) from error
raise
return response
def start_user_connection(self, credential_name: str) -> UserConnectionStart:
url: Final = f"{self._base_url}/credentials/{quote(credential_name, safe='')}/user_connection/start"
request: Final = requests.Request("POST", url, headers=self._get_headers())
response: Final = self._send_user_connection_request(request)
return UserConnectionStart.model_validate(response.json())
def poll_user_connection(self, credential_name: str, flow_handle: str) -> UserConnectionPoll:
url: Final = f"{self._base_url}/credentials/{quote(credential_name, safe='')}/user_connection/poll"
request: Final = requests.Request(
"POST",
url,
headers=self._get_headers(),
json={"flow_handle": flow_handle},
)
response: Final = self._send_user_connection_request(request)
return UserConnectionPoll.model_validate(response.json())
def list_user_connections(self) -> UserConnections:
url: Final = f"{self._base_url}/credentials/user_connections"
request: Final = requests.Request("GET", url, headers=self._get_headers())
response: Final = self._send_user_connection_request(request)
return UserConnections.model_validate(response.json())
def delete_user_connection(self, credential_name: str) -> UserConnectionDelete:
url: Final = f"{self._base_url}/credentials/{quote(credential_name, safe='')}/user_connection"
request: Final = requests.Request("DELETE", url, headers=self._get_headers())
response: Final = self._send_user_connection_request(request)
return UserConnectionDelete.model_validate(response.json())

View file

@ -254,8 +254,10 @@ def test_exchange_auth_failures_raise_caller_auth_error_and_evict(status):
with github_http(_exchange_responder(status=status)):
with pytest.raises(CallerCredentialAuthenticationError) as exc:
exchange_github_token("user-a", "gh-token", "copilot-cred", _GITHUB_ENDPOINTS)
assert "Reconnect GitHub Copilot for credential 'copilot-cred' in the LiteLLM UI (LLM Credentials)" in str(
exc.value
assert str(exc.value) == (
"litellm.AuthenticationError: GitHub rejected the GitHub Copilot connection for credential 'copilot-cred'. "
"Reconnect GitHub Copilot for credential 'copilot-cred' in the LiteLLM UI (LLM Credentials) "
"or run `lite credentials connect copilot-cred`"
)
assert (
_SESSION_CACHE.get_cache(_session_cache_key("user-a", "copilot-cred", GITHUB_COPILOT_DEFAULT_HOST)) is None
@ -346,7 +348,11 @@ def test_attach_per_user_without_connection_raises_connect_401(per_user_credenti
kwargs = {"litellm_credential_name": "copilot-cred", "secret_fields": {}}
with pytest.raises(CallerCredentialAuthenticationError) as exc:
attach_github_copilot_user_session(kwargs)
assert "Connect GitHub Copilot for credential 'copilot-cred' in the LiteLLM UI (LLM Credentials)" in str(exc.value)
assert str(exc.value) == (
"litellm.AuthenticationError: Connect GitHub Copilot for credential 'copilot-cred' "
"in the LiteLLM UI (LLM Credentials) "
"or run `lite credentials connect copilot-cred`"
)
@pytest.mark.parametrize(
@ -362,10 +368,26 @@ def test_attach_rejects_connection_from_a_different_github_host(
with github_http(lambda request: pytest.fail(f"unexpected token exchange at {request.url}")) as requests:
with pytest.raises(CallerCredentialAuthenticationError) as exc:
attach_github_copilot_user_session(kwargs)
assert "Connect GitHub Copilot for credential 'copilot-cred'" in str(exc.value)
assert str(exc.value) == (
"litellm.AuthenticationError: Connect GitHub Copilot for credential 'copilot-cred' "
"in the LiteLLM UI (LLM Credentials) "
"or run `lite credentials connect copilot-cred`"
)
assert not requests
def test_exchange_auth_failure_quotes_credential_name_in_cli_hint():
with github_http(_exchange_responder(status=401)):
with pytest.raises(CallerCredentialAuthenticationError) as exc:
exchange_github_token("user-a", "gh-token", "my cred", _GITHUB_ENDPOINTS)
assert str(exc.value) == (
"litellm.AuthenticationError: GitHub rejected the GitHub Copilot connection for credential 'my cred'. "
"Reconnect GitHub Copilot for credential 'my cred' in the LiteLLM UI (LLM Credentials) "
"or run `lite credentials connect 'my cred'`"
)
def test_legacy_connection_without_host_uses_github_com(per_user_credential):
with github_http(_exchange_responder()) as requests:
kwargs: Final = _kwargs_with_connection(github_host=None)

View file

@ -1,13 +1,20 @@
import json
from collections.abc import Iterator
from typing import Final
from unittest.mock import MagicMock
import pytest
import requests
import responses
from click.testing import CliRunner
from litellm.proxy.client.cli.commands.credentials import (
UserConnectionConnected,
UserConnectionFailed,
run_user_connection,
)
from litellm.proxy.client.cli.main import cli
from litellm.proxy.client.credentials import UserConnectionPoll, UserConnectionStart
@pytest.fixture
@ -264,3 +271,518 @@ def test_update_credential_requires_exactly_one_display_name_flag(cli_runner, mo
assert result.exit_code != 0
assert "exactly one" in result.output
mock_credentials_client.return_value.update_display_name.assert_not_called()
class _SequenceUserConnectionClient:
def __init__(
self,
start: UserConnectionStart,
poll_responses: tuple[UserConnectionPoll, ...],
) -> None:
self._start = start
self._poll_responses: Iterator[UserConnectionPoll] = iter(poll_responses)
def start_user_connection(self, credential_name: str) -> UserConnectionStart:
return self._start
def poll_user_connection(self, credential_name: str, flow_handle: str) -> UserConnectionPoll:
return next(self._poll_responses)
class _StringRecorder:
def __init__(self) -> None:
self.values: tuple[str, ...] = ()
def __call__(self, value: str) -> None:
self.values = (*self.values, value)
class _SleepRecorder:
def __init__(self) -> None:
self.values: tuple[float, ...] = ()
def __call__(self, value: float) -> None:
self.values = (*self.values, value)
class _AdvancingClock:
def __init__(self) -> None:
self.current: float = 0
self.sleeps: tuple[float, ...] = ()
def __call__(self) -> float:
return self.current
def sleep(self, seconds: float) -> None:
self.current += seconds
self.sleeps = (*self.sleeps, seconds)
def advance(self, seconds: float) -> None:
self.current += seconds
class _LateConnectedUserConnectionClient:
def __init__(self, clock: _AdvancingClock) -> None:
self._clock = clock
def start_user_connection(self, _credential_name: str) -> UserConnectionStart:
return _start_response(interval=0, expires_in=3)
def poll_user_connection(self, _credential_name: str, _flow_handle: str) -> UserConnectionPoll:
self._clock.advance(4)
return UserConnectionPoll(status="connected", github_login="octocat")
def _start_response(
verification_uri: str = "https://github.com/login/device",
interval: int = 5,
expires_in: int = 900,
) -> UserConnectionStart:
return UserConnectionStart(
user_code="ABCD-1234",
verification_uri=verification_uri,
expires_in=expires_in,
interval=interval,
flow_handle="flow-handle",
)
def _cli_args(*args: str) -> tuple[str, ...]:
return ("--base-url", "http://fake-proxy", "--api-key", "sk-user", *args)
def _register_start_response(credential_name: str = "copilot", expires_in: int = 900) -> None:
responses.add(
responses.POST,
f"http://fake-proxy/credentials/{credential_name}/user_connection/start",
json={
"user_code": "ABCD-1234",
"verification_uri": "https://github.com/login/device",
"expires_in": expires_in,
"interval": 0,
"flow_handle": "flow-handle",
},
status=200,
)
@pytest.mark.parametrize(
("expires_in", "expected_expiry"),
[(899, "15 minutes"), (60, "1 minute"), (20, "1 minute")],
)
@responses.activate
def test_connect_command_polls_until_connected(
cli_runner: CliRunner,
expires_in: int,
expected_expiry: str,
) -> None:
_register_start_response(expires_in=expires_in)
responses.add(
responses.POST,
"http://fake-proxy/credentials/copilot/user_connection/poll",
json={"status": "pending"},
status=200,
)
responses.add(
responses.POST,
"http://fake-proxy/credentials/copilot/user_connection/poll",
json={"status": "slow_down", "interval": 0},
status=200,
)
responses.add(
responses.POST,
"http://fake-proxy/credentials/copilot/user_connection/poll",
json={"status": "connected", "github_login": "octocat"},
status=200,
)
result: Final = cli_runner.invoke(cli, _cli_args("credentials", "connect", "copilot", "--no-browser"))
assert result.exit_code == 0, result.output
assert "ABCD-1234" in result.output
assert "https://github.com/login/device" in result.output
assert f"The code expires in {expected_expiry}" in result.output
assert "Connected GitHub Copilot for credential 'copilot' as octocat" in result.output
@pytest.mark.parametrize(
("status", "message"),
[
(
"expired",
"The device code expired before it was approved. "
"Run `lite credentials connect copilot` again to get a new code",
),
(
"denied",
"The request was denied on GitHub. Run `lite credentials connect copilot` again if this was a mistake",
),
(
"no_copilot_seat",
"This GitHub account does not have GitHub Copilot access, so nothing was saved",
),
],
)
@responses.activate
def test_connect_command_reports_terminal_poll_statuses(
cli_runner: CliRunner,
status: str,
message: str,
) -> None:
_register_start_response()
responses.add(
responses.POST,
"http://fake-proxy/credentials/copilot/user_connection/poll",
json={"status": status},
status=200,
)
result: Final = cli_runner.invoke(cli, _cli_args("credentials", "connect", "copilot", "--no-browser"))
assert result.exit_code == 1
assert result.output.splitlines()[-1] == f"Error: {message}"
@responses.activate
def test_connect_command_reports_proxy_connection_error(cli_runner: CliRunner) -> None:
responses.add(
responses.POST,
"http://fake-proxy/credentials/copilot/user_connection/start",
body=requests.exceptions.ConnectionError("connection refused"),
)
result: Final = cli_runner.invoke(cli, _cli_args("credentials", "connect", "copilot", "--no-browser"))
assert result.exit_code == 1
assert "Could not connect to the LiteLLM proxy" in result.output
@responses.activate
def test_connect_command_prints_unauthorized_detail(cli_runner: CliRunner) -> None:
responses.add(
responses.POST,
"http://fake-proxy/credentials/copilot/user_connection/start",
json={"detail": "An authenticated user is required"},
status=401,
)
result: Final = cli_runner.invoke(cli, _cli_args("credentials", "connect", "copilot", "--no-browser"))
assert result.exit_code == 1
assert "Error: HTTP 401: An authenticated user is required" in result.output
@responses.activate
def test_connect_command_prints_proxy_404_error_message(cli_runner: CliRunner) -> None:
responses.add(
responses.POST,
"http://fake-proxy/credentials/copilot/user_connection/start",
json={
"error": {
"message": "Credential not found",
"type": "internal_server_error",
"param": None,
"code": "404",
}
},
status=404,
)
result: Final = cli_runner.invoke(cli, _cli_args("credentials", "connect", "copilot", "--no-browser"))
assert result.exit_code == 1
assert result.output.splitlines()[-1] == "Error: HTTP 404: Credential not found"
@responses.activate
def test_connect_command_prints_proxy_401_error_message(cli_runner: CliRunner) -> None:
responses.add(
responses.POST,
"http://fake-proxy/credentials/copilot/user_connection/start",
json={
"error": {
"message": "Authentication Error, Invalid API key provided.",
"type": "token_not_found_in_db",
"param": "key",
"code": "401",
}
},
status=401,
)
result: Final = cli_runner.invoke(cli, _cli_args("credentials", "connect", "copilot", "--no-browser"))
assert result.exit_code == 1
assert result.output.splitlines()[-1] == "Error: HTTP 401: Authentication Error, Invalid API key provided."
@responses.activate
def test_connect_command_prints_nested_fastapi_error_detail(cli_runner: CliRunner) -> None:
responses.add(
responses.POST,
"http://fake-proxy/credentials/copilot/user_connection/start",
json={"detail": {"error": "Invalid flow details"}},
status=400,
)
result: Final = cli_runner.invoke(cli, _cli_args("credentials", "connect", "copilot", "--no-browser"))
assert result.exit_code == 1
assert result.output.splitlines()[-1] == "Error: HTTP 400: Invalid flow details"
@responses.activate
def test_connect_command_reports_invalid_flow_handle(cli_runner: CliRunner) -> None:
_register_start_response()
responses.add(
responses.POST,
"http://fake-proxy/credentials/copilot/user_connection/poll",
json={"detail": "Invalid flow handle"},
status=400,
)
result: Final = cli_runner.invoke(cli, _cli_args("credentials", "connect", "copilot", "--no-browser"))
assert result.exit_code == 1
assert "Error: HTTP 400: Invalid flow handle" in result.output
@pytest.mark.parametrize("output_format", ["table", "json"])
@responses.activate
def test_connections_command_lists_per_user_connections(cli_runner: CliRunner, output_format: str) -> None:
response: Final = {
"connections": [
{
"credential_name": "copilot-prod",
"provider": "github_copilot",
"connected": True,
"github_login": "octocat",
"connected_at": "2025-01-19T12:00:00+00:00",
},
{
"credential_name": "copilot-dev",
"provider": "github_copilot",
"connected": False,
"github_login": None,
"connected_at": None,
},
]
}
responses.add(
responses.GET,
"http://fake-proxy/credentials/user_connections",
json=response,
status=200,
)
result: Final = cli_runner.invoke(
cli,
_cli_args("credentials", "connections", "--format", output_format),
env={"COLUMNS": "200"},
)
assert result.exit_code == 0, result.output
if output_format == "json":
assert json.loads(result.output) == response
else:
assert "GitHub Copilot connections" in result.output
assert "Credential Name" in result.output
assert "Connected" in result.output
assert "GitHub Login" in result.output
assert "Connected At" in result.output
prod_row: Final = next((line for line in result.output.splitlines() if "copilot-prod" in line), "")
dev_row: Final = next((line for line in result.output.splitlines() if "copilot-dev" in line), "")
assert "yes" in prod_row
assert "octocat" in prod_row
assert "2025-01-19T12:00:00+00:00" in prod_row
assert "no" in dev_row
@responses.activate
def test_disconnect_command_encodes_credential_name(cli_runner: CliRunner) -> None:
credential_name: Final = "my/ credential"
responses.add(
responses.DELETE,
"http://fake-proxy/credentials/my%2F%20credential/user_connection",
json={"status": "disconnected"},
status=200,
)
result: Final = cli_runner.invoke(
cli,
_cli_args("credentials", "disconnect", credential_name),
)
assert result.exit_code == 0, result.output
assert "Disconnected GitHub Copilot for credential 'my/ credential'" in result.output
assert responses.calls[0].request.url == "http://fake-proxy/credentials/my%2F%20credential/user_connection"
def test_run_user_connection_uses_response_intervals_and_slow_down_fallback() -> None:
client: Final = _SequenceUserConnectionClient(
_start_response(interval=5),
(
UserConnectionPoll(status="pending"),
UserConnectionPoll(status="slow_down", interval=10),
UserConnectionPoll(status="slow_down"),
UserConnectionPoll(status="connected", github_login="octocat"),
),
)
sleep_calls: Final = _SleepRecorder()
output: Final = _StringRecorder()
result: Final = run_user_connection(
client,
"copilot",
open_browser=False,
launch_browser=lambda uri: None,
sleep=sleep_calls,
clock=lambda: 0,
echo=output,
)
assert result == UserConnectionConnected(status="connected", github_login="octocat")
assert sleep_calls.values == (5, 5, 10, 15)
assert output.values[-1] == "Connected GitHub Copilot for credential 'copilot' as octocat"
def test_run_user_connection_honors_server_poll_interval() -> None:
sleep_calls: Final = _SleepRecorder()
client: Final = _SequenceUserConnectionClient(
_start_response(),
(
UserConnectionPoll(status="slow_down", interval=9),
UserConnectionPoll(status="connected", github_login="octocat"),
),
)
result: Final = run_user_connection(
client,
"copilot",
open_browser=False,
launch_browser=lambda uri: None,
sleep=sleep_calls,
clock=lambda: 0,
echo=_StringRecorder(),
)
assert result == UserConnectionConnected(status="connected", github_login="octocat")
assert sleep_calls.values == (5, 9)
def test_run_user_connection_stops_at_local_expiry_deadline() -> None:
clock: Final = _AdvancingClock()
client: Final = _SequenceUserConnectionClient(
_start_response(interval=2, expires_in=3),
(UserConnectionPoll(status="pending"), UserConnectionPoll(status="pending")),
)
result: Final = run_user_connection(
client,
"copilot",
open_browser=False,
launch_browser=lambda uri: None,
sleep=clock.sleep,
clock=clock,
echo=lambda message: None,
)
assert result == UserConnectionFailed(
status="failed",
reason="expired",
message=(
"The device code expired before it was approved. "
"Run `lite credentials connect copilot` again to get a new code"
),
)
assert clock.sleeps == (2, 2)
def test_run_user_connection_accepts_connected_poll_received_after_deadline() -> None:
clock: Final = _AdvancingClock()
client: Final = _LateConnectedUserConnectionClient(clock)
result: Final = run_user_connection(
client,
"copilot",
open_browser=False,
launch_browser=lambda uri: None,
sleep=clock.sleep,
clock=clock,
echo=lambda message: None,
)
assert result == UserConnectionConnected(status="connected", github_login="octocat")
def test_run_user_connection_opens_safe_url_only_when_requested() -> None:
launches: Final = _StringRecorder()
client: Final = _SequenceUserConnectionClient(
_start_response(),
(UserConnectionPoll(status="connected", github_login="octocat"),),
)
result: Final = run_user_connection(
client,
"copilot",
open_browser=True,
launch_browser=launches,
sleep=lambda seconds: None,
clock=lambda: 0,
echo=lambda message: None,
)
assert result == UserConnectionConnected(status="connected", github_login="octocat")
assert launches.values == ("https://github.com/login/device",)
def test_run_user_connection_does_not_launch_browser_when_disabled() -> None:
launches: Final = _StringRecorder()
client: Final = _SequenceUserConnectionClient(
_start_response(),
(UserConnectionPoll(status="connected", github_login="octocat"),),
)
run_user_connection(
client,
"copilot",
open_browser=False,
launch_browser=launches,
sleep=lambda seconds: None,
clock=lambda: 0,
echo=lambda message: None,
)
assert launches.values == ()
@pytest.mark.parametrize(
"verification_uri",
[
"http://github.com/login/device",
"https://github.com:443/login/device",
"https://user@github.com/login/device",
"https://github.com.evil.com/login/device",
],
)
def test_run_user_connection_replaces_unsafe_verification_uri(verification_uri: str) -> None:
launches: Final = _StringRecorder()
output: Final = _StringRecorder()
client: Final = _SequenceUserConnectionClient(
_start_response(verification_uri=verification_uri, interval=0),
(UserConnectionPoll(status="connected", github_login="octocat"),),
)
run_user_connection(
client,
"copilot",
open_browser=True,
launch_browser=launches,
sleep=lambda seconds: None,
clock=lambda: 0,
echo=output,
)
assert launches.values == ("https://github.com/login/device",)
assert "https://github.com/login/device" in output.values[0]
assert verification_uri not in output.values[0]

View file

@ -1,18 +1,40 @@
import time
from typing import Final
import pytest
import requests
import responses
from pydantic import BaseModel, ConfigDict, TypeAdapter
from litellm.proxy.client.credentials import CredentialsManagementClient
from litellm.proxy.client.credentials import (
CredentialsManagementClient,
UserConnection,
UserConnectionDelete,
UserConnectionPoll,
UserConnections,
UserConnectionStart,
)
from litellm.proxy.client.exceptions import UnauthorizedError
from litellm.proxy.credential_endpoints.endpoints import CredentialHelperUtils
from litellm.types.utils import CredentialItem
class _CapturedRequest(BaseModel):
model_config = ConfigDict(frozen=True, from_attributes=True)
headers: dict[str, str]
body: bytes | str | None
method: str | None
url: str | None
class _CapturedCall(BaseModel):
model_config = ConfigDict(frozen=True, from_attributes=True)
request: _CapturedRequest
@pytest.fixture
def base_url():
return "http://localhost:8000"
@ -316,3 +338,108 @@ def test_list_gives_up_at_the_timeout_instead_of_hanging(hanging_server):
client.list()
assert time.monotonic() - started < 10
@responses.activate
def test_user_connection_methods_encode_names_send_auth_and_validate_responses(
client: CredentialsManagementClient,
base_url: str,
api_key: str,
) -> None:
credential_name: Final = "my/ credential"
credential_path: Final = "my%2F%20credential"
start_payload: Final = {
"user_code": "ABCD-1234",
"verification_uri": "https://github.com/login/device",
"expires_in": 900,
"interval": 5,
"flow_handle": "encrypted-handle",
}
poll_payload: Final = {"status": "slow_down", "interval": 10, "github_login": None}
connections_payload: Final = {
"connections": [
{
"credential_name": credential_name,
"provider": "github_copilot",
"connected": True,
"github_login": "octocat",
"connected_at": "2025-01-19T12:00:00+00:00",
}
]
}
delete_payload: Final = {"status": "disconnected"}
responses.add(
responses.POST,
f"{base_url}/credentials/{credential_path}/user_connection/start",
json=start_payload,
status=200,
)
responses.add(
responses.POST,
f"{base_url}/credentials/{credential_path}/user_connection/poll",
json=poll_payload,
status=200,
)
responses.add(
responses.GET,
f"{base_url}/credentials/user_connections",
json=connections_payload,
status=200,
)
responses.add(
responses.DELETE,
f"{base_url}/credentials/{credential_path}/user_connection",
json=delete_payload,
status=200,
)
start: Final = client.start_user_connection(credential_name)
poll: Final = client.poll_user_connection(credential_name, "encrypted-handle")
connections: Final = client.list_user_connections()
deleted: Final = client.delete_user_connection(credential_name)
calls: Final = TypeAdapter(tuple[_CapturedCall, ...]).validate_python(responses.calls)
assert start == UserConnectionStart.model_validate(start_payload)
assert poll == UserConnectionPoll.model_validate(poll_payload)
assert connections == UserConnections.model_validate(connections_payload)
assert connections.connections == (
UserConnection.model_validate(connections_payload["connections"][0]),
)
assert deleted == UserConnectionDelete.model_validate(delete_payload)
assert tuple(call.request.headers["Authorization"] for call in calls) == (f"Bearer {api_key}",) * 4
assert calls[1].request.body == b'{"flow_handle": "encrypted-handle"}'
assert tuple(call.request.method for call in calls) == ("POST", "POST", "GET", "DELETE")
assert tuple(call.request.url for call in calls) == (
f"{base_url}/credentials/{credential_path}/user_connection/start",
f"{base_url}/credentials/{credential_path}/user_connection/poll",
f"{base_url}/credentials/user_connections",
f"{base_url}/credentials/{credential_path}/user_connection",
)
@responses.activate
def test_start_user_connection_raises_unauthorized_error(
client: CredentialsManagementClient,
base_url: str,
) -> None:
responses.add(
responses.POST,
f"{base_url}/credentials/copilot/user_connection/start",
json={"detail": "An authenticated user is required"},
status=401,
)
with pytest.raises(UnauthorizedError):
client.start_user_connection("copilot")
def test_user_connection_start_repr_omits_flow_handle() -> None:
start: Final = UserConnectionStart(
user_code="ABCD-1234",
verification_uri="https://github.com/login/device",
expires_in=900,
interval=5,
flow_handle="secret-handle",
)
assert "secret-handle" not in repr(start)

View file

@ -21,6 +21,8 @@ _HELP_ARGV: Final = (
("codex", "--help"),
("pi", "--help"),
("keys", "list", "--help"),
("credentials", "connect", "--help"),
("credentials", "connections", "--help"),
)
_COMMAND_ARGV: Final = (("logout",), ("whoami",))