Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 71 additions & 6 deletions litellm/proxy/client/cli/commands/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,28 @@ def prompt_team_selection_fallback(
return None


def _response_error_detail(response: requests.Response) -> str | None:
try:
body = response.json()
except ValueError:
return None
detail = body.get("detail") if isinstance(body, dict) else None
if isinstance(detail, str) and detail:
return detail
return None


def _polling_error_message(response: requests.Response) -> str:
detail = _response_error_detail(response)
if detail:
return f"Polling error: HTTP {response.status_code}: {detail}"
return f"Polling error: HTTP {response.status_code}"


def _is_permanent_polling_error(status_code: int) -> bool:
return 400 <= status_code < 500 and status_code != 429


# Polling-based authentication - no local server needed
def _poll_for_ready_data(
url: str,
Expand Down Expand Up @@ -308,8 +330,14 @@ def _poll_for_ready_data(
click.echo(pending_message)
elif other_status_message and other_status_log_every > 0 and attempt % other_status_log_every == 0:
click.echo(other_status_message)
elif _is_permanent_polling_error(response.status_code):
detail = _response_error_detail(response)
raise ValueError(
f"The proxy rejected the login session with HTTP {response.status_code}"
+ (f": {detail}" if detail else f" and no error detail (from {url})")
)
elif http_error_log_every > 0 and attempt % http_error_log_every == 0:
click.echo(f"Polling error: HTTP {response.status_code}")
click.echo(_polling_error_message(response))
except requests.RequestException as e:
if connection_error_log_every > 0 and attempt % connection_error_log_every == 0:
click.echo(f"Connection error (will retry): {e}")
Expand Down Expand Up @@ -342,12 +370,45 @@ def _normalize_teams(teams, team_details):


def _start_cli_sso_flow(base_url: str) -> Dict[str, Any]:
response = requests.post(f"{base_url}/sso/cli/start", timeout=10)
response.raise_for_status()
data = response.json()
start_url = f"{base_url}/sso/cli/start"
try:
response = requests.post(start_url, timeout=10)
except requests.RequestException as e:
raise ValueError(
f"Could not reach the proxy at {start_url}: {e}. "
"Check that the proxy is running and that --base-url points at it."
) from e

if response.status_code in (404, 405):
raise ValueError(
f"POST {start_url} returned HTTP {response.status_code}. "
"Either --base-url is wrong, or the proxy is older than this CLI and does not support "
"the CLI SSO login flow; upgrade the proxy or use a CLI version that matches it."
)
if response.status_code != 200:
detail = _response_error_detail(response)
raise ValueError(
f"Starting CLI login failed: HTTP {response.status_code} from {start_url}"
+ (f": {detail}" if detail else "")
)

try:
data = response.json()
except ValueError:
content_type = response.headers.get("content-type", "unknown")
raise ValueError(
f"The proxy returned a non-JSON response from {start_url} (content-type: {content_type}). "
"A proxy, load balancer, or auth gateway in front of LiteLLM may be intercepting the request. "
f"Response starts with: {response.text[:200]!r}"
)

required_fields = ("login_id", "poll_secret", "user_code")
if not all(isinstance(data.get(field), str) for field in required_fields):
raise ValueError("Invalid CLI SSO start response")
missing_fields = tuple(field for field in required_fields if not isinstance(data.get(field), str))
if missing_fields:
raise ValueError(
f"The response from {start_url} is missing required field(s): {', '.join(missing_fields)}. "
"The proxy version may not match this CLI; upgrade whichever is older."
)
return data


Expand Down Expand Up @@ -577,6 +638,10 @@ def login(ctx: click.Context):
return
else:
click.echo("❌ Authentication timed out. Please try again.")
click.echo(
"The proxy never reported the browser sign-in as finished. If you did complete it, "
"check the proxy logs for /sso/callback errors and confirm SSO is configured on the proxy."
)
return

except KeyboardInterrupt:
Expand Down
25 changes: 23 additions & 2 deletions litellm/proxy/management_endpoints/ui_sso.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,13 +241,34 @@ def _check_cli_sso_start_rate_limit(


def _get_cli_sso_flow_or_raise(login_id: Optional[str], cache: DualCache) -> dict:
if isinstance(login_id, str) and login_id.startswith("sk-"):
raise HTTPException(
status_code=400,
detail=(
"Your litellm CLI is out of date and uses a login flow this proxy no longer supports. "
"Upgrade it with `pip install -U 'litellm[proxy]'` and run `litellm-proxy login` again."
),
)
if not _is_valid_cli_sso_login_id(login_id):
raise HTTPException(status_code=400, detail="Invalid CLI login session")
raise HTTPException(status_code=400, detail="Invalid CLI login session id")

cache_key = _get_cli_sso_flow_cache_key(cast(str, login_id))
flow = cache.get_cache(key=cache_key)
if not isinstance(flow, dict) or "poll_secret_hash" not in flow:
raise HTTPException(status_code=400, detail="Invalid CLI login session")
verbose_proxy_logger.warning(
"CLI SSO login session not found in cache for login_id=%s. If the proxy runs multiple replicas, "
"a shared Redis cache (enable_redis_auth_cache: true) is required for CLI login to work.",
login_id,
)
raise HTTPException(
status_code=400,
detail=(
"CLI login session not found or expired. Run `litellm-proxy login` again. "
"If this happens immediately after starting a login, the proxy is likely running multiple "
"replicas without a shared cache; configure Redis with `enable_redis_auth_cache: true` "
"so every replica can see the login session."
),
)
return flow


Expand Down
20 changes: 9 additions & 11 deletions tests/test_litellm/proxy/auth/test_cli_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,13 @@ async def test_normalize_teams_with_details_with_aliases():

@patch("litellm.proxy.client.cli.commands.auth.requests.post")
def test_start_cli_sso_flow_rejects_invalid_response(request_mock):
"""Test CLI SSO start rejects malformed server responses"""
"""Test CLI SSO start rejects malformed server responses and names the missing fields"""
response = Mock()
response.raise_for_status = Mock()
response.status_code = 200
response.json.return_value = {"login_id": "cli-session", "user_code": "ABCD-EFGH"}
request_mock.return_value = response

with pytest.raises(ValueError, match="Invalid CLI SSO start response"):
with pytest.raises(ValueError, match="missing required field\\(s\\): poll_secret"):
_start_cli_sso_flow("https://litellm.com")


Expand All @@ -75,15 +75,13 @@ def test_start_cli_sso_flow_rejects_invalid_response(request_mock):
"litellm.proxy.client.cli.commands.auth.requests.get",
side_effect=[Mock(status_code=404)],
)
@patch("litellm.proxy.client.cli.commands.auth.click.echo")
@patch("litellm.proxy.client.cli.commands.auth.time.sleep")
async def test_poll_for_ready_404(sleep_mock, click_mock, request_mock):
"""Test poll_for_ready function"""
actual = _poll_for_ready_data(
"https://litellm.com", poll_interval=1, total_timeout=1, request_timeout=42
)
assert actual is None
click_mock.assert_called_once_with("Polling error: HTTP 404")
async def test_poll_for_ready_404(sleep_mock, request_mock):
"""Test polling treats HTTP 404 as a permanent error and raises instead of retrying"""
with pytest.raises(ValueError, match="rejected the login session with HTTP 404"):
_poll_for_ready_data(
"https://litellm.com", poll_interval=1, total_timeout=1, request_timeout=42
)
request_mock.assert_called_once_with("https://litellm.com", timeout=42)


Expand Down
133 changes: 133 additions & 0 deletions tests/test_litellm/proxy/client/cli/test_auth_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
sys.path.insert(0, os.path.abspath("../../..")) # Adds the parent directory to the system path


import pytest
from click.testing import CliRunner

from litellm.constants import CLI_JWT_EXPIRATION_HOURS
Expand Down Expand Up @@ -40,6 +41,138 @@ def _mock_cli_sso_start_response(
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) 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) 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) 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) 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) 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 TestTokenUtilities:
"""Test token file utility functions"""

