diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py
index d9763f72741..c7adae2ce8d 100644
--- a/litellm/proxy/management_endpoints/team_endpoints.py
+++ b/litellm/proxy/management_endpoints/team_endpoints.py
@@ -6109,13 +6109,52 @@ async def list_team_v2(
"total_pages": 0,
}
- # Build order_by conditions
- valid_sort_columns: Final = ["team_id", "team_alias", "created_at"]
- order_by = None
- if sort_by and sort_by in valid_sort_columns:
- if sort_order.lower() not in ["asc", "desc"]:
- sort_order = "asc"
- order_by = {sort_by: sort_order.lower()}
+ from litellm.proxy.management_helpers.team_list_sorting import (
+ COMPUTED_TEAM_SORTS,
+ UnsupportedTeamFilter,
+ sorted_team_ids,
+ )
+
+ valid_sort_columns: Final = (
+ "team_id",
+ "team_alias",
+ "created_at",
+ "spend",
+ "max_budget",
+ "tpm_limit",
+ "rpm_limit",
+ "updated_at",
+ )
+ sort_direction: Final = "desc" if str(sort_order).lower() == "desc" else "asc"
+ nullable_sort: Final = sort_by in ("max_budget", "tpm_limit", "rpm_limit")
+ order_by: Final = (
+ (
+ {sort_by: {"sort": sort_direction, "nulls": "first" if sort_direction == "desc" else "last"}},
+ {"team_id": "asc"},
+ )
+ if nullable_sort
+ else ({sort_by: sort_direction}, {"team_id": "asc"})
+ if sort_by in ("spend", "updated_at")
+ else {sort_by: sort_direction}
+ if sort_by in valid_sort_columns
+ else None
+ )
+ computed_sort: Final = not use_deleted_table and sort_by in COMPUTED_TEAM_SORTS
+ sorted_ids: Final = (
+ await sorted_team_ids(
+ prisma_client,
+ where=where_conditions,
+ sort_by=sort_by or "",
+ descending=sort_direction == "desc",
+ skip=skip,
+ take=page_size,
+ )
+ if computed_sort
+ else ()
+ )
+
+ if isinstance(sorted_ids, UnsupportedTeamFilter):
+ raise HTTPException(status_code=500, detail="Unsupported team sorting filter")
# Get teams with pagination
if use_deleted_table:
@@ -6130,26 +6169,32 @@ async def list_team_v2(
total_count = await _deleted_team_db(prisma_client).count(where=where_conditions)
else:
teams = await _team_db(prisma_client).find_many(
- where=where_conditions,
- skip=skip,
+ # bounded-ok: sorted_ids comes from LIMIT page_size, capped at 100 by the route
+ where={"AND": (where_conditions, {"team_id": {"in": sorted_ids}})} if computed_sort else where_conditions,
+ skip=0 if computed_sort else skip,
take=page_size,
- order=order_by if order_by else {"created_at": "desc"}, # Default sort
+ order=order_by if order_by else {"created_at": "desc"},
include=_INCLUDE_MODEL_TABLE,
)
# Get total count for pagination
total_count = await _team_db(prisma_client).count(where=where_conditions)
+ positions: Final = {team_id: index for index, team_id in enumerate(sorted_ids)}
+ sorted_teams: Final = sorted(teams, key=lambda team: positions[team.team_id]) if computed_sort else teams
+
# Calculate total pages
total_pages: Final = -(-total_count // page_size) # Ceiling division
# Aggregate virtual-key counts per team for the current page. The deleted
# table does not carry keys_count, so it is skipped.
- keys_count_by_team: dict[str, int] = {}
- if not use_deleted_table:
- keys_count_by_team = await _get_keys_count_by_team(prisma_client, teams)
+ keys_count_by_team: Final = (
+ await _get_keys_count_by_team(prisma_client, sorted_teams) if not use_deleted_table else {}
+ )
# Convert Prisma models to response models with members_count and keys_count
- team_list = _convert_teams_to_response_models(teams, use_deleted_table, keys_count_by_team=keys_count_by_team)
+ team_list: Final = _convert_teams_to_response_models(
+ sorted_teams, use_deleted_table, keys_count_by_team=keys_count_by_team
+ )
# Resolve resources inherited from access groups (single batch query)
if not use_deleted_table:
diff --git a/litellm/proxy/management_helpers/team_list_sorting.py b/litellm/proxy/management_helpers/team_list_sorting.py
new file mode 100644
index 00000000000..caddd8afc08
--- /dev/null
+++ b/litellm/proxy/management_helpers/team_list_sorting.py
@@ -0,0 +1,109 @@
+from collections.abc import Mapping, Sequence
+from dataclasses import dataclass
+from itertools import chain
+from types import MappingProxyType
+from typing import Final
+
+from pydantic import BaseModel, ConfigDict, JsonValue, TypeAdapter
+
+from litellm.proxy.utils import PrismaClient
+
+COMPUTED_TEAM_SORTS: Final = MappingProxyType(
+ {
+ "organization_alias": "COALESCE((SELECT NULLIF(o.organization_alias, '') "
+ 'FROM "LiteLLM_OrganizationTable" o WHERE o.organization_id = t.organization_id), '
+ "NULLIF(t.organization_id, ''))",
+ "members": "CASE WHEN jsonb_typeof(t.members_with_roles) = 'array' "
+ "THEN jsonb_array_length(t.members_with_roles) ELSE 0 END",
+ "models": "cardinality(t.models)",
+ "keys_count": '(SELECT COUNT(*) FROM "LiteLLM_VerificationToken" k WHERE k.team_id = t.team_id)',
+ }
+)
+_WHERE: Final = TypeAdapter(dict[str, JsonValue])
+
+
+@dataclass(frozen=True, slots=True)
+class UnsupportedTeamFilter:
+ field: str
+
+
+def _field_sql(field: str, value: JsonValue, index: int) -> tuple[str, tuple[object, ...]] | UnsupportedTeamFilter:
+ if field not in ("team_id", "team_alias", "organization_id"):
+ return UnsupportedTeamFilter(field)
+ column: Final = f't."{field}"'
+ match value:
+ case str(text):
+ return f"{column} = ${index}", (text,)
+ case {"in": list(values)}:
+ return f"{column} = ANY(${index}::text[])", (values,)
+ case {"contains": str(text), "mode": "insensitive"}:
+ return f"{column} ILIKE ${index}", (f"%{text}%",)
+ case {"startsWith": str(text)}:
+ return f"{column} LIKE ${index}", (f"{text}%",)
+ case _:
+ return UnsupportedTeamFilter(field)
+
+
+def _group_tokens(
+ field: str, value: Sequence[JsonValue] | Mapping[str, JsonValue]
+) -> tuple[str | tuple[str, JsonValue], ...]:
+ children: Final = tuple(value.items()) if isinstance(value, Mapping) else tuple(("AND", item) for item in value)
+ if not children:
+ return ("FALSE" if field == "OR" else "TRUE",)
+ return (
+ "(",
+ *chain.from_iterable(((f" {field} " if index else ""), child) for index, child in enumerate(children)),
+ ")",
+ )
+
+
+def _where_sql(where: Mapping[str, JsonValue]) -> tuple[str, tuple[object, ...]] | UnsupportedTeamFilter:
+ pending: Final[list[str | tuple[str, JsonValue]]] = [("AND", dict(where))] # mutable-ok: iterative tree traversal
+ fragments: Final[list[str]] = [] # mutable-ok: append-only SQL output avoids quadratic tuple copying
+ params: Final[list[object]] = [] # mutable-ok: append-only bindings follow traversal order
+ while pending:
+ match pending.pop():
+ case str(fragment):
+ fragments.append(fragment)
+ case (field, value) if field in ("AND", "OR") and isinstance(value, (list, dict)):
+ pending.extend(reversed(_group_tokens(field, value)))
+ case (field, value):
+ match _field_sql(field, value, len(params) + 1):
+ case UnsupportedTeamFilter() as failure:
+ return failure
+ case (fragment, values):
+ fragments.append(fragment)
+ params.extend(values)
+ return "".join(fragments), tuple(params)
+
+
+class _TeamId(BaseModel):
+ model_config = ConfigDict(frozen=True)
+ team_id: str
+
+
+_TEAM_IDS: Final = TypeAdapter(tuple[_TeamId, ...])
+
+
+async def sorted_team_ids(
+ prisma_client: PrismaClient,
+ *,
+ where: Mapping[str, object],
+ sort_by: str,
+ descending: bool,
+ skip: int,
+ take: int,
+) -> Sequence[str] | UnsupportedTeamFilter:
+ result: Final = _where_sql(_WHERE.validate_python(where))
+ if isinstance(result, UnsupportedTeamFilter):
+ return result
+ predicate, params = result
+ expression: Final = COMPUTED_TEAM_SORTS[sort_by]
+ direction: Final = "DESC" if descending else "ASC"
+ sql: Final = (
+ f'SELECT t.team_id FROM "LiteLLM_TeamTable" t WHERE {predicate} '
+ f"ORDER BY {expression} {direction} NULLS LAST, t.team_id ASC "
+ f"LIMIT ${len(params) + 1} OFFSET ${len(params) + 2}"
+ )
+ rows: Final = _TEAM_IDS.validate_python(await prisma_client.db.query_raw(sql, *params, take, skip))
+ return tuple(row.team_id for row in rows)
diff --git a/tests/integration/management/test_team_list_sorting.py b/tests/integration/management/test_team_list_sorting.py
new file mode 100644
index 00000000000..c09e51f0dfe
--- /dev/null
+++ b/tests/integration/management/test_team_list_sorting.py
@@ -0,0 +1,244 @@
+import json
+from itertools import chain
+from typing import Final
+from uuid import uuid4
+
+import pytest
+
+from tests.integration._support.client import Gateway, Scenario
+from tests.integration._support.database import write_rows
+
+
+@pytest.mark.parametrize(
+ "field", ("spend", "max_budget", "tpm_limit", "rpm_limit", "updated_at", "members", "models", "keys_count")
+)
+@pytest.mark.parametrize("direction", ("asc", "desc"))
+def test_team_list_sorts_across_pages_and_respects_membership(
+ field: str,
+ direction: str,
+ gateway: Gateway,
+ scenario: Scenario,
+) -> None:
+ prefix: Final = f"sort-{uuid4().hex}"
+ owner: Final = scenario.user(user_role="internal_user")
+ caller: Final = scenario.key(user_id=owner)
+ values: Final = (20, 3, 3, 100, 0)
+ teams: Final = tuple(
+ scenario.team(
+ team_id=f"{prefix}-{index}",
+ team_alias=f"{prefix}-{index}",
+ members_with_roles=[{"user_id": owner, "role": "admin"}],
+ )
+ for index in range(len(values))
+ )
+ outsider: Final = scenario.team(team_alias=f"{prefix}-outside")
+ for team_id, value in zip(teams, values, strict=True):
+ write_rows(
+ 'UPDATE "LiteLLM_TeamTable" SET spend = %s::float, max_budget = %s::float, '
+ "tpm_limit = %s::bigint, rpm_limit = %s::bigint, "
+ "updated_at = timestamp '2030-01-01' + %s::int * interval '1 day', "
+ "members_with_roles = %s::jsonb, models = ARRAY(SELECT generate_series(1, %s::int)::text) "
+ "WHERE team_id = %s",
+ (
+ str(value),
+ str(value),
+ str(value),
+ str(value),
+ str(value),
+ json.dumps(
+ [{"user_id": owner if i == 0 else f"{prefix}-member-{i}", "role": "admin"} for i in range(value)]
+ ),
+ str(value),
+ team_id,
+ ),
+ )
+ write_rows(
+ 'INSERT INTO "LiteLLM_VerificationToken" (token, team_id) '
+ "SELECT %s || '-key-' || n::text, %s FROM generate_series(1, %s::int) n",
+ (team_id, team_id, str(value)),
+ )
+ write_rows(
+ 'UPDATE "LiteLLM_TeamTable" SET max_budget = NULL, tpm_limit = NULL, rpm_limit = NULL WHERE team_id = %s',
+ (teams[-1],),
+ )
+ try:
+ expected: Final = tuple(
+ team_id
+ for value, team_id in sorted(
+ zip(values, teams, strict=True),
+ key=lambda item: (
+ (item[0] != 0 if direction == "desc" else item[0] == 0)
+ if field in ("max_budget", "tpm_limit", "rpm_limit")
+ else False,
+ item[0] * (1 if direction == "asc" else -1),
+ item[1],
+ ),
+ )
+ )
+ pages: Final = tuple(
+ gateway.request(
+ "GET",
+ "/v2/team/list",
+ key=caller,
+ params={
+ "search": prefix,
+ "user_id": owner,
+ "sort_by": field,
+ "sort_order": direction,
+ "page_size": "2",
+ "page": str(page),
+ },
+ )
+ for page in (1, 2, 3)
+ )
+ assert all(page.status_code == 200 for page in pages), [page.text for page in pages]
+ returned: Final = tuple(row["team_id"] for row in chain.from_iterable(page.json()["teams"] for page in pages))
+ assert returned == expected
+ assert outsider not in returned
+ assert all(page.json()["total"] == len(teams) for page in pages)
+ finally:
+ for team_id in teams:
+ write_rows('DELETE FROM "LiteLLM_VerificationToken" WHERE team_id = %s', (team_id,))
+
+
+@pytest.mark.parametrize("direction", ("asc", "desc"))
+def test_team_list_sorts_organization_names_and_missing_values(
+ direction: str, gateway: Gateway, scenario: Scenario
+) -> None:
+ prefix: Final = f"sort-org-{uuid4().hex}"
+ budget: Final = scenario.budget(max_budget=10)
+ organizations: Final = ((f"{prefix}-z", "alpha"), (f"{prefix}-a", "zulu"), (f"{prefix}-m", ""))
+ teams: Final = tuple(scenario.team(team_id=f"{prefix}-{i}", team_alias=f"{prefix}-{i}") for i in range(4))
+ try:
+ for (organization_id, alias), team_id in zip(organizations, teams):
+ write_rows(
+ 'INSERT INTO "LiteLLM_OrganizationTable" '
+ "(organization_id, organization_alias, budget_id, created_by, updated_by, updated_at) "
+ "VALUES (%s, %s, %s, 'sorting-test', 'sorting-test', NOW())",
+ (organization_id, alias, budget),
+ )
+ write_rows(
+ 'UPDATE "LiteLLM_TeamTable" SET organization_id = %s WHERE team_id = %s',
+ (organization_id, team_id),
+ )
+ ascending: Final = (teams[0], teams[2], teams[1])
+ ordered: Final = ascending if direction == "asc" else tuple(reversed(ascending))
+ pages: Final = tuple(
+ gateway.request(
+ "GET",
+ "/v2/team/list",
+ params={
+ "search": prefix,
+ "sort_by": "organization_alias",
+ "sort_order": direction,
+ "page_size": "2",
+ "page": str(page),
+ },
+ )
+ for page in (1, 2)
+ )
+ assert all(page.status_code == 200 for page in pages), [page.text for page in pages]
+ assert tuple(row["team_id"] for row in chain.from_iterable(page.json()["teams"] for page in pages)) == (*ordered, teams[-1])
+ finally:
+ for team_id in teams:
+ write_rows('UPDATE "LiteLLM_TeamTable" SET organization_id = NULL WHERE team_id = %s', (team_id,))
+ for organization_id, _ in organizations:
+ write_rows('DELETE FROM "LiteLLM_OrganizationTable" WHERE organization_id = %s', (organization_id,))
+
+
+@pytest.mark.parametrize("field", ("organization_alias", "members", "models", "keys_count"))
+@pytest.mark.parametrize("direction", ("asc", "desc"))
+def test_computed_team_sorts_preserve_org_and_membership_union(
+ field: str, direction: str, gateway: Gateway, scenario: Scenario
+) -> None:
+ prefix: Final = f"sort-org-scope-{uuid4().hex}"
+ owner: Final = scenario.user(user_role="internal_user")
+ budget: Final = scenario.budget(max_budget=10)
+ orgs: Final = (f"{prefix}-allowed", f"{prefix}-outside")
+ teams: Final = tuple(
+ scenario.team(
+ team_id=f"{prefix}-{i}",
+ team_alias=f"{prefix}-{i}",
+ members_with_roles=[{"user_id": owner, "role": "admin"}] if i == 2 else [],
+ )
+ for i in range(4)
+ )
+ try:
+ for org, alias in zip(orgs, ("alpha", "zulu"), strict=True):
+ write_rows(
+ 'INSERT INTO "LiteLLM_OrganizationTable" '
+ "(organization_id, organization_alias, budget_id, created_by, updated_by, updated_at) "
+ "VALUES (%s, %s, %s, 'sorting-test', 'sorting-test', NOW())",
+ (org, alias, budget),
+ )
+ write_rows(
+ 'INSERT INTO "LiteLLM_OrganizationMembership" (user_id, organization_id, user_role) '
+ "VALUES (%s, %s, 'org_admin')",
+ (owner, orgs[0]),
+ )
+ for index, team in enumerate(teams):
+ write_rows(
+ 'UPDATE "LiteLLM_TeamTable" SET organization_id = %s, members_with_roles = %s::jsonb, '
+ "models = ARRAY(SELECT generate_series(1, %s::int)::text) WHERE team_id = %s",
+ (
+ orgs[0] if index < 2 else orgs[1],
+ json.dumps(
+ [
+ {"user_id": owner if i == 0 else f"{prefix}-member-{i}", "role": "admin"}
+ for i in range(index + 1)
+ ]
+ ),
+ str(index + 1),
+ team,
+ ),
+ )
+ write_rows(
+ 'INSERT INTO "LiteLLM_VerificationToken" (token, team_id) '
+ "SELECT %s || '-key-' || n::text, %s FROM generate_series(1, %s::int) n",
+ (team, team, str(index + 1)),
+ )
+ caller: Final = scenario.key(user_id=owner)
+ values: Final = (0, 0, 1) if field == "organization_alias" else (1, 2, 3)
+ expected: Final = tuple(
+ team
+ for value, team in sorted(
+ zip(values, teams[:3], strict=True),
+ key=lambda item: (item[0] * (-1 if direction == "desc" else 1), item[1]),
+ )
+ )
+ pages: Final = tuple(
+ gateway.request(
+ "GET",
+ "/v2/team/list",
+ key=caller,
+ params={
+ "user_id": owner,
+ "search": prefix,
+ "sort_by": field,
+ "sort_order": direction,
+ "page_size": "2",
+ "page": str(page),
+ },
+ )
+ for page in (1, 2)
+ )
+ assert all(page.status_code == 200 for page in pages), [page.text for page in pages]
+ assert all(page.json()["total"] == 3 for page in pages)
+ assert tuple(row["team_id"] for row in chain.from_iterable(page.json()["teams"] for page in pages)) == expected
+ denied: Final = gateway.request(
+ "GET",
+ "/v2/team/list",
+ key=caller,
+ params={
+ "organization_id": orgs[1],
+ "sort_by": field,
+ },
+ )
+ assert denied.status_code == 403, denied.text
+ finally:
+ for team in teams:
+ write_rows('DELETE FROM "LiteLLM_VerificationToken" WHERE team_id = %s', (team,))
+ write_rows('UPDATE "LiteLLM_TeamTable" SET organization_id = NULL WHERE team_id = %s', (team,))
+ for org in orgs:
+ write_rows('DELETE FROM "LiteLLM_OrganizationMembership" WHERE organization_id = %s', (org,))
+ write_rows('DELETE FROM "LiteLLM_OrganizationTable" WHERE organization_id = %s', (org,))
diff --git a/tests/unit/proxy/management_helpers/test_team_list_sorting.py b/tests/unit/proxy/management_helpers/test_team_list_sorting.py
new file mode 100644
index 00000000000..bc4c77cc6a4
--- /dev/null
+++ b/tests/unit/proxy/management_helpers/test_team_list_sorting.py
@@ -0,0 +1,85 @@
+import json
+import re
+import sqlite3
+from typing import Final
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+from pydantic import JsonValue
+
+from litellm.proxy.management_helpers.team_list_sorting import UnsupportedTeamFilter, _where_sql, sorted_team_ids
+
+
+@pytest.mark.parametrize(
+ ("where", "expected"),
+ (
+ ({}, ("a", "b", "c")),
+ ({"OR": []}, ()),
+ ({"AND": []}, ("a", "b", "c")),
+ ({"team_id": "a"}, ("a",)),
+ ({"team_alias": {"contains": "ALPHA", "mode": "insensitive"}}, ("a", "c")),
+ ({"team_id": {"startsWith": "b"}}, ("b",)),
+ ({"organization_id": {"in": ["org-1"]}}, ("a", "b")),
+ (
+ {
+ "OR": [{"team_id": "b"}, {"team_alias": {"contains": "alpha", "mode": "insensitive"}}],
+ "AND": [{"OR": [{"organization_id": "org-1"}, {"team_id": "c"}]}],
+ "team_id": {"in": ["a", "c"]},
+ },
+ ("a", "c"),
+ ),
+ ({"team_id": "a' OR 1=1 --"}, ()),
+ ),
+)
+def test_team_filters_preserve_search_and_authorization_intersection(
+ where: dict[str, JsonValue], expected: tuple[str, ...]
+) -> None:
+ result: Final = _where_sql(where)
+ assert not isinstance(result, UnsupportedTeamFilter)
+ predicate, params = result
+ sqlite_predicate: Final = re.sub(
+ r"= ANY\((\$\d+)::text\[\]\)", r"IN (SELECT value FROM json_each(\1))", predicate
+ ).replace(" ILIKE ", " LIKE ")
+ with sqlite3.connect(":memory:") as db:
+ db.execute("CREATE TABLE teams (team_id TEXT, team_alias TEXT, organization_id TEXT)")
+ db.executemany(
+ "INSERT INTO teams VALUES (?, ?, ?)",
+ (("a", "Alpha", "org-1"), ("b", "Beta", "org-1"), ("c", "Alpha", "org-2")),
+ )
+ rows: Final = db.execute(
+ f"SELECT t.team_id FROM teams t WHERE {sqlite_predicate} ORDER BY t.team_id",
+ {
+ str(index): json.dumps(value) if isinstance(value, list) else value
+ for index, value in enumerate(params, 1)
+ },
+ ).fetchall()
+ assert tuple(row[0] for row in rows) == expected
+
+
+@pytest.mark.parametrize("where", ({"unknown": "x"}, {"team_id": {"not": "x"}}, {"AND": [False]}))
+@pytest.mark.asyncio
+async def test_unsupported_filter_fails_without_reading_unscoped_teams(where: dict[str, JsonValue]) -> None:
+ prisma: Final = MagicMock()
+ prisma.db.query_raw = AsyncMock()
+ result: Final = await sorted_team_ids(prisma, where=where, sort_by="keys_count", descending=False, skip=0, take=25)
+ assert isinstance(result, UnsupportedTeamFilter)
+ prisma.db.query_raw.assert_not_awaited()
+
+
+@pytest.mark.parametrize("sort_by", ("keys_count", "members", "models", "organization_alias"))
+@pytest.mark.parametrize("descending", (False, True))
+@pytest.mark.asyncio
+async def test_sorted_ids_bind_search_and_pagination_without_interpolating_input(
+ sort_by: str, descending: bool
+) -> None:
+ prisma: Final = MagicMock()
+ prisma.db.query_raw = AsyncMock(return_value=[])
+ search: Final = "x' OR TRUE --"
+ result: Final = await sorted_team_ids(
+ prisma, where={"team_id": search}, sort_by=sort_by, descending=descending, skip=25, take=25
+ )
+ assert result == ()
+ sql, *bindings = prisma.db.query_raw.await_args.args
+ assert search not in sql
+ assert bindings == [search, 25, 25]
+ assert ("DESC" if descending else "ASC") + " NULLS LAST, t.team_id ASC" in sql
diff --git a/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.test.tsx b/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.integration.test.tsx
similarity index 92%
rename from ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.test.tsx
rename to ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.integration.test.tsx
index ad89b62d611..def6750071f 100644
--- a/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.test.tsx
+++ b/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.integration.test.tsx
@@ -163,16 +163,26 @@ describe("sort contract – only backend-sortable columns are sortable", () => {
it("right-aligns Spend / Budget but not Team", () => {
renderTable();
- expect(screen.getByRole("columnheader", { name: "Spend / Budget" })).toHaveClass("text-right");
+ expect(screen.getByRole("columnheader", { name: /Spend\s*\/\s*Budget/ })).toHaveClass("text-right");
expect(screen.getByRole("columnheader", { name: "Team" })).not.toHaveClass("text-right");
});
- it("does not make Spend / Budget sortable (the backend rejects sort_by=spend)", () => {
+ it.each([
+ ["Spend or Budget", "Spend", "spend"],
+ ["Spend or Budget", "Budget", "max_budget"],
+ ["Members or Models or Keys", "Members", "members"],
+ ["Members or Models or Keys", "Models", "models"],
+ ["Members or Models or Keys", "Keys", "keys_count"],
+ ])("sorts %s by %s through the API", async (menu, label, field) => {
+ const user = userEvent.setup();
renderTable();
- expect(screen.queryByText("Spend / Budget").closest("button")).toBeNull();
- // Team and Created are the only sortable headers.
- expect(screen.getByText("Team").closest("button")).not.toBeNull();
- expect(screen.getByText("Created").closest("button")).not.toBeNull();
+ await user.click(screen.getByRole("button", { name: `Sort options for ${menu}` }));
+ await user.click(await screen.findByRole("menuitem", { name: `${label} descending` }));
+ await waitFor(() => expect(lastOptions()).toMatchObject({ sortBy: field, sortOrder: "desc" }));
+ expect(mockUseTeamsTable.mock.calls.at(-1)?.[0]).toBe(1);
+ await user.click(screen.getByRole("button", { name: `Sort options for ${menu}` }));
+ await user.click(await screen.findByRole("menuitem", { name: `${label} ascending` }));
+ await waitFor(() => expect(lastOptions()).toMatchObject({ sortBy: field, sortOrder: "asc" }));
});
});
diff --git a/ui/litellm-dashboard/src/components/TeamsPage/teamTableColumns.tsx b/ui/litellm-dashboard/src/components/TeamsPage/teamTableColumns.tsx
index 05bfccf6417..e56e0d16522 100644
--- a/ui/litellm-dashboard/src/components/TeamsPage/teamTableColumns.tsx
+++ b/ui/litellm-dashboard/src/components/TeamsPage/teamTableColumns.tsx
@@ -3,7 +3,7 @@
import { ColumnDef } from "@tanstack/react-table";
import { Copy, KeyRound, Layers, MoreHorizontal, Pencil, Trash2, Users } from "lucide-react";
-import { DataTableSortHeader } from "@/components/shared/DataTable";
+import { DataTableMultiSortHeader, DataTableSortHeader } from "@/components/shared/DataTable";
import { DateCell, IdentityCell, SpendBudgetCell } from "@/components/shared/table_cells";
import { buttonVariants } from "@/components/ui/button";
import {
@@ -174,9 +174,10 @@ export const getTeamTableColumns = ({
id: "organization_alias",
accessorKey: "organization_id",
meta: { title: "Organization" },
- header: "Organization",
+ header: ({ column }) => ,
+ sortDescFirst: false,
size: 160,
- enableSorting: false,
+ enableSorting: true,
cell: (info) => {
const orgId = info.getValue() as string | null;
if (!orgId) return —;
@@ -202,7 +203,16 @@ export const getTeamTableColumns = ({
),
},
- header: "Resources",
+ header: ({ table }) => (
+
+ ),
size: 210,
enableSorting: false,
cell: ({ row }) => ,
@@ -211,7 +221,15 @@ export const getTeamTableColumns = ({
id: "spend",
accessorKey: "spend",
meta: { title: "Spend / Budget", skeleton: "meter", numeric: true },
- header: "Spend / Budget",
+ header: ({ table }) => (
+
+ ),
size: 200,
enableSorting: false,
cell: ({ row }) => (
@@ -234,24 +252,36 @@ export const getTeamTableColumns = ({
},
{
id: "members",
+ accessorFn: teamMemberCount,
meta: { title: "Members", numeric: true },
- header: "Members",
+ header: ({ column }) => ,
+ sortDescFirst: true,
size: 110,
- enableSorting: false,
+ enableSorting: true,
cell: ({ row }) => {teamMemberCount(row.original)},
},
{
id: "models",
+ accessorFn: teamModelCount,
meta: { title: "Models", numeric: true },
- header: "Models",
+ header: ({ column }) => ,
+ sortDescFirst: true,
size: 100,
- enableSorting: false,
+ enableSorting: true,
cell: ({ row }) => {teamModelCount(row.original)},
},
{
id: "rate_limits",
meta: { title: "Rate Limits", skeleton: "twoLine" },
- header: "Rate Limits",
+ header: ({ table }) => (
+
+ ),
size: 140,
enableSorting: false,
cell: ({ row }) => (
@@ -265,9 +295,10 @@ export const getTeamTableColumns = ({
id: "updated_at",
accessorKey: "updated_at",
meta: { title: "Updated" },
- header: "Updated",
+ header: ({ column }) => ,
+ sortDescFirst: false,
size: 130,
- enableSorting: false,
+ enableSorting: true,
cell: (info) => ,
},
{