feat(ui): sort every teams table data column (#48109)

* feat(ui): sort every teams table data column

* fix(ui): order unlimited team limits as infinity and verify org scope

* fix(ui): validate team sort filters without recursion
This commit is contained in:
moe-berri 2026-10-23 16:21:36 -07:00 • committed by GitHub
parent 91287b49f4
commit 683442eb61
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 556 additions and 32 deletions

View file

@ -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:

View file

@ -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)

View file

@ -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,))

View file

@ -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

View file

@ -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" }));
});
});

View file

@ -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 }) => <DataTableSortHeader column={column} title="Organization" />,
sortDescFirst: false,
size: 160,
enableSorting: false,
enableSorting: true,
cell: (info) => {
const orgId = info.getValue() as string | null;
if (!orgId) return <span className="text-muted-foreground">—</span>;
@ -202,7 +203,16 @@ export const getTeamTableColumns = ({
</div>
),
},
header: "Resources",
header: ({ table }) => (
<DataTableMultiSortHeader
table={table}
fields={[
{ id: "members", label: "Members" },
{ id: "models", label: "Models" },
{ id: "keys_count", label: "Keys" },
]}
/>
),
size: 210,
enableSorting: false,
cell: ({ row }) => <ResourcesCell team={row.original} />,
@ -211,7 +221,15 @@ export const getTeamTableColumns = ({
id: "spend",
accessorKey: "spend",
meta: { title: "Spend / Budget", skeleton: "meter", numeric: true },
header: "Spend / Budget",
header: ({ table }) => (
<DataTableMultiSortHeader
table={table}
fields={[
{ id: "spend", label: "Spend" },
{ id: "max_budget", label: "Budget" },
]}
/>
),
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 }) => <DataTableSortHeader column={column} title="Members" />,
sortDescFirst: true,
size: 110,
enableSorting: false,
enableSorting: true,
cell: ({ row }) => <span className="text-sm tabular-nums">{teamMemberCount(row.original)}</span>,
},
{
id: "models",
accessorFn: teamModelCount,
meta: { title: "Models", numeric: true },
header: "Models",
header: ({ column }) => <DataTableSortHeader column={column} title="Models" />,
sortDescFirst: true,
size: 100,
enableSorting: false,
enableSorting: true,
cell: ({ row }) => <span className="text-sm tabular-nums">{teamModelCount(row.original)}</span>,
},
{
id: "rate_limits",
meta: { title: "Rate Limits", skeleton: "twoLine" },
header: "Rate Limits",
header: ({ table }) => (
<DataTableMultiSortHeader
table={table}
fields={[
{ id: "tpm_limit", label: "TPM" },
{ id: "rpm_limit", label: "RPM" },
]}
/>
),
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 }) => <DataTableSortHeader column={column} title="Updated" />,
sortDescFirst: false,
size: 130,
enableSorting: false,
enableSorting: true,
cell: (info) => <DateCell value={info.getValue() as string | null} precision="date" fallback="Never" />,
},
{