diff --git a/litellm/proxy/management_endpoints/ui_sso.py b/litellm/proxy/management_endpoints/ui_sso.py index 1ebcb53fd6b..0d6cf71fc7e 100644 --- a/litellm/proxy/management_endpoints/ui_sso.py +++ b/litellm/proxy/management_endpoints/ui_sso.py @@ -2203,6 +2203,18 @@ def _cli_sso_team_detail(team_row: Mapping[str, object]) -> CliSsoTeamDetail: ) +def _cli_sso_team_sort_key(detail: CliSsoTeamDetail) -> tuple[str, str]: + """Stable, predictable order for a user's team choices. + + The DB returns rows in an arbitrary order, so the consent page (and the + classic login team choice, which shares this lookup) would preselect + whichever team happened to come back first -- silently binding a user who + takes the default to a team they did not mean to pick. Order by alias + case-insensitively, with team_id as a deterministic tiebreaker. + """ + return ((detail.team_alias or "").casefold(), detail.team_id or "") + + async def fetch_cli_sso_team_details( prisma_client: PrismaClient, teams: Sequence[str], @@ -2218,7 +2230,10 @@ async def fetch_cli_sso_team_details( except Exception as e: verbose_proxy_logger.error("Error fetching team details for CLI SSO session: %s", e) return None - return tuple(_cli_sso_team_detail(team_row.model_dump()) for team_row in prisma_teams) + details: Final = ( + _cli_sso_team_detail(team_row.model_dump()) for team_row in prisma_teams + ) + return tuple(sorted(details, key=_cli_sso_team_sort_key)) def _cli_sso_session_teams(team_details: Sequence[CliSsoTeamDetail]) -> list[str]: 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 80b7e25e07d..67c7c2f9698 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py +++ b/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py @@ -8344,3 +8344,50 @@ class TestPersistReturnToCookieSharedHelper: resp = Response() _persist_return_to_cookie(resp, "https://cp.example.com/ui?page=models") assert "litellm_cp_return_to=" in self._cookie(resp) + + +@pytest.mark.asyncio +async def test_fetch_cli_sso_team_details_orders_by_alias_then_id(): + """CLI SSO team choices must come back in a stable order. + + The consent page preselects the first team, so an arbitrary DB order can + silently bind a user who takes the default to a team they did not mean to + pick. Teams must be ordered by alias case-insensitively, then team_id as a + deterministic tiebreaker. Regression for the CLI login consent page. + """ + from unittest.mock import AsyncMock, MagicMock, patch + + from litellm.proxy.management_endpoints import ui_sso as ui_sso_mod + + def _row(team_id, alias): + row = MagicMock() + row.model_dump.return_value = { + "team_id": team_id, + "team_alias": alias, + "models": [], + } + return row + + # Deliberately scrambled DB order, mixed case, plus an alias-less team. + rows = [ + _row("35551", "forge-team-b-35551"), + _row("10000", "Forge-Team-A-35551"), + _row("99999", None), + ] + repo = MagicMock() + repo.find_many = AsyncMock(return_value=rows) + + with patch.object(ui_sso_mod, "TeamRepository", return_value=MagicMock()), patch.object( + ui_sso_mod, "_team_detail_db", return_value=repo + ): + result = await ui_sso_mod.fetch_cli_sso_team_details( + prisma_client=MagicMock(), teams=["35551", "10000", "99999"] + ) + + assert result is not None + # None alias sorts first (""), then case-insensitive "Forge-Team-A" < "forge-team-b". + assert [d.team_alias for d in result] == [ + None, + "Forge-Team-A-35551", + "forge-team-b-35551", + ]