From fb1b5c24d7a5a6e300349bd810666218f81ad976 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Tue, 14 Jul 2026 17:26:56 -0700 Subject: [PATCH 1/3] fix(proxy): tell outdated litellm CLIs to upgrade when CLI SSO login id is legacy sk- format --- litellm/proxy/management_endpoints/ui_sso.py | 8 +++++ .../proxy/management_endpoints/test_ui_sso.py | 29 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/litellm/proxy/management_endpoints/ui_sso.py b/litellm/proxy/management_endpoints/ui_sso.py index d8015bb8031f..4d5aecf797f8 100644 --- a/litellm/proxy/management_endpoints/ui_sso.py +++ b/litellm/proxy/management_endpoints/ui_sso.py @@ -241,6 +241,14 @@ 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") diff --git a/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py b/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py index 2a4e2ed6b256..0a2cbb479f3e 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py +++ b/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py @@ -2186,6 +2186,35 @@ 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- 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" + + 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 expired_exc.value.detail == "Invalid CLI login session" + @pytest.mark.asyncio async def test_cli_sso_start_creates_bound_flow(self): """Test CLI SSO start creates a polling secret bound flow""" From 214db30ab97dec9bda5d3d5ab1d6dc0065e80891 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Wed, 15 Jul 2026 08:12:13 -0700 Subject: [PATCH 2/3] fix(cli): surface server error detail when SSO login polling fails and stop on permanent 4xx --- litellm/proxy/client/cli/commands/auth.py | 20 +++++++- .../proxy/client/cli/test_auth_commands.py | 49 +++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/client/cli/commands/auth.py b/litellm/proxy/client/cli/commands/auth.py index 4eda6817252f..9ee39ebac3c0 100644 --- a/litellm/proxy/client/cli/commands/auth.py +++ b/litellm/proxy/client/cli/commands/auth.py @@ -277,6 +277,21 @@ def prompt_team_selection_fallback( return None +def _polling_error_message(response: requests.Response) -> str: + try: + body = response.json() + except ValueError: + body = None + detail = body.get("detail") if isinstance(body, dict) else None + if isinstance(detail, str) and 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, @@ -308,8 +323,11 @@ 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): + click.echo(_polling_error_message(response)) + return None 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}") diff --git a/tests/test_litellm/proxy/client/cli/test_auth_commands.py b/tests/test_litellm/proxy/client/cli/test_auth_commands.py index 6be43c9da448..f9a517782107 100644 --- a/tests/test_litellm/proxy/client/cli/test_auth_commands.py +++ b/tests/test_litellm/proxy/client/cli/test_auth_commands.py @@ -40,6 +40,55 @@ def _mock_cli_sso_start_response( return mock_response +class TestPollingErrorSurfacing: + def test_client_error_prints_server_detail_and_stops_polling(self, capsys): + 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"): + result = _poll_for_ready_data("http://test/sso/cli/poll/sk-legacy") + + assert result is None + assert mock_get.call_count == 1 + assert ( + "Polling error: HTTP 400: Your litellm CLI is out of date and uses a login flow " + "this proxy no longer supports." in capsys.readouterr().out + ) + + 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 TestTokenUtilities: """Test token file utility functions""" From a83e9ee8842669a1a0ece0fdfd65e629c0d82e1d Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Wed, 15 Jul 2026 08:30:02 -0700 Subject: [PATCH 3/3] fix(cli): exhaustive, actionable error handling across the CLI SSO login flow --- litellm/proxy/client/cli/commands/auth.py | 65 +++++++++++-- litellm/proxy/management_endpoints/ui_sso.py | 17 +++- .../test_litellm/proxy/auth/test_cli_auth.py | 20 ++-- .../proxy/client/cli/test_auth_commands.py | 94 ++++++++++++++++++- .../proxy/management_endpoints/test_ui_sso.py | 5 +- 5 files changed, 172 insertions(+), 29 deletions(-) diff --git a/litellm/proxy/client/cli/commands/auth.py b/litellm/proxy/client/cli/commands/auth.py index 9ee39ebac3c0..785d3b1e37b6 100644 --- a/litellm/proxy/client/cli/commands/auth.py +++ b/litellm/proxy/client/cli/commands/auth.py @@ -277,13 +277,20 @@ def prompt_team_selection_fallback( return None -def _polling_error_message(response: requests.Response) -> str: +def _response_error_detail(response: requests.Response) -> str | None: try: body = response.json() except ValueError: - body = None + 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}" @@ -324,8 +331,11 @@ def _poll_for_ready_data( 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): - click.echo(_polling_error_message(response)) - return None + 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(_polling_error_message(response)) except requests.RequestException as e: @@ -360,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 @@ -595,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: diff --git a/litellm/proxy/management_endpoints/ui_sso.py b/litellm/proxy/management_endpoints/ui_sso.py index 4d5aecf797f8..0475566192e2 100644 --- a/litellm/proxy/management_endpoints/ui_sso.py +++ b/litellm/proxy/management_endpoints/ui_sso.py @@ -250,12 +250,25 @@ def _get_cli_sso_flow_or_raise(login_id: Optional[str], cache: DualCache) -> dic ), ) 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 diff --git a/tests/test_litellm/proxy/auth/test_cli_auth.py b/tests/test_litellm/proxy/auth/test_cli_auth.py index a4f72ef90eff..c9b31a1d776e 100644 --- a/tests/test_litellm/proxy/auth/test_cli_auth.py +++ b/tests/test_litellm/proxy/auth/test_cli_auth.py @@ -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") @@ -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) diff --git a/tests/test_litellm/proxy/client/cli/test_auth_commands.py b/tests/test_litellm/proxy/client/cli/test_auth_commands.py index f9a517782107..a451889415f0 100644 --- a/tests/test_litellm/proxy/client/cli/test_auth_commands.py +++ b/tests/test_litellm/proxy/client/cli/test_auth_commands.py @@ -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 @@ -41,7 +42,7 @@ def _mock_cli_sso_start_response( class TestPollingErrorSurfacing: - def test_client_error_prints_server_detail_and_stops_polling(self, capsys): + 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() @@ -51,15 +52,36 @@ def test_client_error_prints_server_detail_and_stops_polling(self, capsys): } with patch("requests.get", return_value=mock_response) as mock_get, patch("time.sleep"): - result = _poll_for_ready_data("http://test/sso/cli/poll/sk-legacy") + with pytest.raises(ValueError) as exc_info: + _poll_for_ready_data("http://test/sso/cli/poll/sk-legacy") - assert result is None assert mock_get.call_count == 1 assert ( - "Polling error: HTTP 400: Your litellm CLI is out of date and uses a login flow " - "this proxy no longer supports." in capsys.readouterr().out + "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 @@ -89,6 +111,68 @@ def test_rate_limit_is_retried_not_aborted(self, capsys): 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 = "Sign in to corporate VPN" + + 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""" diff --git a/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py b/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py index 0a2cbb479f3e..92d1b870d757 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py +++ b/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py @@ -2208,12 +2208,13 @@ def test_cli_sso_flow_lookup_tells_legacy_clients_to_upgrade(self): 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" + 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 expired_exc.value.detail == "Invalid CLI login session" + 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):