mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-02 02:11:58 +00:00
* test(proxy): move auth, hooks, policy_engine and client tests into tests/unit/proxy Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(proxy): stub HIBP through respx by disabling the aiohttp transport Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(proxy): share the httpx transport fixture across proxy unit tests Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(proxy): restore proxy globals without a missing-value sentinel Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(proxy): package moved dirs and stub the login breach check at the HTTP boundary Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(proxy): isolate the mcp server manager per test Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: yuneng <yuneng@berri.ai> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2046 lines
90 KiB
Python
2046 lines
90 KiB
Python
import json
|
|
import os
|
|
import stat
|
|
import time
|
|
from pathlib import Path
|
|
from unittest.mock import Mock, patch
|
|
|
|
|
|
import pytest
|
|
from click.testing import CliRunner
|
|
|
|
from litellm.constants import CLI_JWT_EXPIRATION_HOURS
|
|
from litellm.litellm_core_utils.cli_keyring import (
|
|
DISABLE_KEYRING_ENV_VAR,
|
|
KeyringDisabled,
|
|
KeyringNotInstalled,
|
|
SecretErased,
|
|
SecretStored,
|
|
)
|
|
from litellm.litellm_core_utils.cli_token_utils import (
|
|
CliTokenRecord,
|
|
CredentialNotRecorded,
|
|
CredentialNotSaved,
|
|
save_cli_token,
|
|
)
|
|
from litellm.proxy.client.cli import cli
|
|
from litellm.proxy.client.cli.commands import claude_settings as claude_settings_module
|
|
from litellm.proxy.client.cli.commands.claude_settings import SettingsFileOwner
|
|
from litellm.proxy.client.cli.commands.auth import (
|
|
get_stored_api_key,
|
|
login,
|
|
logout,
|
|
print_token,
|
|
whoami,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def isolated_home(monkeypatch, tmp_path):
|
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
|
monkeypatch.delenv("LITELLM_PROXY_URL", raising=False)
|
|
monkeypatch.delenv("LITELLM_PROXY_API_KEY", raising=False)
|
|
return tmp_path
|
|
|
|
|
|
def _write_home_json(home: Path, filename: str, payload: dict[str, object]) -> None:
|
|
litellm_dir = home / ".litellm"
|
|
litellm_dir.mkdir(exist_ok=True)
|
|
(litellm_dir / filename).write_text(json.dumps(payload))
|
|
|
|
|
|
def _write_token_file(home: Path, *, key: str | None) -> None:
|
|
"""A stored login: `key=None` is the metadata half of a keychain-backed pair, a key is a file-backed one."""
|
|
payload: dict[str, object] = {"base_url": "https://test.example.com", "user_id": "u-1", "timestamp": time.time()}
|
|
_write_home_json(home, "token.json", payload if key is None else {**payload, "key": key})
|
|
|
|
|
|
def _secret_blob(base_url: str, key: str) -> str:
|
|
return json.dumps({"base_url": base_url, "key": key, "jwt_token": ""})
|
|
|
|
|
|
def _mock_cli_sso_start_response(
|
|
login_id: str = "cli-session-uuid-456",
|
|
poll_secret: str = "poll-secret",
|
|
user_code: str = "ABCD-EFGH",
|
|
**extra_fields: object,
|
|
) -> Mock:
|
|
mock_response = Mock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = {
|
|
"login_id": login_id,
|
|
"poll_secret": poll_secret,
|
|
"user_code": user_code,
|
|
**extra_fields,
|
|
}
|
|
mock_response.raise_for_status = Mock()
|
|
return mock_response
|
|
|
|
|
|
class TestPollingErrorSurfacing:
|
|
def test_client_error_raises_with_server_detail_and_stops_polling(self):
|
|
from litellm.proxy.client.cli.commands.auth import _poll_for_ready_data
|
|
|
|
mock_response = Mock()
|
|
mock_response.status_code = 400
|
|
mock_response.json.return_value = {
|
|
"detail": "Your litellm CLI is out of date and uses a login flow this proxy no longer supports."
|
|
}
|
|
|
|
with patch("requests.get", return_value=mock_response) as mock_get, patch("time.sleep"):
|
|
with pytest.raises(ValueError, match="Your litellm CLI is out of date and uses a login flow") as exc_info:
|
|
_poll_for_ready_data("http://test/sso/cli/poll/sk-legacy")
|
|
|
|
assert mock_get.call_count == 1
|
|
assert (
|
|
"The proxy rejected the login session with HTTP 400: Your litellm CLI is out of date "
|
|
"and uses a login flow this proxy no longer supports." in str(exc_info.value)
|
|
)
|
|
|
|
def test_login_command_shows_server_rejection_to_user(self):
|
|
mock_context = Mock()
|
|
mock_context.obj = {"base_url": "https://test.example.com"}
|
|
|
|
mock_poll_response = Mock()
|
|
mock_poll_response.status_code = 400
|
|
mock_poll_response.json.return_value = {"detail": "CLI login session not found or expired."}
|
|
|
|
with (
|
|
patch("webbrowser.open"),
|
|
patch("requests.post", return_value=_mock_cli_sso_start_response()),
|
|
patch("requests.get", return_value=mock_poll_response),
|
|
patch("time.sleep"),
|
|
):
|
|
result = CliRunner().invoke(login, obj=mock_context.obj)
|
|
|
|
assert result.exit_code == 0
|
|
assert "Authentication failed:" in result.output
|
|
assert "CLI login session not found or expired." in result.output
|
|
assert "Authentication timed out" not in result.output
|
|
|
|
def test_server_error_without_json_body_retries_until_timeout(self, capsys):
|
|
from litellm.proxy.client.cli.commands.auth import _poll_for_ready_data
|
|
|
|
mock_response = Mock()
|
|
mock_response.status_code = 500
|
|
mock_response.json.side_effect = ValueError("no json")
|
|
|
|
with patch("requests.get", return_value=mock_response) as mock_get, patch("time.sleep"):
|
|
result = _poll_for_ready_data("http://test/sso/cli/poll/cli-abc", total_timeout=6, poll_interval=2)
|
|
|
|
assert result is None
|
|
assert mock_get.call_count == 3
|
|
assert "Polling error: HTTP 500" in capsys.readouterr().out
|
|
|
|
def test_rate_limit_is_retried_not_aborted(self, capsys):
|
|
from litellm.proxy.client.cli.commands.auth import _poll_for_ready_data
|
|
|
|
mock_response = Mock()
|
|
mock_response.status_code = 429
|
|
mock_response.json.return_value = {"detail": "Too many CLI login attempts. Try again later."}
|
|
|
|
with patch("requests.get", return_value=mock_response) as mock_get, patch("time.sleep"):
|
|
result = _poll_for_ready_data("http://test/sso/cli/poll/cli-abc", total_timeout=4, poll_interval=2)
|
|
|
|
assert result is None
|
|
assert mock_get.call_count == 2
|
|
assert "Polling error: HTTP 429: Too many CLI login attempts. Try again later." in capsys.readouterr().out
|
|
|
|
|
|
class TestStartCliSsoFlowErrors:
|
|
def test_endpoint_not_found_explains_version_or_base_url(self):
|
|
from litellm.proxy.client.cli.commands.auth import _start_cli_sso_flow
|
|
|
|
mock_response = Mock()
|
|
mock_response.status_code = 404
|
|
|
|
with patch("requests.post", return_value=mock_response):
|
|
with pytest.raises(ValueError, match="Either --base-url is wrong, or the proxy is older than") as exc_info:
|
|
_start_cli_sso_flow("https://old-proxy.example.com")
|
|
|
|
message = str(exc_info.value)
|
|
assert "HTTP 404" in message
|
|
assert "--base-url" in message
|
|
assert "older than this CLI" in message
|
|
|
|
def test_http_error_includes_server_detail(self):
|
|
from litellm.proxy.client.cli.commands.auth import _start_cli_sso_flow
|
|
|
|
mock_response = Mock()
|
|
mock_response.status_code = 429
|
|
mock_response.json.return_value = {"detail": "Too many CLI login attempts. Try again later."}
|
|
|
|
with patch("requests.post", return_value=mock_response):
|
|
with pytest.raises(ValueError, match="Too many CLI login attempts\\. Try again later\\.") as exc_info:
|
|
_start_cli_sso_flow("https://test.example.com")
|
|
|
|
assert "HTTP 429" in str(exc_info.value)
|
|
assert "Too many CLI login attempts. Try again later." in str(exc_info.value)
|
|
|
|
def test_non_json_response_names_interception(self):
|
|
from litellm.proxy.client.cli.commands.auth import _start_cli_sso_flow
|
|
|
|
mock_response = Mock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.side_effect = ValueError("no json")
|
|
mock_response.headers = {"content-type": "text/html"}
|
|
mock_response.text = "<html>Sign in to corporate VPN</html>"
|
|
|
|
with patch("requests.post", return_value=mock_response):
|
|
with pytest.raises(ValueError, match="A proxy, load balancer, or auth gateway in front of") as exc_info:
|
|
_start_cli_sso_flow("https://test.example.com")
|
|
|
|
message = str(exc_info.value)
|
|
assert "non-JSON response" in message
|
|
assert "text/html" in message
|
|
assert "Sign in to corporate VPN" in message
|
|
|
|
def test_connection_error_points_at_base_url(self):
|
|
import requests
|
|
|
|
from litellm.proxy.client.cli.commands.auth import _start_cli_sso_flow
|
|
|
|
with patch("requests.post", side_effect=requests.ConnectionError("Connection refused")):
|
|
with pytest.raises(ValueError, match="Connection refused\\. Check that the proxy is running") as exc_info:
|
|
_start_cli_sso_flow("https://unreachable.example.com")
|
|
|
|
message = str(exc_info.value)
|
|
assert "Could not reach the proxy" in message
|
|
assert "https://unreachable.example.com/sso/cli/start" in message
|
|
|
|
|
|
class TestStoredApiKeyLookup:
|
|
"""`get_stored_api_key` is what every other `lite` subcommand authenticates with, so the
|
|
keychain split and the origin check both have to be invisible to it."""
|
|
|
|
def test_returns_the_secret_the_keychain_holds(self, isolated_home, secret_vault_factory):
|
|
_write_home_json(isolated_home, "token.json", {"base_url": "https://real-proxy.com", "user_id": "u-1"})
|
|
vault = secret_vault_factory(blob=_secret_blob("https://real-proxy.com", "sk-from-keychain"))
|
|
|
|
assert get_stored_api_key(vault=vault) == "sk-from-keychain"
|
|
|
|
def test_returns_a_legacy_plaintext_key(self, isolated_home, secret_vault_factory):
|
|
_write_home_json(isolated_home, "token.json", {"base_url": "https://real-proxy.com", "key": "sk-legacy"})
|
|
|
|
assert get_stored_api_key(vault=secret_vault_factory()) == "sk-legacy"
|
|
|
|
def test_no_token_at_all_returns_nothing(self, isolated_home, secret_vault_factory):
|
|
assert get_stored_api_key(vault=secret_vault_factory()) is None
|
|
|
|
def test_metadata_without_a_secret_returns_nothing(self, isolated_home, secret_vault_factory):
|
|
_write_home_json(isolated_home, "token.json", {"base_url": "https://real-proxy.com", "user_id": "u-1"})
|
|
|
|
assert get_stored_api_key(vault=secret_vault_factory()) is None
|
|
|
|
def test_matching_base_url_returns_the_key(self, isolated_home, secret_vault_factory):
|
|
_write_home_json(isolated_home, "token.json", {"base_url": "https://real-proxy.com", "key": "sk-prod"})
|
|
|
|
assert get_stored_api_key("https://real-proxy.com", vault=secret_vault_factory()) == "sk-prod"
|
|
|
|
def test_trailing_slash_on_the_expected_url_is_normalised(self, isolated_home, secret_vault_factory):
|
|
_write_home_json(isolated_home, "token.json", {"base_url": "https://real-proxy.com", "key": "sk-prod"})
|
|
|
|
assert get_stored_api_key("https://real-proxy.com/", vault=secret_vault_factory()) == "sk-prod"
|
|
|
|
def test_mismatched_base_url_withholds_the_key(self, isolated_home, secret_vault_factory):
|
|
_write_home_json(isolated_home, "token.json", {"base_url": "https://real-proxy.com", "key": "sk-prod"})
|
|
|
|
assert get_stored_api_key("https://evil.com", vault=secret_vault_factory()) is None
|
|
|
|
def test_old_tokens_without_a_base_url_are_rejected_when_an_origin_is_expected(
|
|
self, isolated_home, secret_vault_factory
|
|
):
|
|
_write_home_json(isolated_home, "token.json", {"key": "sk-old-token"})
|
|
|
|
assert get_stored_api_key("https://real-proxy.com", vault=secret_vault_factory()) is None
|
|
|
|
|
|
class TestLoginCommand:
|
|
"""Test login CLI command"""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def isolated_home(self, monkeypatch, tmp_path):
|
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
|
return tmp_path
|
|
|
|
def setup_method(self):
|
|
"""Setup for each test"""
|
|
self.runner = CliRunner()
|
|
|
|
def test_login_replaces_a_pkce_record_and_revokes_its_refresh_token(self):
|
|
mock_response = Mock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = {
|
|
"status": "ready",
|
|
"key": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test.jwt",
|
|
"user_id": "test-user-123",
|
|
"team_id": "team-1",
|
|
"teams": ["team-1"],
|
|
}
|
|
_FakeSession.instances.clear()
|
|
|
|
with (
|
|
patch("webbrowser.open"),
|
|
patch("requests.post", return_value=_mock_cli_sso_start_response()),
|
|
patch("requests.get", return_value=mock_response),
|
|
patch("litellm.proxy.client.cli.commands.auth.requests.Session", _FakeSession),
|
|
patch("litellm.proxy.client.cli.commands.auth.load_token", return_value=_pkce_record()),
|
|
patch("litellm.proxy.client.cli.commands.auth.save_token", return_value=SecretStored()) as mock_save,
|
|
patch("litellm.proxy.client.cli.interface.show_commands"),
|
|
):
|
|
result = self.runner.invoke(login, obj={"base_url": "https://test.example.com"})
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert "Login successful!" in result.output
|
|
assert "Could not revoke" not in result.output
|
|
assert mock_save.call_args.args[0]["key"] == "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test.jwt"
|
|
assert _FakeSession.instances[0].posts == [
|
|
(
|
|
f"{PKCE_BASE_URL}/revoke",
|
|
{"token": "llm_srefresh_old", "token_type_hint": "refresh_token", "client_id": "llm_dcrc_abc"},
|
|
)
|
|
]
|
|
|
|
def test_login_success(self):
|
|
"""Test successful login flow with single team (JWT generated immediately)"""
|
|
mock_context = Mock()
|
|
mock_context.obj = {"base_url": "https://test.example.com"}
|
|
|
|
# Mock the requests for successful authentication with single team
|
|
mock_response = Mock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = {
|
|
"status": "ready",
|
|
"key": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test.jwt",
|
|
"user_id": "test-user-123",
|
|
"team_id": "team-1",
|
|
"teams": ["team-1"],
|
|
}
|
|
|
|
with (
|
|
patch("webbrowser.open") as mock_browser,
|
|
patch(
|
|
"requests.post",
|
|
return_value=_mock_cli_sso_start_response(login_id="cli-test-uuid-123"),
|
|
) as mock_post,
|
|
patch("requests.get", return_value=mock_response) as mock_get,
|
|
patch("litellm.proxy.client.cli.commands.auth.save_cli_token") as mock_save,
|
|
patch("litellm.proxy.client.cli.interface.show_commands") as mock_show_commands,
|
|
):
|
|
result = self.runner.invoke(login, obj=mock_context.obj)
|
|
|
|
assert result.exit_code == 0
|
|
assert "Login successful!" in result.output
|
|
assert "Automatically assigned to team: team-1" in result.output
|
|
|
|
# Verify browser was opened with correct URL
|
|
mock_browser.assert_called_once()
|
|
call_args = mock_browser.call_args[0][0]
|
|
assert "https://test.example.com/sso/key/generate" in call_args
|
|
assert "cli-test-uuid-123" in call_args
|
|
assert "user_code" not in call_args
|
|
assert "Verification code: ABCD-EFGH" in result.output
|
|
assert "pre-filled in the browser" not in result.output
|
|
mock_post.assert_called_once()
|
|
mock_get.assert_called()
|
|
assert mock_get.call_args.kwargs["headers"] == {"x-litellm-cli-poll-secret": "poll-secret"}
|
|
|
|
# Verify JWT was saved
|
|
mock_save.assert_called_once()
|
|
saved_data = mock_save.call_args[0][0]
|
|
assert saved_data.key == "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test.jwt"
|
|
assert saved_data.user_id == "test-user-123"
|
|
|
|
# Verify commands were shown
|
|
mock_show_commands.assert_called_once()
|
|
|
|
def test_login_prefills_the_code_in_the_browser_when_the_proxy_advertises_it(
|
|
self, isolated_home, secret_vault_factory
|
|
) -> None:
|
|
vault = secret_vault_factory()
|
|
poll_response = Mock()
|
|
poll_response.status_code = 200
|
|
poll_response.json.return_value = {
|
|
"status": "ready",
|
|
"key": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test.jwt",
|
|
"user_id": "test-user-123",
|
|
"team_id": "team-1",
|
|
"teams": ["team-1"],
|
|
}
|
|
start_response = _mock_cli_sso_start_response(
|
|
login_id="cli-test-uuid-123",
|
|
verification_uri_complete=(
|
|
"https://internal-hostname.example.com/sso/key/generate"
|
|
"?source=litellm-cli&key=cli-test-uuid-123&user_code=ABCD-EFGH"
|
|
),
|
|
)
|
|
|
|
with (
|
|
patch("webbrowser.open") as mock_browser,
|
|
patch("requests.post", return_value=start_response),
|
|
patch("requests.get", return_value=poll_response),
|
|
):
|
|
result = self.runner.invoke(login, obj={"base_url": "https://test.example.com", "secret_vault": vault})
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert json.loads(vault.blob)["key"] == "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test.jwt"
|
|
assert json.loads((isolated_home / ".litellm" / "token.json").read_text())["user_id"] == "test-user-123"
|
|
opened_url = mock_browser.call_args[0][0]
|
|
assert opened_url.startswith("https://test.example.com/sso/key/generate?")
|
|
assert "internal-hostname" not in opened_url
|
|
assert "key=cli-test-uuid-123" in opened_url
|
|
assert "user_code=ABCD-EFGH" in opened_url
|
|
assert "Verification code: ABCD-EFGH (pre-filled in the browser, check it matches)" in result.output
|
|
|
|
def test_login_keeps_the_code_out_of_the_url_when_the_proxy_sends_a_non_url_verification_uri(
|
|
self, secret_vault_factory
|
|
) -> None:
|
|
poll_response = Mock()
|
|
poll_response.status_code = 200
|
|
poll_response.json.return_value = {
|
|
"status": "ready",
|
|
"key": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test.jwt",
|
|
"user_id": "test-user-123",
|
|
"team_id": "team-1",
|
|
"teams": ["team-1"],
|
|
}
|
|
for advertised in (None, True):
|
|
start_response = _mock_cli_sso_start_response(verification_uri_complete=advertised)
|
|
|
|
with (
|
|
patch("webbrowser.open") as mock_browser,
|
|
patch("requests.post", return_value=start_response),
|
|
patch("requests.get", return_value=poll_response),
|
|
):
|
|
result = self.runner.invoke(
|
|
login, obj={"base_url": "https://test.example.com", "secret_vault": secret_vault_factory()}
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert "user_code" not in mock_browser.call_args[0][0]
|
|
assert "pre-filled in the browser" not in result.output
|
|
|
|
def test_login_timeout(self):
|
|
"""Test login timeout scenario"""
|
|
mock_context = Mock()
|
|
mock_context.obj = {"base_url": "https://test.example.com"}
|
|
|
|
# Mock response that never returns ready status
|
|
mock_response = Mock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = {"status": "pending"}
|
|
|
|
with (
|
|
patch("webbrowser.open"),
|
|
patch("requests.post", return_value=_mock_cli_sso_start_response()),
|
|
patch("requests.get", return_value=mock_response),
|
|
patch("time.sleep"),
|
|
):
|
|
# Mock time.sleep to avoid actual delays in tests
|
|
result = self.runner.invoke(login, obj=mock_context.obj)
|
|
|
|
assert result.exit_code == 0
|
|
assert "Authentication timed out" in result.output
|
|
|
|
def test_login_http_error(self):
|
|
"""Test login with HTTP error"""
|
|
mock_context = Mock()
|
|
mock_context.obj = {"base_url": "https://test.example.com"}
|
|
|
|
# Mock response with HTTP error
|
|
mock_response = Mock()
|
|
mock_response.status_code = 500
|
|
|
|
with (
|
|
patch("webbrowser.open"),
|
|
patch("requests.post", return_value=_mock_cli_sso_start_response()),
|
|
patch("requests.get", return_value=mock_response),
|
|
patch("time.sleep"),
|
|
):
|
|
result = self.runner.invoke(login, obj=mock_context.obj)
|
|
|
|
assert result.exit_code == 0
|
|
assert "Authentication timed out" in result.output
|
|
|
|
def test_login_request_exception(self):
|
|
"""Test login with request exception"""
|
|
import requests
|
|
|
|
mock_context = Mock()
|
|
mock_context.obj = {"base_url": "https://test.example.com"}
|
|
|
|
with (
|
|
patch("webbrowser.open"),
|
|
patch("requests.post", return_value=_mock_cli_sso_start_response()),
|
|
patch(
|
|
"requests.get",
|
|
side_effect=requests.RequestException("Connection failed"),
|
|
),
|
|
patch("time.sleep"),
|
|
):
|
|
result = self.runner.invoke(login, obj=mock_context.obj)
|
|
|
|
assert result.exit_code == 0
|
|
assert "Authentication timed out" in result.output
|
|
|
|
def test_login_keyboard_interrupt(self):
|
|
"""Test login cancelled by user"""
|
|
mock_context = Mock()
|
|
mock_context.obj = {"base_url": "https://test.example.com"}
|
|
|
|
with (
|
|
patch("webbrowser.open"),
|
|
patch("requests.post", return_value=_mock_cli_sso_start_response()),
|
|
patch("requests.get", side_effect=KeyboardInterrupt),
|
|
):
|
|
result = self.runner.invoke(login, obj=mock_context.obj)
|
|
|
|
assert result.exit_code == 0
|
|
assert "Authentication cancelled by user" in result.output
|
|
|
|
def test_login_no_api_key_in_response(self):
|
|
"""Test login when response doesn't contain API key"""
|
|
mock_context = Mock()
|
|
mock_context.obj = {"base_url": "https://test.example.com"}
|
|
|
|
# Mock response without API key
|
|
mock_response = Mock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = {
|
|
"status": "ready"
|
|
# Missing 'key' field
|
|
}
|
|
|
|
with (
|
|
patch("webbrowser.open"),
|
|
patch("requests.post", return_value=_mock_cli_sso_start_response()),
|
|
patch("requests.get", return_value=mock_response),
|
|
patch("time.sleep"),
|
|
):
|
|
result = self.runner.invoke(login, obj=mock_context.obj)
|
|
|
|
assert result.exit_code == 0
|
|
assert "Authentication timed out" in result.output
|
|
|
|
def test_login_general_exception(self):
|
|
"""Test login with general exception (not requests exception)"""
|
|
mock_context = Mock()
|
|
mock_context.obj = {"base_url": "https://test.example.com"}
|
|
|
|
with (
|
|
patch("webbrowser.open"),
|
|
patch("requests.post", return_value=_mock_cli_sso_start_response()),
|
|
patch("requests.get", side_effect=ValueError("Invalid value")),
|
|
):
|
|
result = self.runner.invoke(login, obj=mock_context.obj)
|
|
|
|
assert result.exit_code == 0
|
|
assert "Authentication failed: Invalid value" in result.output
|
|
|
|
|
|
class TestLogoutCommand:
|
|
"""Test logout CLI command"""
|
|
|
|
def setup_method(self):
|
|
"""Setup for each test"""
|
|
self.runner = CliRunner()
|
|
|
|
def test_logout_success(self, isolated_home, secret_vault_factory):
|
|
"""Test successful logout"""
|
|
vault = secret_vault_factory(blob=_secret_blob("https://test.example.com", "sk-stored"))
|
|
_write_token_file(isolated_home, key=None)
|
|
|
|
result = self.runner.invoke(logout, obj={"secret_vault": vault})
|
|
|
|
assert result.exit_code == 0
|
|
assert "Logged out successfully" in result.output
|
|
assert vault.blob is None
|
|
assert not (isolated_home / ".litellm" / "token.json").exists()
|
|
|
|
def test_logout_without_the_keyring_package_does_not_claim_the_keychain_is_clear(
|
|
self, isolated_home, secret_vault_factory
|
|
):
|
|
"""Logging out from an install without the cli extra cannot touch an entry a keychain-backed
|
|
login left behind, so it must point at the package rather than report a clean logout."""
|
|
_write_token_file(isolated_home, key=None)
|
|
|
|
result = self.runner.invoke(
|
|
logout, obj={"secret_vault": secret_vault_factory(available=False, failure=KeyringNotInstalled())}
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
assert "Logged out successfully" not in result.output
|
|
assert "could not be checked" in result.output
|
|
assert "pip install 'litellm[cli]'" in result.output
|
|
|
|
def test_logout_does_not_call_an_unusable_keychain_clean(self, isolated_home, secret_vault_factory):
|
|
"""A keychain-backed login, then a login that fell back to the file because the keychain had
|
|
become unusable, leaves the first entry live. The file's own secret says nothing about it,
|
|
so a clean bill of health here is the one answer that cannot be justified."""
|
|
_write_token_file(isolated_home, key="sk-in-file")
|
|
vault = secret_vault_factory(available=False, failure=KeyringDisabled())
|
|
|
|
result = self.runner.invoke(logout, obj={"secret_vault": vault})
|
|
|
|
assert result.exit_code == 0
|
|
assert "Logged out successfully" not in result.output
|
|
assert "could not be checked" in result.output
|
|
assert DISABLE_KEYRING_ENV_VAR in result.output
|
|
|
|
def test_logout_warns_when_the_keychain_refuses_to_release_the_entry(self, isolated_home, secret_vault_factory):
|
|
"""A locked keychain leaves a live credential behind that the user believes is gone."""
|
|
vault = secret_vault_factory(blob=_secret_blob("https://test.example.com", "sk-stored"), erasable=False)
|
|
_write_token_file(isolated_home, key=None)
|
|
|
|
result = self.runner.invoke(logout, obj={"secret_vault": vault})
|
|
|
|
assert result.exit_code == 0
|
|
assert "Logged out successfully" not in result.output
|
|
assert "still in the OS keychain" in result.output
|
|
assert "Unlock your keychain" in result.output
|
|
|
|
@pytest.mark.skipif(os.geteuid() == 0, reason="root ignores file permissions")
|
|
def test_logout_reports_a_token_file_it_cannot_clear(self, isolated_home, secret_vault_factory):
|
|
"""`lite logout` on a read-only ~/.litellm holding a read-only token file used to end in a
|
|
PermissionError traceback with the credential still sitting in the file. The user has to be
|
|
told what is left and where."""
|
|
_write_token_file(isolated_home, key="sk-in-file")
|
|
config_dir = isolated_home / ".litellm"
|
|
path = config_dir / "token.json"
|
|
path.chmod(0o400)
|
|
config_dir.chmod(0o500)
|
|
try:
|
|
result = self.runner.invoke(logout, obj={"secret_vault": secret_vault_factory()})
|
|
finally:
|
|
config_dir.chmod(0o700)
|
|
path.chmod(0o600)
|
|
|
|
assert result.exit_code == 0
|
|
assert "Logged out successfully" not in result.output
|
|
assert "still in" in result.output
|
|
assert str(config_dir / "token.json") in result.output
|
|
|
|
@pytest.mark.skipif(os.geteuid() == 0, reason="root ignores directory permissions")
|
|
def test_logout_on_a_read_only_directory_still_takes_the_secret_out_of_the_file(
|
|
self, isolated_home, secret_vault_factory
|
|
):
|
|
"""A ~/.litellm that will accept no replacement file and no removal still lets the file it
|
|
has be shortened, so the logout the user asked for happens rather than being handed back to
|
|
them with instructions."""
|
|
_write_token_file(isolated_home, key="sk-in-file")
|
|
config_dir = isolated_home / ".litellm"
|
|
path = config_dir / "token.json"
|
|
config_dir.chmod(0o500)
|
|
try:
|
|
result = self.runner.invoke(logout, obj={"secret_vault": secret_vault_factory()})
|
|
finally:
|
|
config_dir.chmod(0o700)
|
|
|
|
assert result.exit_code == 0
|
|
assert "Logged out successfully" in result.output
|
|
assert "sk-in-file" not in path.read_text()
|
|
|
|
def test_logout_without_the_keyring_package_still_warns_about_a_file_held_secret(
|
|
self, isolated_home, secret_vault_factory
|
|
):
|
|
"""A file holding its own secret only says the login that wrote it had no keychain to write
|
|
to. An earlier login on this machine may have had one, and no install without the package
|
|
can look, so the honest answer is that the keychain went unchecked."""
|
|
_write_token_file(isolated_home, key="sk-in-file")
|
|
|
|
result = self.runner.invoke(
|
|
logout, obj={"secret_vault": secret_vault_factory(available=False, failure=KeyringNotInstalled())}
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
assert "Logged out successfully" not in result.output
|
|
assert "could not be checked" in result.output
|
|
assert "pip install 'litellm[cli]'" in result.output
|
|
|
|
|
|
class TestWhoamiCommand:
|
|
"""Test whoami CLI command"""
|
|
|
|
def setup_method(self):
|
|
"""Setup for each test"""
|
|
self.runner = CliRunner()
|
|
|
|
def test_whoami_authenticated(self):
|
|
"""Test whoami when user is authenticated"""
|
|
token_data = CliTokenRecord(
|
|
user_email="test@example.com",
|
|
user_id="test-user-123",
|
|
user_role="admin",
|
|
key="sk-live",
|
|
timestamp=time.time() - 3600,
|
|
)
|
|
|
|
with patch("litellm.proxy.client.cli.commands.auth.load_cli_token", return_value=token_data):
|
|
result = self.runner.invoke(whoami)
|
|
|
|
assert result.exit_code == 0
|
|
assert "Authenticated" in result.output
|
|
assert "test@example.com" in result.output
|
|
assert "test-user-123" in result.output
|
|
assert "admin" in result.output
|
|
assert "Token age: 1.0 hours" in result.output
|
|
|
|
def test_whoami_not_authenticated(self):
|
|
"""Test whoami when user is not authenticated"""
|
|
with patch("litellm.proxy.client.cli.commands.auth.load_cli_token", return_value=None):
|
|
result = self.runner.invoke(whoami)
|
|
|
|
assert result.exit_code == 0
|
|
assert "Not authenticated" in result.output
|
|
assert "Run 'lite login'" in result.output
|
|
|
|
def test_whoami_old_token(self):
|
|
"""Test whoami with old token showing warning"""
|
|
token_data = CliTokenRecord(
|
|
user_email="test@example.com",
|
|
user_id="test-user-123",
|
|
user_role="admin",
|
|
key="sk-live",
|
|
timestamp=time.time() - (25 * 3600),
|
|
)
|
|
|
|
with patch("litellm.proxy.client.cli.commands.auth.load_cli_token", return_value=token_data):
|
|
result = self.runner.invoke(whoami)
|
|
|
|
assert result.exit_code == 0
|
|
assert "Authenticated" in result.output
|
|
assert "Warning: Token is more than 24 hours old" in result.output
|
|
|
|
def test_whoami_missing_fields(self):
|
|
"""Test whoami with token missing some fields"""
|
|
token_data = CliTokenRecord(key="sk-live", timestamp=time.time() - 3600)
|
|
|
|
with patch("litellm.proxy.client.cli.commands.auth.load_cli_token", return_value=token_data):
|
|
result = self.runner.invoke(whoami)
|
|
|
|
assert result.exit_code == 0
|
|
assert "Authenticated" in result.output
|
|
assert "Unknown" in result.output # Should show "Unknown" for missing fields
|
|
|
|
def test_whoami_pkce_record_shows_the_team_and_when_the_key_renews(self):
|
|
token_data = {
|
|
"key": "sk-cli",
|
|
"user_email": "unknown",
|
|
"user_id": "user-1",
|
|
"user_role": "cli",
|
|
"team_id": "team-alpha",
|
|
"timestamp": time.time() - 25 * 3600,
|
|
"expires_at": time.time() + 2 * 3600,
|
|
"refresh_token": "llm_srefresh_abc",
|
|
}
|
|
|
|
with patch("litellm.proxy.client.cli.commands.auth.load_token", return_value=token_data):
|
|
result = self.runner.invoke(whoami)
|
|
|
|
assert result.exit_code == 0
|
|
assert "Team ID: team-alpha" in result.output
|
|
assert "Key expires in: 2.0 hours, renewed on next use" in result.output
|
|
assert "Warning" not in result.output
|
|
|
|
def test_whoami_expired_key_without_a_refresh_token_asks_for_a_new_login(self):
|
|
token_data = {
|
|
"key": "sk-cli",
|
|
"user_id": "user-1",
|
|
"timestamp": time.time() - 3600,
|
|
"expires_at": time.time() - 60,
|
|
}
|
|
|
|
with patch("litellm.proxy.client.cli.commands.auth.load_token", return_value=token_data):
|
|
result = self.runner.invoke(whoami)
|
|
|
|
assert result.exit_code == 0
|
|
assert "Team ID" not in result.output
|
|
assert "Key expired. Run 'lite login' again" in result.output
|
|
|
|
def test_whoami_expired_pkce_record_that_could_not_be_renewed_asks_for_a_new_pkce_login(self):
|
|
token_data = {
|
|
"key": "sk-cli",
|
|
"user_id": "user-1",
|
|
"team_id": "team-alpha",
|
|
"timestamp": time.time() - 3600,
|
|
"expires_at": time.time() - 60,
|
|
"refresh_token": "llm_srefresh_spent",
|
|
}
|
|
|
|
with patch("litellm.proxy.client.cli.commands.auth.load_token", return_value=token_data):
|
|
result = self.runner.invoke(whoami)
|
|
|
|
assert result.exit_code == 0
|
|
assert "Key expired. Run 'lite login --pkce' again" in result.output
|
|
assert "renewed on next use" not in result.output
|
|
|
|
def test_whoami_no_timestamp(self):
|
|
"""Test whoami with token missing timestamp"""
|
|
token_data = CliTokenRecord(
|
|
user_email="test@example.com",
|
|
user_id="test-user-123",
|
|
user_role="admin",
|
|
key="sk-live",
|
|
)
|
|
|
|
with (
|
|
patch(
|
|
"litellm.proxy.client.cli.commands.auth.load_cli_token",
|
|
return_value=token_data,
|
|
),
|
|
patch("time.time", return_value=1000),
|
|
):
|
|
result = self.runner.invoke(whoami)
|
|
|
|
assert result.exit_code == 0
|
|
assert "Authenticated" in result.output
|
|
# Should calculate age based on timestamp=0
|
|
assert "Token age:" in result.output
|
|
|
|
|
|
class TestCLIKeyRegenerationFlow:
|
|
"""Test the end-to-end CLI key regeneration flow from CLI perspective"""
|
|
|
|
def setup_method(self):
|
|
"""Setup for each test"""
|
|
self.runner = CliRunner()
|
|
|
|
def test_login_with_team_selection_flow(self):
|
|
"""Test complete login flow when user has multiple teams - should prompt for selection"""
|
|
mock_context = Mock()
|
|
mock_context.obj = {"base_url": "https://test.example.com"}
|
|
|
|
# Mock first response - requires team selection
|
|
mock_first_response = Mock()
|
|
mock_first_response.status_code = 200
|
|
mock_first_response.json.return_value = {
|
|
"status": "ready",
|
|
"requires_team_selection": True,
|
|
"user_id": "test-user-456",
|
|
"teams": ["team-alpha", "team-beta", "team-gamma"],
|
|
# New richer response with team details including aliases
|
|
"team_details": [
|
|
{"team_id": "team-alpha", "team_alias": "Alpha Team"},
|
|
{"team_id": "team-beta", "team_alias": "Beta Team"},
|
|
{"team_id": "team-gamma", "team_alias": "Gamma Team"},
|
|
],
|
|
}
|
|
|
|
# Mock second response after team selection - JWT with selected team
|
|
mock_second_response = Mock()
|
|
mock_second_response.status_code = 200
|
|
mock_second_response.json.return_value = {
|
|
"status": "ready",
|
|
"key": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.team-beta.jwt",
|
|
"user_id": "test-user-456",
|
|
"team_id": "team-beta",
|
|
"teams": ["team-alpha", "team-beta", "team-gamma"],
|
|
}
|
|
|
|
# Simulate user selecting team #2 (team-beta)
|
|
with (
|
|
patch("webbrowser.open") as mock_browser,
|
|
patch(
|
|
"requests.post",
|
|
return_value=_mock_cli_sso_start_response(login_id="cli-session-uuid-456"),
|
|
),
|
|
patch("requests.get", side_effect=[mock_first_response, mock_second_response]) as mock_get,
|
|
patch("litellm.proxy.client.cli.commands.auth.save_cli_token") as mock_save,
|
|
patch("litellm.proxy.client.cli.interface.show_commands") as mock_show_commands,
|
|
patch("click.prompt", return_value="2"),
|
|
): # User selects index 2
|
|
result = self.runner.invoke(login, obj=mock_context.obj)
|
|
|
|
assert result.exit_code == 0
|
|
assert "Login successful!" in result.output
|
|
assert "team-beta" in result.output
|
|
# Ensure we surface the human-readable team alias to the user
|
|
assert "Beta Team" in result.output
|
|
|
|
# Verify browser was opened
|
|
mock_browser.assert_called_once()
|
|
call_args = mock_browser.call_args[0][0]
|
|
assert "https://test.example.com/sso/key/generate" in call_args
|
|
|
|
# Verify two polling requests were made
|
|
assert mock_get.call_count == 2
|
|
|
|
# First poll should be without team_id
|
|
first_poll_url = mock_get.call_args_list[0][0][0]
|
|
assert "cli-session-uuid-456" in first_poll_url
|
|
assert "team_id=" not in first_poll_url
|
|
assert mock_get.call_args_list[0].kwargs["headers"] == {"x-litellm-cli-poll-secret": "poll-secret"}
|
|
|
|
# Second poll should include team_id=team-beta
|
|
second_poll_url = mock_get.call_args_list[1][0][0]
|
|
assert "team_id=team-beta" in second_poll_url
|
|
|
|
# Verify JWT was saved
|
|
mock_save.assert_called_once()
|
|
saved_data = mock_save.call_args[0][0]
|
|
assert saved_data.key == "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.team-beta.jwt"
|
|
assert saved_data.user_id == "test-user-456"
|
|
|
|
mock_show_commands.assert_called_once()
|
|
|
|
def test_login_without_teams_flow(self):
|
|
"""Test complete login flow when user has no teams - JWT generated without team"""
|
|
mock_context = Mock()
|
|
mock_context.obj = {"base_url": "https://test.example.com"}
|
|
|
|
# Mock response with no teams
|
|
mock_response = Mock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = {
|
|
"status": "ready",
|
|
"key": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.no-team.jwt",
|
|
"user_id": "test-user-solo",
|
|
"team_id": None,
|
|
"teams": [],
|
|
}
|
|
|
|
with (
|
|
patch("webbrowser.open") as mock_browser,
|
|
patch(
|
|
"requests.post",
|
|
return_value=_mock_cli_sso_start_response(login_id="cli-session-uuid-solo"),
|
|
),
|
|
patch("requests.get", return_value=mock_response),
|
|
patch("litellm.proxy.client.cli.commands.auth.save_cli_token") as mock_save,
|
|
patch("litellm.proxy.client.cli.interface.show_commands"),
|
|
):
|
|
result = self.runner.invoke(login, obj=mock_context.obj)
|
|
|
|
assert result.exit_code == 0
|
|
assert "Login successful!" in result.output
|
|
|
|
# Verify browser was opened
|
|
mock_browser.assert_called_once()
|
|
call_args = mock_browser.call_args[0][0]
|
|
assert "https://test.example.com/sso/key/generate" in call_args
|
|
assert "source=litellm-cli" in call_args
|
|
assert "key=cli-session-uuid-solo" in call_args
|
|
|
|
# Verify JWT was saved
|
|
mock_save.assert_called_once()
|
|
saved_data = mock_save.call_args[0][0]
|
|
assert saved_data.key == "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.no-team.jwt"
|
|
assert saved_data.user_id == "test-user-solo"
|
|
|
|
|
|
class TestPrintTokenCommand:
|
|
"""Test `lite auth print-token`, used as Claude Code's apiKeyHelper.
|
|
|
|
stdout must contain *only* the token -- Claude Code treats stdout
|
|
verbatim as the bearer token, so any diagnostic text on stdout would
|
|
corrupt authentication.
|
|
|
|
`lite up` now writes `apiKeyHelper` with an explicit `--base-url` bound
|
|
to whatever proxy it was pointed at (resolve_api_key_helper), so
|
|
print-token enforces that the cached token was actually issued for that
|
|
server -- a token minted for a different, previously-logged-into proxy
|
|
must never be handed to whichever server the helper is invoked for.
|
|
Settings patched by an older `lite up`, or a manually-configured
|
|
apiKeyHelper, can still invoke this bare (no --base-url at all); that
|
|
case falls back to trusting whatever `lite login` stored in token.json,
|
|
since there is no explicit target to check it against. `--base-url`/
|
|
`LITELLM_PROXY_URL` only enforces the match when a caller explicitly
|
|
passes it (tracked via ctx.obj["base_url_explicit"], set by the `cli`
|
|
group from click's ParameterSource); a base_url saved via
|
|
`lite config set` counts as explicit too.
|
|
"""
|
|
|
|
def setup_method(self):
|
|
self.runner = CliRunner()
|
|
|
|
def test_no_stored_token_fails_cleanly(self):
|
|
with patch("litellm.proxy.client.cli.commands.auth.load_cli_token", return_value=None):
|
|
result = self.runner.invoke(print_token, obj={})
|
|
|
|
assert result.exit_code != 0
|
|
assert "Not authenticated" in result.output
|
|
|
|
def test_bare_invocation_resolves_server_from_stored_token(self):
|
|
"""The legacy/manual invocation shape: no --base-url given at all
|
|
(e.g. settings patched before resolve_api_key_helper started binding
|
|
one). Must use token.json's own base_url, not a hardcoded default."""
|
|
with (
|
|
patch(
|
|
"litellm.proxy.client.cli.commands.auth.load_cli_token",
|
|
return_value=CliTokenRecord(
|
|
base_url="https://litellm-proxy.corp.com",
|
|
key="sk-prod-fresh",
|
|
timestamp=time.time(),
|
|
),
|
|
),
|
|
patch("requests.post") as mock_post,
|
|
):
|
|
result = self.runner.invoke(print_token, obj={})
|
|
|
|
assert result.exit_code == 0
|
|
assert result.output.strip() == "sk-prod-fresh"
|
|
mock_post.assert_not_called()
|
|
|
|
def test_explicit_base_url_mismatch_fails_cleanly(self):
|
|
"""When the caller *does* explicitly pass --base-url, a token issued
|
|
for a different server must never be printed. This is the exact
|
|
scenario `lite up`'s own bound --base-url now guards against: a
|
|
token minted for proxy A must not reach a helper invocation aimed
|
|
at proxy B, even though the token itself is otherwise fresh."""
|
|
with patch(
|
|
"litellm.proxy.client.cli.commands.auth.load_cli_token",
|
|
return_value=CliTokenRecord(
|
|
base_url="https://other-server.com",
|
|
key="sk-should-not-print",
|
|
timestamp=time.time(),
|
|
),
|
|
):
|
|
result = self.runner.invoke(
|
|
print_token,
|
|
obj={"base_url": "http://localhost:4000", "base_url_explicit": True},
|
|
)
|
|
|
|
assert result.exit_code != 0
|
|
assert "sk-should-not-print" not in result.output
|
|
|
|
def test_explicit_base_url_match_prints_token(self):
|
|
"""`lite up`'s own bound invocation shape: --base-url matching the token's origin
|
|
must succeed exactly like the bare/legacy invocation does."""
|
|
with patch(
|
|
"litellm.proxy.client.cli.commands.auth.load_cli_token",
|
|
return_value=CliTokenRecord(
|
|
base_url="http://localhost:4000",
|
|
key="sk-matches",
|
|
timestamp=time.time(),
|
|
),
|
|
):
|
|
result = self.runner.invoke(
|
|
print_token,
|
|
obj={"base_url": "http://localhost:4000", "base_url_explicit": True},
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
assert result.output.strip() == "sk-matches"
|
|
|
|
def test_fresh_cached_key_printed_without_network_call(self):
|
|
"""A recently-issued key should be printed straight from cache -- no
|
|
refresh call on every single invocation (apiKeyHelper gets called
|
|
frequently)."""
|
|
with (
|
|
patch(
|
|
"litellm.proxy.client.cli.commands.auth.load_cli_token",
|
|
return_value=CliTokenRecord(
|
|
base_url="http://localhost:4000",
|
|
key="sk-cached-fresh",
|
|
timestamp=time.time(),
|
|
),
|
|
),
|
|
patch("requests.post") as mock_post,
|
|
):
|
|
result = self.runner.invoke(print_token, obj={})
|
|
|
|
assert result.exit_code == 0
|
|
assert result.output.strip() == "sk-cached-fresh"
|
|
mock_post.assert_not_called()
|
|
|
|
def test_stale_key_fails_fast_without_network_call(self):
|
|
"""There is no silent refresh: an expired cached key must fail
|
|
loudly (stderr, nonzero exit) telling the user to `lite login`
|
|
again, rather than making a network call or printing a dead key
|
|
that will just 401 Claude Code."""
|
|
old_timestamp = time.time() - (CLI_JWT_EXPIRATION_HOURS + 1) * 3600
|
|
|
|
with (
|
|
patch(
|
|
"litellm.proxy.client.cli.commands.auth.load_cli_token",
|
|
return_value=CliTokenRecord(
|
|
base_url="http://localhost:4000",
|
|
key="sk-stale-key",
|
|
timestamp=old_timestamp,
|
|
),
|
|
),
|
|
patch("requests.post") as mock_post,
|
|
):
|
|
result = self.runner.invoke(print_token, obj={})
|
|
|
|
assert result.exit_code != 0
|
|
assert "sk-stale-key" not in result.output
|
|
assert "lite login" in result.output
|
|
mock_post.assert_not_called()
|
|
|
|
|
|
class TestPrintTokenWithConfigFile:
|
|
"""A config-file base_url is a drop-in replacement for exporting
|
|
LITELLM_PROXY_URL, so print-token must treat it as an explicit server
|
|
choice: a token minted for a different proxy is never handed out."""
|
|
|
|
def test_config_base_url_mismatch_fails_closed(self, isolated_home):
|
|
_write_home_json(
|
|
isolated_home,
|
|
"token.json",
|
|
{"base_url": "https://server-a.example.com", "key": "sk-issued-for-a", "timestamp": time.time()},
|
|
)
|
|
_write_home_json(isolated_home, "config.json", {"base_url": "https://server-b.example.com"})
|
|
|
|
result = CliRunner().invoke(cli, ["auth", "print-token"])
|
|
|
|
assert result.exit_code == 1
|
|
assert "sk-issued-for-a" not in result.output
|
|
assert "Not authenticated for this server" in result.output
|
|
|
|
def test_config_base_url_match_prints_token(self, isolated_home):
|
|
_write_home_json(
|
|
isolated_home,
|
|
"token.json",
|
|
{"base_url": "https://server-a.example.com", "key": "sk-issued-for-a", "timestamp": time.time()},
|
|
)
|
|
_write_home_json(isolated_home, "config.json", {"base_url": "https://server-a.example.com"})
|
|
|
|
result = CliRunner().invoke(cli, ["auth", "print-token"])
|
|
|
|
assert result.exit_code == 0
|
|
assert result.stdout.strip() == "sk-issued-for-a"
|
|
|
|
def test_empty_config_base_url_treated_as_unset(self, isolated_home):
|
|
"""A hand-edited config.json with base_url "" must behave like no config at all:
|
|
base_url falls back to the default AND explicitness stays False."""
|
|
_write_home_json(
|
|
isolated_home,
|
|
"token.json",
|
|
{"base_url": "https://server-a.example.com", "key": "sk-issued-for-a", "timestamp": time.time()},
|
|
)
|
|
_write_home_json(isolated_home, "config.json", {"base_url": ""})
|
|
|
|
result = CliRunner().invoke(cli, ["auth", "print-token"])
|
|
|
|
assert result.exit_code == 0
|
|
assert result.stdout.strip() == "sk-issued-for-a"
|
|
|
|
def test_bare_invocation_without_config_file_unchanged(self, isolated_home):
|
|
"""No config file means base_url_explicit stays False, so the stored
|
|
token's own server is trusted (pre-config behavior must not regress)."""
|
|
_write_home_json(
|
|
isolated_home,
|
|
"token.json",
|
|
{"base_url": "https://server-a.example.com", "key": "sk-issued-for-a", "timestamp": time.time()},
|
|
)
|
|
|
|
result = CliRunner().invoke(cli, ["auth", "print-token"])
|
|
|
|
assert result.exit_code == 0
|
|
assert result.stdout.strip() == "sk-issued-for-a"
|
|
|
|
|
|
class TestFileFallbackStorage:
|
|
"""On a headless box with no keychain the token file is still the only store, so it has to
|
|
stay owner-only and survive a failed write."""
|
|
|
|
def test_owner_only_file_and_directory_with_no_temp_leftovers(self, isolated_home, secret_vault_factory):
|
|
save_cli_token(
|
|
CliTokenRecord(base_url="https://proxy.example.com", key="sk-secret", user_id="u-1", timestamp=1234567890),
|
|
vault=secret_vault_factory(available=False),
|
|
)
|
|
|
|
token_file = isolated_home / ".litellm" / "token.json"
|
|
assert json.loads(token_file.read_text())["key"] == "sk-secret"
|
|
assert stat.S_IMODE(token_file.stat().st_mode) == 0o600
|
|
assert stat.S_IMODE(token_file.parent.stat().st_mode) == 0o700
|
|
assert list(token_file.parent.glob(".tmp-*")) == []
|
|
|
|
def test_a_failed_write_preserves_the_existing_token(self, isolated_home, secret_vault_factory, monkeypatch):
|
|
_write_home_json(isolated_home, "token.json", {"key": "sk-original", "timestamp": 1234567890})
|
|
token_file = isolated_home / ".litellm" / "token.json"
|
|
|
|
def _explode(*args, **kwargs):
|
|
raise TypeError("not serialisable")
|
|
|
|
monkeypatch.setattr("litellm.litellm_core_utils.private_json.json.dump", _explode)
|
|
|
|
with pytest.raises(TypeError):
|
|
save_cli_token(CliTokenRecord(key="sk-new"), vault=secret_vault_factory(available=False))
|
|
|
|
assert json.loads(token_file.read_text()) == {"key": "sk-original", "timestamp": 1234567890}
|
|
assert list(token_file.parent.glob(".tmp-*")) == []
|
|
|
|
|
|
class TestKeychainBackedCommands:
|
|
"""End-to-end through the `lite` commands: the secret lives in the keychain, the file keeps
|
|
only metadata, and every command still reads and writes through that split."""
|
|
|
|
def setup_method(self):
|
|
self.runner = CliRunner()
|
|
|
|
def _login(self, vault, base_url="https://test.example.com"):
|
|
poll_response = Mock()
|
|
poll_response.status_code = 200
|
|
poll_response.json.return_value = {
|
|
"status": "ready",
|
|
"key": "sk-minted",
|
|
"user_id": "test-user-123",
|
|
"team_id": "team-1",
|
|
"teams": ["team-1"],
|
|
}
|
|
with (
|
|
patch("webbrowser.open"),
|
|
patch("requests.post", return_value=_mock_cli_sso_start_response()),
|
|
patch("requests.get", return_value=poll_response),
|
|
patch("litellm.proxy.client.cli.interface.show_commands"),
|
|
):
|
|
return self.runner.invoke(login, obj={"base_url": base_url, "secret_vault": vault})
|
|
|
|
def test_login_puts_the_secret_in_the_keychain_and_not_in_the_file(self, isolated_home, secret_vault_factory):
|
|
vault = secret_vault_factory()
|
|
|
|
result = self._login(vault)
|
|
|
|
token_file = isolated_home / ".litellm" / "token.json"
|
|
assert result.exit_code == 0
|
|
assert "Credential stored in your OS keychain." in result.output
|
|
assert json.loads(vault.blob)["key"] == "sk-minted"
|
|
assert "sk-minted" not in token_file.read_text()
|
|
assert json.loads(token_file.read_text())["user_id"] == "test-user-123"
|
|
|
|
def test_login_without_a_keychain_says_where_the_credential_went(self, isolated_home, secret_vault_factory):
|
|
result = self._login(secret_vault_factory(available=False))
|
|
|
|
token_file = isolated_home / ".litellm" / "token.json"
|
|
assert result.exit_code == 0
|
|
assert "No OS keychain available" in result.output
|
|
assert str(token_file) in result.output
|
|
assert json.loads(token_file.read_text())["key"] == "sk-minted"
|
|
|
|
def test_login_points_a_user_missing_the_keyring_package_at_the_install(self, isolated_home, secret_vault_factory):
|
|
"""`lite` ships with every install, the keyring package only with the cli extra. Telling
|
|
that user their machine has no keychain sends them looking for a problem they do not have."""
|
|
result = self._login(secret_vault_factory(available=False, failure=KeyringNotInstalled()))
|
|
|
|
token_file = isolated_home / ".litellm" / "token.json"
|
|
assert result.exit_code == 0
|
|
assert "pip install 'litellm[cli]'" in result.output
|
|
assert "No OS keychain available" not in result.output
|
|
assert json.loads(token_file.read_text())["key"] == "sk-minted"
|
|
|
|
def test_login_keeps_the_credential_when_the_backend_keeps_nothing(self, isolated_home, secret_vault_factory):
|
|
"""A backend that accepts writes and stores nothing must not be reported as keychain
|
|
storage, because the file is then told to drop the only remaining copy."""
|
|
result = self._login(secret_vault_factory(discards=True))
|
|
|
|
token_file = isolated_home / ".litellm" / "token.json"
|
|
assert result.exit_code == 0
|
|
assert "Credential stored in your OS keychain." not in result.output
|
|
assert "keyring --enable" in result.output
|
|
assert json.loads(token_file.read_text())["key"] == "sk-minted"
|
|
|
|
def test_login_names_the_kill_switch_instead_of_blaming_the_machine(self, isolated_home, secret_vault_factory):
|
|
result = self._login(secret_vault_factory(available=False, failure=KeyringDisabled()))
|
|
|
|
assert result.exit_code == 0
|
|
assert DISABLE_KEYRING_ENV_VAR in result.output
|
|
assert "No OS keychain available" not in result.output
|
|
assert json.loads((isolated_home / ".litellm" / "token.json").read_text())["key"] == "sk-minted"
|
|
|
|
def test_whoami_and_print_token_read_through_the_keychain(self, isolated_home, secret_vault_factory):
|
|
vault = secret_vault_factory()
|
|
self._login(vault)
|
|
obj = {"base_url": "https://test.example.com", "secret_vault": vault}
|
|
|
|
whoami_result = self.runner.invoke(whoami, obj=obj)
|
|
print_result = self.runner.invoke(print_token, obj=obj)
|
|
|
|
assert "Authenticated" in whoami_result.output
|
|
assert "test-user-123" in whoami_result.output
|
|
assert print_result.exit_code == 0
|
|
assert print_result.stdout.strip() == "sk-minted"
|
|
|
|
def test_logout_clears_the_keychain_as_well_as_the_file(self, isolated_home, secret_vault_factory):
|
|
vault = secret_vault_factory()
|
|
self._login(vault)
|
|
|
|
result = self.runner.invoke(logout, obj={"base_url": "https://test.example.com", "secret_vault": vault})
|
|
|
|
assert result.exit_code == 0
|
|
assert "Logged out successfully" in result.output
|
|
assert vault.blob is None
|
|
assert not (isolated_home / ".litellm" / "token.json").exists()
|
|
|
|
def test_logout_warns_when_the_keychain_will_not_release_the_secret(self, isolated_home, secret_vault_factory):
|
|
"""Silently reporting success would leave a live credential in the keychain."""
|
|
vault = secret_vault_factory(erasable=False)
|
|
self._login(vault)
|
|
|
|
result = self.runner.invoke(logout, obj={"base_url": "https://test.example.com", "secret_vault": vault})
|
|
|
|
assert result.exit_code == 0
|
|
assert "could not be removed" in result.output
|
|
assert not (isolated_home / ".litellm" / "token.json").exists()
|
|
|
|
def test_print_token_explains_a_locked_keychain_instead_of_printing_nothing(
|
|
self, isolated_home, secret_vault_factory
|
|
):
|
|
_write_home_json(
|
|
isolated_home,
|
|
"token.json",
|
|
{"base_url": "https://test.example.com", "user_id": "u-1", "timestamp": time.time()},
|
|
)
|
|
obj = {"base_url": "https://test.example.com", "secret_vault": secret_vault_factory(available=False)}
|
|
|
|
result = self.runner.invoke(print_token, obj=obj)
|
|
|
|
assert result.exit_code == 1
|
|
assert "could not be read" in result.output
|
|
assert "lite login" in result.output
|
|
|
|
def test_whoami_does_not_call_a_credential_it_cannot_read_authenticated(self, isolated_home, secret_vault_factory):
|
|
"""A login whose secret is stuck in an unreachable keychain authenticates nothing. Leading
|
|
with "Authenticated" and a token age reads as a working session, and sends the user looking
|
|
for the problem somewhere other than the keychain the notice underneath names."""
|
|
_write_home_json(
|
|
isolated_home,
|
|
"token.json",
|
|
{"base_url": "https://test.example.com", "user_id": "u-1", "timestamp": time.time()},
|
|
)
|
|
obj = {"base_url": "https://test.example.com", "secret_vault": secret_vault_factory(available=False)}
|
|
|
|
result = self.runner.invoke(whoami, obj=obj)
|
|
|
|
assert "Authenticated" not in result.output
|
|
assert "the credential cannot be read" in result.output
|
|
assert "could not be read" in result.output
|
|
|
|
def test_whoami_names_the_kill_switch_rather_than_a_missing_package(self, isolated_home, secret_vault_factory):
|
|
"""Every unreachable keychain used to be described as a locked one needing the keyring
|
|
package installed. Someone who set the kill switch has the package and an unlocked keychain,
|
|
so that advice sends them to fix two things that were never wrong."""
|
|
_write_token_file(isolated_home, key=None)
|
|
vault = secret_vault_factory(available=False, failure=KeyringDisabled())
|
|
|
|
result = self.runner.invoke(whoami, obj={"base_url": "https://test.example.com", "secret_vault": vault})
|
|
|
|
assert DISABLE_KEYRING_ENV_VAR in result.output
|
|
assert "pip install" not in result.output
|
|
|
|
def test_print_token_points_an_install_without_keyring_at_the_package(self, isolated_home, secret_vault_factory):
|
|
_write_token_file(isolated_home, key=None)
|
|
vault = secret_vault_factory(available=False, failure=KeyringNotInstalled())
|
|
obj = {"base_url": "https://test.example.com", "secret_vault": vault}
|
|
|
|
result = self.runner.invoke(print_token, obj=obj)
|
|
|
|
assert result.exit_code == 1
|
|
assert "pip install 'litellm[cli]'" in result.output
|
|
assert DISABLE_KEYRING_ENV_VAR not in result.output
|
|
|
|
|
|
class TestApiKeyPrecedence:
|
|
"""`LITELLM_PROXY_API_KEY` and `--api-key` outrank the stored credential; moving the secret
|
|
into the keychain must not disturb that order."""
|
|
|
|
def _resolved_key(self, args, obj=None):
|
|
with patch("litellm.proxy.client.cli.main.print_version") as mock_print_version:
|
|
result = CliRunner().invoke(cli, [*args, "version"], obj=obj)
|
|
assert result.exit_code == 0, result.output
|
|
return mock_print_version.call_args[0][1]
|
|
|
|
def test_the_stored_credential_is_the_fallback(self, isolated_home):
|
|
_write_home_json(
|
|
isolated_home,
|
|
"token.json",
|
|
{"base_url": "http://localhost:4000", "key": "sk-stored", "timestamp": time.time()},
|
|
)
|
|
|
|
assert self._resolved_key([]) == "sk-stored"
|
|
|
|
def test_the_stored_credential_is_read_through_the_injected_keychain(self, isolated_home, secret_vault_factory):
|
|
"""The vault handed to the CLI through ctx.obj must be the one the group callback reads,
|
|
so a keychain-held secret resolves without ever touching the host OS keychain."""
|
|
_write_home_json(isolated_home, "token.json", {"base_url": "http://localhost:4000", "timestamp": time.time()})
|
|
vault = secret_vault_factory(_secret_blob("http://localhost:4000", "sk-keychain"))
|
|
|
|
assert self._resolved_key([], obj={"secret_vault": vault}) == "sk-keychain"
|
|
|
|
def test_env_var_beats_the_stored_credential(self, isolated_home, monkeypatch):
|
|
_write_home_json(
|
|
isolated_home,
|
|
"token.json",
|
|
{"base_url": "http://localhost:4000", "key": "sk-stored", "timestamp": time.time()},
|
|
)
|
|
monkeypatch.setenv("LITELLM_PROXY_API_KEY", "sk-from-env")
|
|
|
|
assert self._resolved_key([]) == "sk-from-env"
|
|
|
|
def test_explicit_api_key_beats_both(self, isolated_home, monkeypatch):
|
|
_write_home_json(
|
|
isolated_home,
|
|
"token.json",
|
|
{"base_url": "http://localhost:4000", "key": "sk-stored", "timestamp": time.time()},
|
|
)
|
|
monkeypatch.setenv("LITELLM_PROXY_API_KEY", "sk-from-env")
|
|
|
|
assert self._resolved_key(["--api-key", "sk-explicit"]) == "sk-explicit"
|
|
|
|
|
|
class TestLoginConfigClaude:
|
|
"""`lite login --config-claude` wiring into ~/.claude/settings.json"""
|
|
|
|
def setup_method(self):
|
|
self.runner = CliRunner()
|
|
|
|
def _isolate_default_settings(self, tmp_path, monkeypatch):
|
|
"""The default file, its `lite up` backup and its configure receipt all live under tmp_path."""
|
|
backup_path = tmp_path / "claude_settings_backup.json"
|
|
monkeypatch.setattr(
|
|
claude_settings_module, "SETTINGS_FILE_OWNERS", (SettingsFileOwner(backup_path, "lite up", "lite down"),)
|
|
)
|
|
monkeypatch.setattr(
|
|
claude_settings_module, "CLAUDE_SETTINGS_PATH", tmp_path / "default-home" / ".claude" / "settings.json"
|
|
)
|
|
monkeypatch.setattr(claude_settings_module, "CONFIGURE_STATE_PATH", tmp_path / "claude_configure_state.json")
|
|
return backup_path
|
|
|
|
def _run_login(
|
|
self, tmp_path, monkeypatch, args, base_url="https://test.example.com", *, config_dir_env=None, stored=None
|
|
):
|
|
settings_path = tmp_path / "claude" / "settings.json"
|
|
backup_path = self._isolate_default_settings(tmp_path, monkeypatch)
|
|
env = {"CLAUDE_CONFIG_DIR": str(settings_path.parent)} if config_dir_env is None else config_dir_env
|
|
poll_response = Mock()
|
|
poll_response.status_code = 200
|
|
poll_response.json.return_value = {
|
|
"status": "ready",
|
|
"key": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test.jwt",
|
|
"user_id": "test-user-123",
|
|
"team_id": "team-1",
|
|
"teams": ["team-1"],
|
|
}
|
|
with (
|
|
patch("webbrowser.open"),
|
|
patch("requests.post", return_value=_mock_cli_sso_start_response()),
|
|
patch("requests.get", return_value=poll_response),
|
|
patch("litellm.proxy.client.cli.commands.auth.save_cli_token", return_value=stored or SecretStored()),
|
|
patch("litellm.proxy.client.cli.interface.show_commands"),
|
|
):
|
|
result = self.runner.invoke(login, args, obj={"base_url": base_url}, env=env)
|
|
return result, settings_path, backup_path
|
|
|
|
def test_default_login_does_not_touch_claude_settings(self, tmp_path, monkeypatch):
|
|
result, settings_path, _backup_path = self._run_login(tmp_path, monkeypatch, [])
|
|
|
|
assert result.exit_code == 0
|
|
assert "Login successful!" in result.output
|
|
assert not settings_path.exists()
|
|
assert "Configured Claude Code" not in result.output
|
|
|
|
def test_flag_writes_the_settings_file_and_reports_success(self, tmp_path, monkeypatch):
|
|
result, settings_path, _backup_path = self._run_login(tmp_path, monkeypatch, ["--config-claude"])
|
|
|
|
assert result.exit_code == 0
|
|
written = json.loads(settings_path.read_text())
|
|
assert written["env"]["ANTHROPIC_BASE_URL"] == "https://test.example.com"
|
|
assert written["env"]["ENABLE_TOOL_SEARCH"] == "true"
|
|
# The minted key goes in as a static token: an apiKeyHelper would make Claude Code spawn `lite` (and
|
|
# its keychain probe) on every credential refresh, which is what this flag used to write.
|
|
assert written["env"]["ANTHROPIC_AUTH_TOKEN"] == "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test.jwt"
|
|
assert "apiKeyHelper" not in written
|
|
assert f"Configured Claude Code: {settings_path} now routes through https://test.example.com." in result.output
|
|
assert "run `lite login --config-claude` again after it expires" in result.output
|
|
assert "the model Claude Code starts and resumes on" in result.output
|
|
|
|
def test_flag_preserves_unrelated_settings_on_an_existing_file(self, tmp_path, monkeypatch):
|
|
settings_path = tmp_path / "claude" / "settings.json"
|
|
settings_path.parent.mkdir(parents=True)
|
|
settings_path.write_text(json.dumps({"theme": "dark", "env": {"KEEP": "me"}}))
|
|
|
|
result, _settings_path, _backup_path = self._run_login(tmp_path, monkeypatch, ["--config-claude"])
|
|
|
|
assert result.exit_code == 0
|
|
written = json.loads(settings_path.read_text())
|
|
assert written["theme"] == "dark"
|
|
assert written["env"]["KEEP"] == "me"
|
|
|
|
def _run_login_refused_before_the_sso_flow(self, tmp_path, monkeypatch, config_dir):
|
|
self._isolate_default_settings(tmp_path, monkeypatch).write_text("{}")
|
|
with patch("requests.post") as post, patch("webbrowser.open") as browser:
|
|
result = self.runner.invoke(
|
|
login,
|
|
["--config-claude"],
|
|
obj={"base_url": "https://test.example.com"},
|
|
env={"CLAUDE_CONFIG_DIR": config_dir},
|
|
)
|
|
assert result.exit_code != 0
|
|
assert "not logging in" in result.output and "lite down" in result.output
|
|
assert "`lite up` is currently managing" in result.output
|
|
assert "Login successful!" not in result.output
|
|
post.assert_not_called()
|
|
browser.assert_not_called()
|
|
assert not (tmp_path / "default-home" / ".claude" / "settings.json").exists()
|
|
|
|
def test_refuses_before_logging_in_while_lite_up_holds_the_default_settings_file(self, tmp_path, monkeypatch):
|
|
self._run_login_refused_before_the_sso_flow(tmp_path, monkeypatch, config_dir="")
|
|
|
|
def test_refuses_before_logging_in_while_lite_up_holds_the_default_file_reached_through_a_symlink(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
default_config_dir = tmp_path / "default-home" / ".claude"
|
|
default_config_dir.mkdir(parents=True)
|
|
alias = tmp_path / "claude-alias"
|
|
alias.symlink_to(default_config_dir, target_is_directory=True)
|
|
|
|
self._run_login_refused_before_the_sso_flow(tmp_path, monkeypatch, config_dir=str(alias))
|
|
|
|
def test_flag_writes_an_alternate_config_dir_even_while_lite_up_holds_the_default_file(self, tmp_path, monkeypatch):
|
|
(tmp_path / "claude_settings_backup.json").write_text("{}")
|
|
|
|
result, settings_path, _backup_path = self._run_login(tmp_path, monkeypatch, ["--config-claude"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
written = json.loads(settings_path.read_text())
|
|
assert written["env"]["ANTHROPIC_AUTH_TOKEN"] == "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test.jwt"
|
|
assert f"Configured Claude Code: {settings_path} now routes through https://test.example.com." in result.output
|
|
|
|
def test_flag_keeps_a_config_dir_receipt_apart_from_the_default_file_receipt(self, tmp_path, monkeypatch):
|
|
result, settings_path, _backup_path = self._run_login(tmp_path, monkeypatch, ["--config-claude"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
default_receipt = tmp_path / "claude_configure_state.json"
|
|
assert not default_receipt.exists()
|
|
receipts = list((tmp_path / "claude_configure_state").glob("*.json"))
|
|
assert len(receipts) == 1
|
|
assert json.loads(receipts[0].read_text())["file_existed"] is False
|
|
|
|
def test_a_second_login_replaces_the_key_and_unconfigure_still_restores_the_original(self, tmp_path, monkeypatch):
|
|
# The stored key expires daily, so the flag is re-run per login; the receipt must keep owning the
|
|
# slot across re-logins and hand back what was there before the first one.
|
|
from litellm.proxy.client.cli.commands.configure import unconfigure_claude
|
|
|
|
settings_path = tmp_path / "claude" / "settings.json"
|
|
settings_path.parent.mkdir(parents=True)
|
|
settings_path.write_text(json.dumps({"theme": "dark", "env": {"ANTHROPIC_AUTH_TOKEN": "sk-theirs"}}))
|
|
self._run_login(tmp_path, monkeypatch, ["--config-claude"])
|
|
first = json.loads(settings_path.read_text())["env"]["ANTHROPIC_AUTH_TOKEN"]
|
|
self._run_login(tmp_path, monkeypatch, ["--config-claude"])
|
|
assert json.loads(settings_path.read_text())["env"]["ANTHROPIC_AUTH_TOKEN"] == first != "sk-theirs"
|
|
|
|
result = self.runner.invoke(unconfigure_claude, [], env={"CLAUDE_CONFIG_DIR": str(settings_path.parent)})
|
|
assert result.exit_code == 0, result.output
|
|
assert json.loads(settings_path.read_text()) == {"theme": "dark", "env": {"ANTHROPIC_AUTH_TOKEN": "sk-theirs"}}
|
|
|
|
@pytest.mark.parametrize(
|
|
"stored",
|
|
[CredentialNotSaved("read-only ~/.litellm"), CredentialNotRecorded()],
|
|
ids=["nothing-kept-it", "keychain-took-it-file-refused"],
|
|
)
|
|
def test_claude_code_is_configured_even_when_the_cli_could_not_keep_the_credential(
|
|
self, tmp_path, monkeypatch, stored
|
|
):
|
|
# The key is in hand either way, and --config-claude asked for exactly that key to be written into
|
|
# settings.json; whether the CLI's own token file or keychain kept a copy is a separate outcome.
|
|
result, settings_path, _backup_path = self._run_login(tmp_path, monkeypatch, ["--config-claude"], stored=stored)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
written = json.loads(settings_path.read_text())
|
|
assert written["env"]["ANTHROPIC_AUTH_TOKEN"] == "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test.jwt"
|
|
assert f"Configured Claude Code: {settings_path}" in result.output
|
|
assert "even though the CLI itself could not keep it" in result.output
|
|
assert "You can now use the CLI without specifying --api-key" not in result.output
|
|
|
|
def test_settings_failure_is_reported_without_claiming_login_failed(self, tmp_path, monkeypatch):
|
|
settings_path = tmp_path / "claude" / "settings.json"
|
|
settings_path.parent.mkdir(parents=True)
|
|
settings_path.write_text("not json at all {{{")
|
|
|
|
result, _settings_path, _backup_path = self._run_login(tmp_path, monkeypatch, ["--config-claude"])
|
|
|
|
assert result.exit_code != 0
|
|
assert "Login successful!" in result.output
|
|
assert "could not configure Claude Code" in result.output
|
|
assert "invalid JSON" in result.output
|
|
assert "Authentication failed" not in result.output
|
|
|
|
|
|
class _FakeHttpResponse:
|
|
def __init__(self, status_code, payload):
|
|
self.status_code = status_code
|
|
self._payload = payload
|
|
self.text = json.dumps(payload)
|
|
self.content = self.text.encode()
|
|
|
|
def json(self):
|
|
return self._payload
|
|
|
|
|
|
class _FakeSession:
|
|
"""Stands in for ``requests.Session`` so the CLI's refresh and revoke calls can be observed."""
|
|
|
|
instances = []
|
|
|
|
def __init__(self):
|
|
self.posts = []
|
|
self.response = _FakeHttpResponse(200, {})
|
|
_FakeSession.instances.append(self)
|
|
|
|
def post(self, url, *, data=None, json=None, timeout, allow_redirects):
|
|
self.posts.append((url, data))
|
|
return self.response
|
|
|
|
def get(self, url, *, timeout):
|
|
raise AssertionError(f"unexpected GET {url}")
|
|
|
|
|
|
PKCE_BASE_URL = "https://llm.example.com"
|
|
PKCE_TOKEN_RESPONSE = {
|
|
"access_token": "sk-cli-rotated",
|
|
"token_type": "Bearer",
|
|
"expires_in": 3600,
|
|
"refresh_token": "llm_srefresh_rotated",
|
|
"user_id": "u1",
|
|
"team_id": "team-b",
|
|
}
|
|
|
|
|
|
def _pkce_record(**overrides):
|
|
return {
|
|
"base_url": PKCE_BASE_URL,
|
|
"key": "sk-cli-old",
|
|
"user_id": "u1",
|
|
"user_email": "unknown",
|
|
"user_role": "cli",
|
|
"auth_header_name": "Authorization",
|
|
"jwt_token": "",
|
|
"timestamp": time.time(),
|
|
"expires_at": time.time() + 30,
|
|
"refresh_token": "llm_srefresh_old",
|
|
"client_id": "llm_dcrc_abc",
|
|
"token_endpoint": f"{PKCE_BASE_URL}/token",
|
|
"revocation_endpoint": f"{PKCE_BASE_URL}/revoke",
|
|
"resource": PKCE_BASE_URL,
|
|
"team_id": "team-b",
|
|
**overrides,
|
|
}
|
|
|
|
|
|
def _pkce_credential():
|
|
from litellm.proxy.client.cli.commands.pkce_login import PkceCredential
|
|
|
|
return PkceCredential(
|
|
access_token="sk-cli-fresh",
|
|
refresh_token="llm_srefresh_fresh",
|
|
expires_at=time.time() + 3600,
|
|
client_id="llm_dcrc_abc",
|
|
token_endpoint=f"{PKCE_BASE_URL}/token",
|
|
revocation_endpoint=f"{PKCE_BASE_URL}/revoke",
|
|
resource=PKCE_BASE_URL,
|
|
user_id="u1",
|
|
team_id="team-b",
|
|
)
|
|
|
|
|
|
class TestPkceLoginCommand:
|
|
"""``lite login --pkce`` swaps the proxy-mediated SSO poll for the browser PKCE flow."""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def isolated_home(self, monkeypatch, tmp_path):
|
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
|
return tmp_path
|
|
|
|
def setup_method(self):
|
|
self.runner = CliRunner()
|
|
_FakeSession.instances.clear()
|
|
|
|
def test_pkce_login_saves_the_new_record_then_revokes_the_refresh_token_it_replaced(self):
|
|
posts_when_saved = []
|
|
|
|
def record_posts(record, **_):
|
|
posts_when_saved.append(list(_FakeSession.instances[0].posts))
|
|
return SecretStored()
|
|
|
|
with (
|
|
patch("litellm.proxy.client.cli.commands.auth.run_pkce_login", return_value=_pkce_credential()),
|
|
patch("litellm.proxy.client.cli.commands.auth.load_token", return_value=_pkce_record(team_id="team-a")),
|
|
patch("litellm.proxy.client.cli.commands.auth.save_token", side_effect=record_posts) as save,
|
|
patch("litellm.proxy.client.cli.commands.auth.requests.Session", _FakeSession),
|
|
patch("litellm.proxy.client.cli.interface.show_commands"),
|
|
):
|
|
result = self.runner.invoke(login, ["--pkce"], obj={"base_url": PKCE_BASE_URL})
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert "Login successful!" in result.output
|
|
assert "Could not revoke" not in result.output
|
|
assert save.call_args.args[0]["refresh_token"] == "llm_srefresh_fresh"
|
|
assert save.call_args.args[0]["team_id"] == "team-b"
|
|
assert posts_when_saved == [[]]
|
|
assert _FakeSession.instances[0].posts == [
|
|
(
|
|
f"{PKCE_BASE_URL}/revoke",
|
|
{"token": "llm_srefresh_old", "token_type_hint": "refresh_token", "client_id": "llm_dcrc_abc"},
|
|
)
|
|
]
|
|
|
|
def test_pkce_login_keeps_the_new_record_when_the_old_refresh_token_cannot_be_revoked(self):
|
|
class _FailingSession(_FakeSession):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.response = _FakeHttpResponse(503, {"error": "temporarily_unavailable"})
|
|
|
|
with (
|
|
patch("litellm.proxy.client.cli.commands.auth.run_pkce_login", return_value=_pkce_credential()),
|
|
patch("litellm.proxy.client.cli.commands.auth.load_token", return_value=_pkce_record()),
|
|
patch("litellm.proxy.client.cli.commands.auth.save_token", return_value=SecretStored()) as save,
|
|
patch("litellm.proxy.client.cli.commands.auth.requests.Session", _FailingSession),
|
|
patch("litellm.proxy.client.cli.interface.show_commands"),
|
|
):
|
|
result = self.runner.invoke(login, ["--pkce"], obj={"base_url": PKCE_BASE_URL})
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert (
|
|
"Could not revoke the previous login's refresh token on the proxy (revocation failed with 503"
|
|
in result.output
|
|
)
|
|
assert "Login successful!" in result.output
|
|
assert save.call_args.args[0]["refresh_token"] == "llm_srefresh_fresh"
|
|
|
|
@pytest.mark.parametrize("previous", [None, {"key": "sk-classic", "base_url": PKCE_BASE_URL}])
|
|
def test_pkce_login_without_a_previous_refresh_token_makes_no_revocation_request(self, previous):
|
|
with (
|
|
patch("litellm.proxy.client.cli.commands.auth.run_pkce_login", return_value=_pkce_credential()),
|
|
patch("litellm.proxy.client.cli.commands.auth.load_token", return_value=previous),
|
|
patch("litellm.proxy.client.cli.commands.auth.save_token", return_value=SecretStored()) as save,
|
|
patch("litellm.proxy.client.cli.commands.auth.requests.Session", _FakeSession),
|
|
patch("litellm.proxy.client.cli.interface.show_commands"),
|
|
):
|
|
result = self.runner.invoke(login, ["--pkce"], obj={"base_url": PKCE_BASE_URL})
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert "Login successful!" in result.output
|
|
save.assert_called_once()
|
|
assert _FakeSession.instances[0].posts == []
|
|
|
|
def test_pkce_login_saves_the_refreshable_record_and_skips_the_sso_poll(self):
|
|
with (
|
|
patch("litellm.proxy.client.cli.commands.auth.run_pkce_login", return_value=_pkce_credential()) as run,
|
|
patch("litellm.proxy.client.cli.commands.auth._start_cli_sso_flow") as sso_start,
|
|
patch("litellm.proxy.client.cli.commands.auth.save_token", return_value=SecretStored()) as save,
|
|
patch("litellm.proxy.client.cli.interface.show_commands"),
|
|
):
|
|
result = self.runner.invoke(login, ["--pkce"], obj={"base_url": f"{PKCE_BASE_URL}/"})
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert "Login successful!" in result.output
|
|
assert "JWT Token: sk-cli-fresh..." in result.output
|
|
sso_start.assert_not_called()
|
|
assert run.call_args.args[0] == f"{PKCE_BASE_URL}/"
|
|
saved = save.call_args.args[0]
|
|
assert saved["base_url"] == PKCE_BASE_URL
|
|
assert saved["key"] == "sk-cli-fresh"
|
|
assert saved["refresh_token"] == "llm_srefresh_fresh"
|
|
assert saved["client_id"] == "llm_dcrc_abc"
|
|
assert saved["token_endpoint"] == f"{PKCE_BASE_URL}/token"
|
|
assert saved["revocation_endpoint"] == f"{PKCE_BASE_URL}/revoke"
|
|
assert saved["resource"] == PKCE_BASE_URL
|
|
assert saved["user_id"] == "u1"
|
|
assert saved["team_id"] == "team-b"
|
|
|
|
def test_pkce_login_failure_is_reported_and_nothing_is_saved(self):
|
|
from litellm.proxy.client.cli.commands.pkce_login import PkceFailure
|
|
|
|
with (
|
|
patch(
|
|
"litellm.proxy.client.cli.commands.auth.run_pkce_login",
|
|
return_value=PkceFailure("sign-in was not approved (access_denied): no details"),
|
|
),
|
|
patch("litellm.proxy.client.cli.commands.auth.save_token", return_value=SecretStored()) as save,
|
|
):
|
|
result = self.runner.invoke(login, ["--pkce"], obj={"base_url": PKCE_BASE_URL})
|
|
|
|
assert result.exit_code == 0
|
|
assert "Authentication failed: sign-in was not approved (access_denied): no details" in result.output
|
|
save.assert_not_called()
|
|
|
|
def test_login_without_the_flag_never_touches_the_pkce_flow(self):
|
|
with (
|
|
patch("litellm.proxy.client.cli.commands.auth.run_pkce_login") as run,
|
|
patch("litellm.proxy.client.cli.commands.auth._start_cli_sso_flow", side_effect=KeyboardInterrupt),
|
|
):
|
|
result = self.runner.invoke(login, obj={"base_url": PKCE_BASE_URL})
|
|
|
|
assert "cancelled" in result.output
|
|
run.assert_not_called()
|
|
|
|
|
|
class TestPkceLogoutCommand:
|
|
def setup_method(self):
|
|
self.runner = CliRunner()
|
|
_FakeSession.instances.clear()
|
|
|
|
def test_logout_revokes_the_refresh_token_before_clearing(self):
|
|
with (
|
|
patch("litellm.proxy.client.cli.commands.auth.load_token", return_value=_pkce_record()),
|
|
patch("litellm.proxy.client.cli.commands.auth.clear_cli_token", return_value=SecretErased()) as clear,
|
|
patch("litellm.proxy.client.cli.commands.auth.requests.Session", _FakeSession),
|
|
):
|
|
result = self.runner.invoke(logout)
|
|
|
|
assert result.exit_code == 0
|
|
assert result.output == "Logged out successfully. Authentication token cleared.\n"
|
|
clear.assert_called_once()
|
|
assert _FakeSession.instances[0].posts == [
|
|
(
|
|
f"{PKCE_BASE_URL}/revoke",
|
|
{"token": "llm_srefresh_old", "token_type_hint": "refresh_token", "client_id": "llm_dcrc_abc"},
|
|
)
|
|
]
|
|
|
|
def test_logout_still_clears_when_the_proxy_refuses_the_revocation(self):
|
|
class _RefusingSession(_FakeSession):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.response = _FakeHttpResponse(401, {"error": "invalid_client"})
|
|
|
|
with (
|
|
patch("litellm.proxy.client.cli.commands.auth.load_token", return_value=_pkce_record()),
|
|
patch("litellm.proxy.client.cli.commands.auth.clear_cli_token", return_value=SecretErased()) as clear,
|
|
patch("litellm.proxy.client.cli.commands.auth.requests.Session", _RefusingSession),
|
|
):
|
|
result = self.runner.invoke(logout)
|
|
|
|
assert result.exit_code == 0
|
|
assert (
|
|
"Could not revoke the refresh token on the proxy (revocation failed with 401: invalid_client); "
|
|
"it expires on its own." in result.output
|
|
)
|
|
assert "Logged out successfully" in result.output
|
|
clear.assert_called_once()
|
|
|
|
def test_logout_keeps_the_record_when_the_proxy_cannot_record_the_revocation(self):
|
|
class _UnavailableSession(_FakeSession):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.response = _FakeHttpResponse(
|
|
503, {"error": "temporarily_unavailable", "error_description": "the record is unavailable"}
|
|
)
|
|
|
|
with (
|
|
patch("litellm.proxy.client.cli.commands.auth.load_token", return_value=_pkce_record()),
|
|
patch("litellm.proxy.client.cli.commands.auth.clear_cli_token", return_value=SecretErased()) as clear,
|
|
patch("litellm.proxy.client.cli.commands.auth.requests.Session", _UnavailableSession),
|
|
):
|
|
result = self.runner.invoke(logout)
|
|
|
|
assert result.exit_code == 1
|
|
assert (
|
|
"Error: The proxy could not record the revocation (revocation failed with 503: the record is unavailable). "
|
|
"Nothing was cleared; run `lite logout` again shortly." in result.output
|
|
)
|
|
assert "Logged out successfully" not in result.output
|
|
clear.assert_not_called()
|
|
|
|
def test_logout_of_a_classic_token_makes_no_request(self):
|
|
with (
|
|
patch("litellm.proxy.client.cli.commands.auth.load_token", return_value={"key": "sk-classic"}),
|
|
patch("litellm.proxy.client.cli.commands.auth.clear_cli_token", return_value=SecretErased()) as clear,
|
|
patch("litellm.proxy.client.cli.commands.auth.requests.Session", _FakeSession),
|
|
):
|
|
result = self.runner.invoke(logout)
|
|
|
|
assert result.output == "Logged out successfully. Authentication token cleared.\n"
|
|
clear.assert_called_once()
|
|
assert _FakeSession.instances[0].posts == []
|
|
|
|
|
|
class TestPkcePrintToken:
|
|
"""``lite print-token`` is Claude Code's apiKeyHelper, so a near-expiry PKCE key must be
|
|
refreshed silently and stdout must carry nothing but the key."""
|
|
|
|
def setup_method(self):
|
|
self.runner = CliRunner()
|
|
_FakeSession.instances.clear()
|
|
|
|
def test_print_token_refreshes_a_near_expiry_key_and_saves_the_rotation(self):
|
|
class _RefreshingSession(_FakeSession):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.response = _FakeHttpResponse(200, PKCE_TOKEN_RESPONSE)
|
|
|
|
with (
|
|
patch("litellm.proxy.client.cli.commands.auth.load_token", return_value=_pkce_record()),
|
|
patch("litellm.proxy.client.cli.commands.auth.save_token") as save,
|
|
patch("litellm.proxy.client.cli.commands.auth.requests.Session", _RefreshingSession),
|
|
):
|
|
result = self.runner.invoke(print_token, obj={})
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert result.stdout == "sk-cli-rotated\n"
|
|
assert _FakeSession.instances[0].posts[0][0] == f"{PKCE_BASE_URL}/token"
|
|
assert _FakeSession.instances[0].posts[0][1]["refresh_token"] == "llm_srefresh_old"
|
|
saved = save.call_args.args[0]
|
|
assert saved["key"] == "sk-cli-rotated"
|
|
assert saved["refresh_token"] == "llm_srefresh_rotated"
|
|
|
|
def test_print_token_prints_a_fresh_pkce_key_without_a_request(self):
|
|
with (
|
|
patch(
|
|
"litellm.proxy.client.cli.commands.auth.load_token",
|
|
return_value=_pkce_record(expires_at=time.time() + 3600),
|
|
),
|
|
patch("litellm.proxy.client.cli.commands.auth.requests.Session", _FakeSession),
|
|
):
|
|
result = self.runner.invoke(print_token, obj={})
|
|
|
|
assert result.stdout == "sk-cli-old\n"
|
|
assert _FakeSession.instances[0].posts == []
|
|
|
|
def test_print_token_fails_when_the_key_expired_and_refresh_is_refused(self):
|
|
class _RefusingSession(_FakeSession):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.response = _FakeHttpResponse(400, {"error": "invalid_grant"})
|
|
|
|
with (
|
|
patch(
|
|
"litellm.proxy.client.cli.commands.auth.load_token",
|
|
return_value=_pkce_record(expires_at=time.time() - 1),
|
|
),
|
|
patch("litellm.proxy.client.cli.commands.auth.save_token") as save,
|
|
patch("litellm.proxy.client.cli.commands.auth.requests.Session", _RefusingSession),
|
|
):
|
|
result = self.runner.invoke(print_token, obj={})
|
|
|
|
assert result.exit_code == 1
|
|
assert result.stdout == ""
|
|
assert "Could not renew the key: token request failed with 400: invalid_grant" in result.output
|
|
assert "Key expired. Run 'lite login --pkce' again." in result.output
|
|
assert "Run 'lite login' again" not in result.output
|
|
save.assert_not_called()
|
|
|
|
def test_print_token_through_the_cli_group_renews_once_and_reports_a_refusal_once(self, monkeypatch):
|
|
monkeypatch.delenv("LITELLM_PROXY_API_KEY", raising=False)
|
|
monkeypatch.delenv("LITELLM_PROXY_URL", raising=False)
|
|
|
|
class _RefusingSession(_FakeSession):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.response = _FakeHttpResponse(
|
|
400, {"error": "invalid_grant", "error_description": "the refresh token was already used"}
|
|
)
|
|
|
|
with (
|
|
patch(
|
|
"litellm.proxy.client.cli.commands.auth.load_token",
|
|
return_value=_pkce_record(expires_at=time.time() - 1),
|
|
),
|
|
patch("litellm.proxy.client.cli.commands.auth.save_token") as save,
|
|
patch("litellm.proxy.client.cli.commands.auth.requests.Session", _RefusingSession),
|
|
):
|
|
result = self.runner.invoke(cli, ["--base-url", PKCE_BASE_URL, "auth", "print-token"])
|
|
|
|
assert result.exit_code == 1
|
|
assert result.stdout == ""
|
|
assert sum(len(session.posts) for session in _FakeSession.instances) == 1
|
|
assert result.output.count("Could not renew the key") == 1
|
|
assert (
|
|
"Could not renew the key: token request failed with 400: the refresh token was already used"
|
|
in result.output
|
|
)
|
|
assert "Key expired. Run 'lite login --pkce' again." in result.output
|
|
save.assert_not_called()
|
|
|
|
def test_print_token_through_the_cli_group_prints_the_key_the_group_renewed(self, monkeypatch):
|
|
monkeypatch.delenv("LITELLM_PROXY_API_KEY", raising=False)
|
|
monkeypatch.delenv("LITELLM_PROXY_URL", raising=False)
|
|
|
|
class _RefreshingSession(_FakeSession):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.response = _FakeHttpResponse(200, PKCE_TOKEN_RESPONSE)
|
|
|
|
with (
|
|
patch("litellm.proxy.client.cli.commands.auth.load_token", return_value=_pkce_record()),
|
|
patch("litellm.proxy.client.cli.commands.auth.save_token") as save,
|
|
patch("litellm.proxy.client.cli.commands.auth.requests.Session", _RefreshingSession),
|
|
):
|
|
result = self.runner.invoke(cli, ["--base-url", PKCE_BASE_URL, "auth", "print-token"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert result.stdout == "sk-cli-rotated\n"
|
|
assert sum(len(session.posts) for session in _FakeSession.instances) == 1
|
|
assert save.call_count == 1
|
|
|
|
def test_print_token_invoked_bare_for_another_server_renews_once(self, monkeypatch, tmp_path):
|
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
|
monkeypatch.delenv("LITELLM_PROXY_API_KEY", raising=False)
|
|
monkeypatch.delenv("LITELLM_PROXY_URL", raising=False)
|
|
|
|
class _RefreshingSession(_FakeSession):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.response = _FakeHttpResponse(200, PKCE_TOKEN_RESPONSE)
|
|
|
|
with (
|
|
patch("litellm.proxy.client.cli.commands.auth.load_token", return_value=_pkce_record()),
|
|
patch("litellm.proxy.client.cli.commands.auth.save_token") as save,
|
|
patch("litellm.proxy.client.cli.commands.auth.requests.Session", _RefreshingSession),
|
|
):
|
|
result = self.runner.invoke(cli, ["auth", "print-token"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert result.stdout == "sk-cli-rotated\n"
|
|
assert sum(len(session.posts) for session in _FakeSession.instances) == 1
|
|
assert save.call_count == 1
|
|
|
|
def test_print_token_for_an_expired_classic_token_makes_no_request(self):
|
|
with (
|
|
patch(
|
|
"litellm.proxy.client.cli.commands.auth.load_token",
|
|
return_value={"key": "sk-classic", "timestamp": time.time() - (CLI_JWT_EXPIRATION_HOURS + 1) * 3600},
|
|
),
|
|
patch("litellm.proxy.client.cli.commands.auth.requests.Session", _FakeSession),
|
|
):
|
|
result = self.runner.invoke(print_token, obj={})
|
|
|
|
assert result.exit_code == 1
|
|
assert "Token expired" in result.output
|
|
assert _FakeSession.instances == []
|
|
|
|
|
|
class TestGetStoredApiKeyRefresh:
|
|
def test_get_stored_api_key_refreshes_a_near_expiry_pkce_key(self):
|
|
_FakeSession.instances.clear()
|
|
|
|
class _RefreshingSession(_FakeSession):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.response = _FakeHttpResponse(200, PKCE_TOKEN_RESPONSE)
|
|
|
|
with (
|
|
patch("litellm.proxy.client.cli.commands.auth.load_token", return_value=_pkce_record()),
|
|
patch("litellm.proxy.client.cli.commands.auth.save_token") as save,
|
|
patch("litellm.proxy.client.cli.commands.auth.requests.Session", _RefreshingSession),
|
|
):
|
|
assert get_stored_api_key(PKCE_BASE_URL) == "sk-cli-rotated"
|
|
assert get_stored_api_key("https://other.example.com") is None
|
|
|
|
assert save.call_count == 1
|
|
assert len(_FakeSession.instances) == 1
|
|
|
|
def test_get_stored_api_key_reports_a_refused_renewal_on_stderr_and_keeps_the_valid_key(self, capsys):
|
|
_FakeSession.instances.clear()
|
|
|
|
class _RefusingSession(_FakeSession):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.response = _FakeHttpResponse(503, {"error": "temporarily_unavailable"})
|
|
|
|
with (
|
|
patch("litellm.proxy.client.cli.commands.auth.load_token", return_value=_pkce_record()),
|
|
patch("litellm.proxy.client.cli.commands.auth.save_token") as save,
|
|
patch("litellm.proxy.client.cli.commands.auth.requests.Session", _RefusingSession),
|
|
):
|
|
assert get_stored_api_key(PKCE_BASE_URL) == "sk-cli-old"
|
|
|
|
captured = capsys.readouterr()
|
|
assert captured.out == ""
|
|
assert captured.err == "Could not renew the key: token request failed with 503: temporarily_unavailable\n"
|
|
save.assert_not_called()
|