Expand Down
30 changes: 30 additions & 0 deletions tests/test_litellm/proxy/management_endpoints/test_ui_sso.py
Original file line number Diff line number Diff line change
Expand Up @@ -2186,6 +2186,36 @@ def test_cli_sso_login_id_validation_restricts_charset(self):
assert not _is_valid_cli_sso_login_id("cli-test\x001234567890")
assert not _is_valid_cli_sso_login_id("sk-test1234567890")

def test_cli_sso_flow_lookup_tells_legacy_clients_to_upgrade(self):
"""Legacy CLIs send self-generated sk-<uuid> login ids; the 400 must say the CLI is outdated"""
from litellm.proxy.management_endpoints.ui_sso import (
_get_cli_sso_flow_or_raise,
)

mock_cache = MagicMock()
mock_cache.get_cache.return_value = None

with pytest.raises(HTTPException) as legacy_exc:
_get_cli_sso_flow_or_raise(
login_id="sk-85c789af-fc21-474c-9dc9-b5d794fe07ec",
cache=mock_cache,
)
assert legacy_exc.value.status_code == 400
assert "out of date" in legacy_exc.value.detail
assert "pip install" in legacy_exc.value.detail
mock_cache.get_cache.assert_not_called()

with pytest.raises(HTTPException) as generic_exc:
_get_cli_sso_flow_or_raise(login_id="not-a-valid-id", cache=mock_cache)
assert generic_exc.value.status_code == 400
assert generic_exc.value.detail == "Invalid CLI login session id"

with pytest.raises(HTTPException) as expired_exc:
_get_cli_sso_flow_or_raise(login_id="cli-test_1234567890", cache=mock_cache)
assert expired_exc.value.status_code == 400
assert "session not found or expired" in expired_exc.value.detail
assert "enable_redis_auth_cache" in expired_exc.value.detail

@pytest.mark.asyncio
async def test_cli_sso_start_creates_bound_flow(self):
"""Test CLI SSO start creates a polling secret bound flow"""
Expand Down
Loading