feat(proxy): paginated team members list endpoint

GET /management/v1/teams/{team_id}/members returns one page of a team's roster with each member's spend, limits and budget source, searchable by user_id or email and filterable by role. It admits the same readers as /team/info and pages in the database, so a 1k-member team no longer has to ship its whole roster to render one page
This commit is contained in:
ryan-crabbe-berri 2026-10-05 11:47:50 -07:00
parent 14913548d1
commit ff284b7693
13 changed files with 936 additions and 9 deletions

View file

@ -938,6 +938,7 @@ class LiteLLMRoutes(enum.Enum):
"/team/update",
"/team/member_add",
"/team/member_delete",
"/management/v1/teams/{team_id}/members",
"/management/v1/teams/{team_id}/members/bulk_delete",
"/management/v1/teams/{team_id}/members/bulk_update",
"/team/member_update",

View file

@ -38,6 +38,19 @@ class TeamAccess:
return "org_admin"
return "team_admin" if is_team_admin(caller, team) else None
async def reads_roster(self, caller: UserAPIKeyAuth, team: LiteLLM_TeamTable) -> bool:
"""The readers `/team/info` admits: proxy admins and admin viewers, a key issued to this team,
anyone on the roster, and admins of the team's org."""
if caller.user_role in (LitellmUserRoles.PROXY_ADMIN, LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY):
return True
if caller.team_id == team.team_id:
return True
if caller.user_id is None:
return False
if any(member.user_id == caller.user_id for member in team.members_with_roles):
return True
return await self._is_org_admin(caller, team)
async def _is_org_admin(self, caller: UserAPIKeyAuth, team: LiteLLM_TeamTable) -> bool:
if not caller.user_id or not team.organization_id:
return False

View file

@ -1,10 +1,43 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from litellm.proxy._types import CommonProxyErrors
from litellm.proxy.list_api.common import PROBLEM_TYPE_BASE, ManagementProblem
from litellm.proxy.management.teams.access import TeamAccess
from litellm.proxy.management.users.service import PrismaOrgRoles
from litellm.repositories.team_repository import TeamRepository
from litellm.types.proxy.management_endpoints.management_v1 import ProblemDetail
if TYPE_CHECKING:
from litellm.proxy.management.teams.repository import RawQuery
from litellm.proxy.utils import PrismaClient
def get_team_access() -> TeamAccess:
from litellm.proxy.proxy_server import prisma_client, proxy_logging_obj, user_api_key_cache
return TeamAccess(org_roles=PrismaOrgRoles(prisma_client, user_api_key_cache, proxy_logging_obj))
def _connected_prisma_client() -> PrismaClient:
from litellm.proxy.proxy_server import prisma_client
if prisma_client is None:
raise ManagementProblem(
ProblemDetail(
type=f"{PROBLEM_TYPE_BASE}database-not-connected",
title="Database not connected",
status=503,
detail=CommonProxyErrors.db_not_connected_error.value,
)
)
return prisma_client
def get_teams() -> TeamRepository:
return TeamRepository(_connected_prisma_client())
def get_roster_db() -> RawQuery:
return _connected_prisma_client().db

View file

@ -0,0 +1,116 @@
from typing import Annotated, Final
from fastapi import APIRouter, Depends, Request
from typing_extensions import assert_never
from litellm._logging import verbose_proxy_logger
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.list_api.common import PROBLEM_TYPE_BASE, ManagementProblem
from litellm.proxy.list_api.list_framework import handle_list
from litellm.proxy.management.teams.access import TeamAccess
from litellm.proxy.management.teams.dependencies import get_roster_db, get_team_access, get_teams
from litellm.proxy.management.teams.repository import RawQuery, TeamMemberRows
from litellm.proxy.management.teams.schemas import TeamMemberListItem
from litellm.proxy.management.teams.service import (
TEAM_MEMBERS_LIST_SPEC,
ReadableRoster,
RosterHidden,
TeamNotFound,
Teams,
find_readable_roster,
)
from litellm.proxy.management_endpoints.management_v1.common import MANAGEMENT_V1_PREFIX
from litellm.types.proxy.management_endpoints.management_v1 import ListResponse, ProblemDetail
router: Final = APIRouter(prefix=MANAGEMENT_V1_PREFIX)
def _roster_problem(refusal: TeamNotFound | RosterHidden) -> ManagementProblem:
match refusal:
case TeamNotFound(team_id=team_id):
return ManagementProblem(
ProblemDetail(
type=f"{PROBLEM_TYPE_BASE}team-not-found",
title="Team not found",
status=404,
detail=f"Team id={team_id} does not exist in db",
)
)
case RosterHidden(team_id=team_id):
return ManagementProblem(
ProblemDetail(
type=f"{PROBLEM_TYPE_BASE}forbidden",
title="Forbidden",
status=403,
detail=f"Not allowed to read the members of team={team_id}",
)
)
case _:
assert_never(refusal)
@router.get(
"/teams/{team_id}/members",
tags=["team management"],
dependencies=(Depends(user_api_key_auth),),
response_model=ListResponse[TeamMemberListItem],
)
async def list_team_members(
team_id: str,
request: Request,
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
teams: Annotated[Teams, Depends(get_teams)],
roster_db: Annotated[RawQuery, Depends(get_roster_db)],
team_access: Annotated[TeamAccess, Depends(get_team_access)],
) -> ListResponse[TeamMemberListItem]:
"""
One page of a team's members, with each member's spend in the team and the limits on their budget row.
Readable by whoever can read `/team/info` for the team: proxy admins and admin viewers, a key issued to
the team, any member of the team, and admins of the team's organization. An unknown team is a 404.
Members come in the order they joined unless `sort` names a comma-separated list of `user_alias`,
`user_email`, `user_id`, `role`, `spend`, `total_spend`, `max_budget_in_team` or `budget_reset_at`,
each optionally prefixed with `-` for descending. `q` is a case-insensitive substring match on
`user_id` or `user_email`, and `filter[role]=admin` or `filter[role][in]=admin,user` filters by role.
`page_size` defaults to 50 and is capped at 100. `meta.total_count` counts the members matching `q`
and the filters.
`budget_source` is `custom` when the member has their own budget row, `team_default` when they follow
the team's member budget, and `none` when the team has no member budget.
Example curl:
```
curl --location --globoff 'http://0.0.0.0:4000/management/v1/teams/team-1/members?q=acme&filter[role]=admin&page_size=25' \
--header 'Authorization: Bearer sk-1234'
```
"""
try:
lookup: Final = await find_readable_roster(team_id, user_api_key_dict, teams, team_access)
match lookup:
case ReadableRoster(team_id=readable_team_id):
return await handle_list(
spec=TEAM_MEMBERS_LIST_SPEC,
executor=TeamMemberRows(db=roster_db, team_id=readable_team_id),
request=request,
caller=user_api_key_dict,
)
case TeamNotFound() | RosterHidden():
raise _roster_problem(lookup)
case _:
assert_never(lookup)
except ManagementProblem:
raise
except Exception as e: # noqa: BLE001 # a driver error answers as a problem document, not the OpenAI error shape
verbose_proxy_logger.exception(
"litellm.proxy.management.teams.endpoints.list_team_members(): Exception occured - %s", e
)
raise ManagementProblem(
ProblemDetail(
type=f"{PROBLEM_TYPE_BASE}internal-server-error",
title="Internal server error",
status=500,
detail="Failed to list team members.",
)
)

View file

@ -0,0 +1,99 @@
"""Team roster reads.
`members_with_roles` on the team row is the roster, as it is for `/team/info` and every access check. A
roster entry can have no `LiteLLM_TeamMembership` row, so membership rows only add spend and limits, and the
user row only fills in the email and alias the roster entry lacks.
"""
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime
from typing import Final, Literal, Protocol
from pydantic import BaseModel, TypeAdapter
from litellm.proxy.list_api.list_framework import Predicate, QueryPlan, order_by_sql, where_sql
class RawQuery(Protocol):
async def query_raw(self, query: str, *args: object) -> Sequence[Mapping[str, object]]: ...
class TeamMemberRow(BaseModel):
user_id: str | None
user_email: str | None
user_alias: str | None
role: Literal["admin", "user"]
spend: float
total_spend: float
budget_id: str | None
team_default_budget_id: str | None
max_budget_in_team: float | None
budget_duration: str | None
budget_reset_at: datetime | None
tpm_limit: int | None
rpm_limit: int | None
allowed_models: tuple[str, ...]
class _RowCount(BaseModel):
count: int
_MEMBER_ROWS: Final = TypeAdapter(tuple[TeamMemberRow, ...])
_ROW_COUNTS: Final = TypeAdapter(tuple[_RowCount, ...])
_TEAM_MEMBERS_SQL: Final = """
SELECT
roster.position,
roster.member->>'user_id' AS user_id,
COALESCE(NULLIF(roster.member->>'user_email', ''), u.user_email) AS user_email,
u.user_alias,
roster.member->>'role' AS role,
COALESCE(m.spend, 0) AS spend,
COALESCE(m.total_spend, 0) AS total_spend,
m.budget_id,
team_default.budget_id AS team_default_budget_id,
b.max_budget AS max_budget_in_team,
b.budget_duration,
b.budget_reset_at,
b.tpm_limit,
b.rpm_limit,
COALESCE(b.allowed_models, ARRAY[]::text[]) AS allowed_models
FROM "LiteLLM_TeamTable" t
CROSS JOIN LATERAL jsonb_array_elements(
CASE WHEN jsonb_typeof(t.members_with_roles) = 'array' THEN t.members_with_roles ELSE '[]'::jsonb END
) WITH ORDINALITY AS roster(member, position)
LEFT JOIN "LiteLLM_UserTable" u ON u.user_id = roster.member->>'user_id'
LEFT JOIN "LiteLLM_TeamMembership" m ON m.team_id = t.team_id AND m.user_id = roster.member->>'user_id'
LEFT JOIN "LiteLLM_BudgetTable" b ON b.budget_id = m.budget_id
LEFT JOIN "LiteLLM_BudgetTable" team_default ON team_default.budget_id = t.metadata->>'team_member_budget_id'
WHERE t.team_id = $1
"""
@dataclass(frozen=True, slots=True)
class TeamMemberRows:
"""One team's roster. The team is bound here rather than planned, so no query parameter can widen a
page past that team."""
db: RawQuery
team_id: str
async def count(self, where: tuple[Predicate, ...]) -> int:
clauses, params = where_sql(where, first_index=2)
sql: Final = f"SELECT COUNT(*) AS count FROM ({_TEAM_MEMBERS_SQL}) members" + (
f" WHERE {clauses}" if clauses else ""
)
counted: Final = _ROW_COUNTS.validate_python(await self.db.query_raw(sql, self.team_id, *params))
return counted[0].count if counted else 0
async def find_many(self, plan: QueryPlan) -> Sequence[TeamMemberRow]:
clauses, params = where_sql(plan.where, first_index=2)
sql: Final = (
f"SELECT * FROM ({_TEAM_MEMBERS_SQL}) members"
+ (f" WHERE {clauses}" if clauses else "")
+ f" ORDER BY {order_by_sql(plan.order)}"
+ f" LIMIT ${len(params) + 2} OFFSET ${len(params) + 3}"
)
return _MEMBER_ROWS.validate_python(await self.db.query_raw(sql, self.team_id, *params, plan.take, plan.skip))

View file

@ -0,0 +1,31 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict
from litellm.proxy._types import TeamMemberBudgetSource
class TeamMemberListItem(BaseModel):
"""One roster entry with the member's spend in the team and the budget row their membership points at.
The limits are that row's, exactly as `/team/info` returns it under `team_memberships`, so a member
with no row of their own reads null limits even when `budget_source` is `team_default`.
"""
model_config = ConfigDict(frozen=True)
user_id: str | None
user_email: str | None
user_alias: str | None
role: Literal["admin", "user"]
spend: float
total_spend: float
budget_id: str | None
budget_source: TeamMemberBudgetSource
max_budget_in_team: float | None
budget_duration: str | None
budget_reset_at: datetime | None
tpm_limit: int | None
rpm_limit: int | None
allowed_models: tuple[str, ...]

View file

@ -0,0 +1,90 @@
from __future__ import annotations
from dataclasses import dataclass
from types import MappingProxyType
from typing import Final, Protocol, TypeAlias
from litellm.proxy._types import LiteLLM_TeamTable, TeamMemberBudgetSource, UserAPIKeyAuth
from litellm.proxy.list_api.list_framework import FilterSpec, ListSpec, Scope, ScopeAll
from litellm.proxy.management.teams.access import TeamAccess
from litellm.proxy.management.teams.repository import TeamMemberRow
from litellm.proxy.management.teams.schemas import TeamMemberListItem
class Teams(Protocol):
async def find_by_id(self, team_id: str) -> LiteLLM_TeamTable | None: ...
@dataclass(frozen=True, slots=True)
class TeamNotFound:
team_id: str
@dataclass(frozen=True, slots=True)
class RosterHidden:
team_id: str
@dataclass(frozen=True, slots=True)
class ReadableRoster:
team_id: str
RosterLookup: TypeAlias = TeamNotFound | RosterHidden | ReadableRoster
async def find_readable_roster(
team_id: str, caller: UserAPIKeyAuth, teams: Teams, team_access: TeamAccess
) -> RosterLookup:
team: Final = await teams.find_by_id(team_id)
if team is None:
return TeamNotFound(team_id=team_id)
if not await team_access.reads_roster(caller, team):
return RosterHidden(team_id=team_id)
return ReadableRoster(team_id=team_id)
def member_budget_source(budget_id: str | None, team_default_budget_id: str | None) -> TeamMemberBudgetSource:
if budget_id is not None and budget_id != team_default_budget_id:
return "custom"
return "team_default" if team_default_budget_id is not None else "none"
def _team_member_item(row: TeamMemberRow) -> TeamMemberListItem:
return TeamMemberListItem(
user_id=row.user_id,
user_email=row.user_email,
user_alias=row.user_alias,
role=row.role,
spend=row.spend,
total_spend=row.total_spend,
budget_id=row.budget_id,
budget_source=member_budget_source(row.budget_id, row.team_default_budget_id),
max_budget_in_team=row.max_budget_in_team,
budget_duration=row.budget_duration,
budget_reset_at=row.budget_reset_at,
tpm_limit=row.tpm_limit,
rpm_limit=row.rpm_limit,
allowed_models=row.allowed_models,
)
def _roster_reader_scope(_caller: UserAPIKeyAuth) -> Scope:
"""`find_readable_roster` has already admitted the caller to this one team's roster."""
return ScopeAll()
TEAM_MEMBERS_LIST_SPEC: Final[ListSpec[TeamMemberRow, TeamMemberListItem]] = ListSpec(
resource="team members",
sortable=frozenset(
("user_alias", "user_email", "user_id", "role", "spend", "total_spend", "max_budget_in_team", "budget_reset_at")
),
searchable=frozenset(("user_id", "user_email")),
filters=MappingProxyType({"role": FilterSpec(type=str, ops=frozenset(("eq", "in")))}),
default_sort=(),
default_page_size=50,
max_page_size=100,
scope=_roster_reader_scope,
serialize=_team_member_item,
tiebreaker="position",
)

View file

@ -4,6 +4,7 @@ from typing import Final
from fastapi import APIRouter
from litellm.proxy.management.teams.endpoints import router as team_members_router
from litellm.proxy.management_endpoints.management_v1.budgets import (
router as budgets_router,
)
@ -21,6 +22,7 @@ router: Final = APIRouter()
router.include_router(budgets_router)
router.include_router(spend_logs_router)
router.include_router(teams_router)
router.include_router(team_members_router)
router.include_router(users_router)
__all__ = ["router"]

View file

@ -84,7 +84,6 @@ from litellm.proxy._types import (
TeamInfoResponseObjectTeamTable,
TeamListResponseObject,
TeamMemberAddRequest,
TeamMemberBudgetSource,
TeamMemberDeleteRequest,
TeamMemberResetBudgetResponse,
TeamMemberUpdateRequest,
@ -125,6 +124,7 @@ from litellm.proxy.hooks.model_max_budget_limiter import (
)
from litellm.proxy.management.teams.access import TEAM_OR_ORG_ADMIN, TeamRole, is_team_admin, team_access_denied
from litellm.proxy.management.teams.dependencies import get_team_access
from litellm.proxy.management.teams.service import member_budget_source
from litellm.proxy.management_endpoints.common_daily_activity import (
InvalidDateRange,
parse_canonical_date_range,
@ -4054,12 +4054,6 @@ async def _existing_team_default_budget_id(team: LiteLLM_TeamTable, prisma_clien
return budget_id if row is not None else None
def _member_budget_source(budget_id: str | None, team_default_budget_id: str | None) -> TeamMemberBudgetSource:
if budget_id is not None and budget_id != team_default_budget_id:
return "custom"
return "team_default" if team_default_budget_id is not None else "none"
@router.post(
"/team/{team_id}/member/{user_id}/reset_budget",
tags=["team management"],
@ -4120,7 +4114,7 @@ async def reset_team_member_budget_fn(
user_id=user_id,
budget_id=team_default_budget_id,
previous_budget_id=membership_row.budget_id,
budget_source=_member_budget_source(team_default_budget_id, team_default_budget_id),
budget_source=member_budget_source(team_default_budget_id, team_default_budget_id),
)
@ -4978,7 +4972,7 @@ async def team_info(
MappingProxyType(
{
**tm.model_dump(),
"budget_source": _member_budget_source(tm.budget_id, active_default_budget_id),
"budget_source": member_budget_source(tm.budget_id, active_default_budget_id),
}
)
)

View file

@ -0,0 +1,279 @@
from dataclasses import dataclass
import pytest
from prisma import Json
from .actors import Actor
from .conftest import create_scratch_actor, create_scratch_team, create_scratch_user
from .test_team_info import _SCENARIOS as TEAM_INFO_SCENARIOS
pytestmark = pytest.mark.asyncio(loop_scope="session")
@dataclass(frozen=True)
class Roster:
team_id: str
default_budget_id: str
custom_budget_id: str
user_ids: tuple
async def _budget(prisma, budget_id: str, **limits) -> str:
await prisma.db.litellm_budgettable.create(
data={"budget_id": budget_id, "created_by": "phase4-scratch", "updated_by": "phase4-scratch", **limits}
)
return budget_id
async def _membership(prisma, team_id: str, user_id: str, spend: float, budget_id: str | None) -> None:
data = {"user_id": user_id, "team_id": team_id, "spend": spend, "total_spend": spend * 3}
if budget_id is not None:
data["litellm_budget_table"] = {"connect": {"budget_id": budget_id}}
await prisma.db.litellm_teammembership.create(data=data)
async def _seed_roster(prisma, scratch, world) -> Roster:
"""Seven members in roster order: an admin on a custom budget whose email lives only on the user row, a
member linked to the team default, a member with no membership row, a member whose id holds LIKE
wildcards, and three filler members for paging."""
team_id = scratch.prefix
default_budget_id = await _budget(prisma, scratch.tag("default-budget"), max_budget=10.0)
custom_budget_id = await _budget(
prisma,
scratch.tag("custom-budget"),
max_budget=25.0,
budget_duration="30d",
tpm_limit=100,
rpm_limit=5,
allowed_models=["model-a"],
)
ada = await create_scratch_user(
prisma, scratch.prefix, suffix="ada", user_email=f"{scratch.prefix}-ada@example.com"
)
bea = await create_scratch_user(prisma, scratch.prefix, suffix="bea")
cole = await create_scratch_user(prisma, scratch.prefix, suffix="cole")
wild = await create_scratch_user(prisma, scratch.prefix, suffix="100%_wild")
fillers = tuple([await create_scratch_user(prisma, scratch.prefix, suffix=f"filler-{n}") for n in range(3)])
await create_scratch_team(prisma, team_id, organization_id=world.org_a_id)
roster = [
{"user_id": ada, "role": "admin"},
{"user_id": bea, "user_email": f"{scratch.prefix}-BEA@Example.com", "role": "user"},
{"user_id": cole, "role": "user"},
{"user_id": wild, "role": "user"},
*({"user_id": uid, "role": "user"} for uid in fillers),
]
await prisma.db.litellm_teamtable.update(
where={"team_id": team_id},
data={"members_with_roles": Json(roster), "metadata": Json({"team_member_budget_id": default_budget_id})},
)
await _membership(prisma, team_id, ada, 2.5, custom_budget_id)
await _membership(prisma, team_id, bea, 1.0, default_budget_id)
await _membership(prisma, team_id, wild, 0.5, None)
return Roster(
team_id=team_id,
default_budget_id=default_budget_id,
custom_budget_id=custom_budget_id,
user_ids=(ada, bea, cole, wild, *fillers),
)
def _admin(world) -> dict:
return {"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"}
async def _list(proxy_client, world, team_id: str, params: dict | None = None):
return await proxy_client.get(f"/management/v1/teams/{team_id}/members", params=params, headers=_admin(world))
@pytest.mark.parametrize(
"actor,target,expected_status",
[(a, t, s) for (_id, a, t, s) in TEAM_INFO_SCENARIOS],
ids=[s[0] for s in TEAM_INFO_SCENARIOS],
)
async def test_members_list_admits_exactly_the_team_info_readers(
actor: Actor, target: str, expected_status: int, proxy_client, world
):
team_id = {"alpha": world.team_alpha_id, "beta": world.team_beta_id, "gamma": world.team_gamma_id}[target]
headers = {"Authorization": f"Bearer {world.keys[actor].cleartext}"}
members = await proxy_client.get(f"/management/v1/teams/{team_id}/members", headers=headers)
info = await proxy_client.get(f"/team/info?team_id={team_id}", headers=headers)
assert members.status_code == info.status_code == expected_status, members.text
if expected_status == 403:
assert members.headers["content-type"] == "application/problem+json"
assert members.json()["status"] == 403
async def test_members_list_admits_the_admin_viewer(proxy_client, prisma, scratch, world):
viewer = await create_scratch_actor(prisma, scratch.prefix, user_role="proxy_admin_viewer")
resp = await proxy_client.get(
f"/management/v1/teams/{world.team_beta_id}/members",
headers={"Authorization": f"Bearer {viewer.cleartext}"},
)
assert resp.status_code == 200, resp.text
async def test_members_list_unknown_team_is_a_404_problem(proxy_client, world):
resp = await _list(proxy_client, world, "behavior-pin-no-such-team")
assert resp.status_code == 404, resp.text
assert resp.json()["type"].endswith("team-not-found")
async def test_members_list_agrees_with_team_info(proxy_client, prisma, scratch, world):
roster = await _seed_roster(prisma, scratch, world)
listed = await _list(proxy_client, world, roster.team_id, {"page_size": "100"})
info = await proxy_client.get(f"/team/info?team_id={roster.team_id}", headers=_admin(world))
assert listed.status_code == 200, listed.text
assert info.status_code == 200, info.text
items = listed.json()["data"]
info_members = info.json()["team_info"]["members_with_roles"]
memberships = {tm["user_id"]: tm for tm in info.json()["team_memberships"]}
assert [(i["user_id"], i["user_email"], i["role"]) for i in items] == [
(m["user_id"], m["user_email"], m["role"]) for m in info_members
]
for item in items:
tm = memberships.get(item["user_id"])
if tm is None:
assert item["spend"] == 0 and item["budget_id"] is None, item
assert item["budget_source"] == "team_default", item
continue
budget = tm["litellm_budget_table"] or {}
assert (item["spend"], item["total_spend"], item["budget_id"], item["budget_source"]) == (
tm["spend"],
tm["total_spend"],
tm["budget_id"],
tm["budget_source"],
)
assert (item["max_budget_in_team"], item["tpm_limit"], item["rpm_limit"], item["budget_duration"]) == (
budget.get("max_budget"),
budget.get("tpm_limit"),
budget.get("rpm_limit"),
budget.get("budget_duration"),
)
assert list(item["allowed_models"]) == (budget.get("allowed_models") or [])
async def test_members_list_derives_each_budget_source(proxy_client, prisma, scratch, world):
roster = await _seed_roster(prisma, scratch, world)
resp = await _list(proxy_client, world, roster.team_id)
assert resp.status_code == 200, resp.text
by_id = {i["user_id"]: i for i in resp.json()["data"]}
ada, bea, cole, wild = roster.user_ids[:4]
assert by_id[ada]["budget_source"] == "custom"
assert by_id[ada]["max_budget_in_team"] == 25.0
assert by_id[ada]["allowed_models"] == ["model-a"]
assert by_id[ada]["user_email"] == f"{scratch.prefix}-ada@example.com"
assert by_id[bea]["budget_source"] == "team_default"
assert by_id[bea]["max_budget_in_team"] == 10.0
assert by_id[cole]["budget_source"] == "team_default"
assert by_id[wild]["budget_source"] == "team_default"
@pytest.mark.parametrize(
"metadata",
[pytest.param({}, id="no-team-default"), pytest.param({"team_member_budget_id": "gone"}, id="deleted-default")],
)
async def test_members_list_without_a_live_team_default_reads_none(metadata, proxy_client, prisma, scratch, world):
member = await create_scratch_user(prisma, scratch.prefix, suffix="member")
await create_scratch_team(prisma, scratch.prefix, member_user_ids=[member], metadata=metadata)
resp = await _list(proxy_client, world, scratch.prefix)
assert resp.status_code == 200, resp.text
assert [i["budget_source"] for i in resp.json()["data"]] == ["none"]
async def test_members_list_pages_through_the_roster_in_order(proxy_client, prisma, scratch, world):
roster = await _seed_roster(prisma, scratch, world)
pages = [await _list(proxy_client, world, roster.team_id, {"page": str(n), "page_size": "3"}) for n in (1, 2, 3, 4)]
assert [p.status_code for p in pages] == [200, 200, 200, 200]
assert [len(p.json()["data"]) for p in pages] == [3, 3, 1, 0]
assert {(p.json()["meta"]["total_count"], p.json()["meta"]["total_pages"]) for p in pages} == {(7, 3)}
assert tuple(i["user_id"] for p in pages for i in p.json()["data"]) == roster.user_ids
assert pages[0].json()["links"]["next"] is not None
assert pages[2].json()["links"]["next"] is None
async def test_members_list_caps_page_size(proxy_client, prisma, scratch, world):
roster = await _seed_roster(prisma, scratch, world)
resp = await _list(proxy_client, world, roster.team_id, {"page_size": "1000"})
assert resp.status_code == 200, resp.text
assert resp.json()["meta"]["page_size"] == 100
async def test_members_list_never_reads_another_teams_roster(proxy_client, prisma, scratch, world):
roster = await _seed_roster(prisma, scratch, world)
other = await create_scratch_user(prisma, scratch.prefix, suffix="other")
await create_scratch_team(prisma, scratch.tag("other-team"), member_user_ids=[other])
resp = await _list(proxy_client, world, roster.team_id, {"q": "other"})
assert resp.status_code == 200, resp.text
assert resp.json()["data"] == [] and resp.json()["meta"]["total_count"] == 0
@pytest.mark.parametrize(
"q,expected",
[
pytest.param("BEA@example", ("bea",), id="email-case-insensitive"),
pytest.param("-Cole", ("cole",), id="user-id-case-insensitive"),
pytest.param("ada@", ("ada",), id="email-only-on-the-user-row"),
pytest.param("%_", ("100%_wild",), id="like-wildcards-match-literally"),
pytest.param("filler", ("filler-0", "filler-1", "filler-2"), id="substring"),
],
)
async def test_members_list_search(q, expected, proxy_client, prisma, scratch, world):
roster = await _seed_roster(prisma, scratch, world)
resp = await _list(proxy_client, world, roster.team_id, {"q": q})
assert resp.status_code == 200, resp.text
assert tuple(i["user_id"] for i in resp.json()["data"]) == tuple(scratch.tag(s) for s in expected)
assert resp.json()["meta"]["total_count"] == len(expected)
@pytest.mark.parametrize(
"params,expected_count",
[
pytest.param({"filter[role]": "admin"}, 1, id="eq-admin"),
pytest.param({"filter[role]": "user"}, 6, id="eq-user"),
pytest.param({"filter[role][in]": "admin,user"}, 7, id="in"),
pytest.param({"filter[role]": "user", "q": "filler"}, 3, id="with-search"),
],
)
async def test_members_list_role_filter(params, expected_count, proxy_client, prisma, scratch, world):
roster = await _seed_roster(prisma, scratch, world)
resp = await _list(proxy_client, world, roster.team_id, params)
assert resp.status_code == 200, resp.text
assert resp.json()["meta"]["total_count"] == expected_count
assert len(resp.json()["data"]) == expected_count
assert {i["role"] for i in resp.json()["data"]} <= set(params.get("filter[role]", "admin,user").split(","))
async def test_members_list_sorts_by_spend_then_roster_order(proxy_client, prisma, scratch, world):
roster = await _seed_roster(prisma, scratch, world)
resp = await _list(proxy_client, world, roster.team_id, {"sort": "-spend"})
assert resp.status_code == 200, resp.text
ada, bea, cole, wild, *fillers = roster.user_ids
assert tuple(i["user_id"] for i in resp.json()["data"]) == (ada, bea, wild, cole, *fillers)
@pytest.mark.parametrize(
"params",
[
pytest.param({"team_id": "other"}, id="unknown-param"),
pytest.param({"sort": "budget_id"}, id="unsortable-field"),
pytest.param({"filter[spend]": "1"}, id="unfilterable-field"),
],
)
async def test_members_list_rejects_unsupported_params(params, proxy_client, prisma, scratch, world):
roster = await _seed_roster(prisma, scratch, world)
resp = await _list(proxy_client, world, roster.team_id, params)
assert resp.status_code == 400, resp.text
assert resp.headers["content-type"] == "application/problem+json"

View file

@ -134,3 +134,42 @@ def test_team_access_denied_is_the_403_management_routes_have_always_raised() ->
team_access_denied()
assert denied.value.status_code == 403
assert denied.value.detail == "You do not have access to this team"
def team_key(team_id: str) -> UserAPIKeyAuth:
return UserAPIKeyAuth(user_id=None, team_id=team_id, api_key="sk-team", user_role=LitellmUserRoles.INTERNAL_USER)
@pytest.mark.parametrize(
("who", "expected"),
[
pytest.param(caller("root", LitellmUserRoles.PROXY_ADMIN), True, id="proxy-admin"),
pytest.param(caller("viewer", LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY), True, id="admin-viewer"),
pytest.param(team_key("team-1"), True, id="key-issued-to-the-team"),
pytest.param(caller("admin"), True, id="team-admin"),
pytest.param(caller("member"), True, id="plain-member"),
pytest.param(team_key("team-2"), False, id="key-issued-to-another-team"),
pytest.param(caller(None), False, id="no-user-no-team"),
],
)
async def test_reads_roster_admits_team_info_readers_without_an_org_lookup(who: UserAPIKeyAuth, expected: bool) -> None:
assert await TeamAccess(org_roles=NoOrgLookup()).reads_roster(who, team(ADMIN, MEMBER)) is expected
async def test_reads_roster_never_matches_a_userless_caller_to_an_email_only_member() -> None:
email_only: Final = Member(user_id=None, user_email="invitee@example.com", role="user")
assert await TeamAccess(org_roles=NoOrgLookup()).reads_roster(caller(None), team(email_only)) is False
@pytest.mark.parametrize(
("who", "on_team", "expected"),
[
pytest.param(caller("boss"), team(ADMIN, organization_id="org-1"), True, id="org-admin-of-the-team"),
pytest.param(caller("boss"), team(ADMIN, organization_id="org-2"), False, id="org-admin-elsewhere"),
pytest.param(caller("stranger"), team(ADMIN, organization_id="org-1"), False, id="off-the-roster"),
],
)
async def test_reads_roster_falls_back_to_the_teams_org_admins(
who: UserAPIKeyAuth, on_team: LiteLLM_TeamTable, expected: bool
) -> None:
assert await TeamAccess(org_roles=BOSS_OF_ORG_1).reads_roster(who, on_team) is expected

View file

@ -0,0 +1,111 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Final
import pytest
from litellm.proxy._types import LiteLLM_TeamTable, LitellmUserRoles, Member, TeamMemberBudgetSource, UserAPIKeyAuth
from litellm.proxy.management.teams.access import TeamAccess
from litellm.proxy.management.teams.repository import TeamMemberRow
from litellm.proxy.management.teams.service import (
TEAM_MEMBERS_LIST_SPEC,
ReadableRoster,
RosterHidden,
RosterLookup,
TeamNotFound,
find_readable_roster,
member_budget_source,
)
ROSTER: Final = LiteLLM_TeamTable(
team_id="team-1", organization_id=None, members_with_roles=[Member(user_id="member", role="user")]
)
@dataclass(frozen=True, slots=True)
class OneTeam:
team: LiteLLM_TeamTable
async def find_by_id(self, team_id: str) -> LiteLLM_TeamTable | None:
return self.team if team_id == self.team.team_id else None
class NoOrgAdmins:
async def is_org_admin(self, user_id: str, organization_id: str) -> bool:
return False
def caller(user_id: str) -> UserAPIKeyAuth:
return UserAPIKeyAuth(user_id=user_id, api_key="sk-x", user_role=LitellmUserRoles.INTERNAL_USER)
@pytest.mark.parametrize(
("team_id", "who", "expected"),
[
pytest.param("team-1", caller("member"), ReadableRoster(team_id="team-1"), id="member-reads"),
pytest.param("team-1", caller("stranger"), RosterHidden(team_id="team-1"), id="stranger-is-refused"),
pytest.param("team-2", caller("member"), TeamNotFound(team_id="team-2"), id="unknown-team"),
],
)
async def test_find_readable_roster_reports_why_a_roster_is_unreadable(
team_id: str, who: UserAPIKeyAuth, expected: RosterLookup
) -> None:
lookup: Final = await find_readable_roster(team_id, who, OneTeam(ROSTER), TeamAccess(org_roles=NoOrgAdmins()))
assert lookup == expected
@pytest.mark.parametrize(
("budget_id", "team_default_budget_id", "expected"),
[
pytest.param("own", "default", "custom", id="own-row-beside-a-default"),
pytest.param("own", None, "custom", id="own-row-without-a-default"),
pytest.param("default", "default", "team_default", id="linked-to-the-default-row"),
pytest.param(None, "default", "team_default", id="no-row-follows-the-default"),
pytest.param(None, None, "none", id="no-row-no-default"),
],
)
def test_member_budget_source(
budget_id: str | None, team_default_budget_id: str | None, expected: TeamMemberBudgetSource
) -> None:
assert member_budget_source(budget_id, team_default_budget_id) == expected
def test_list_item_carries_the_member_row_and_derives_its_budget_source() -> None:
reset_at: Final = datetime(2026, 11, 1, tzinfo=timezone.utc)
row: Final = TeamMemberRow(
user_id="u-1",
user_email="u1@example.com",
user_alias="Una",
role="admin",
spend=1.5,
total_spend=7.25,
budget_id="own",
team_default_budget_id="default",
max_budget_in_team=20.0,
budget_duration="30d",
budget_reset_at=reset_at,
tpm_limit=1000,
rpm_limit=10,
allowed_models=("gpt-a", "gpt-b"),
)
item: Final = TEAM_MEMBERS_LIST_SPEC.serialize(row)
assert item.model_dump() == {
"user_id": "u-1",
"user_email": "u1@example.com",
"user_alias": "Una",
"role": "admin",
"spend": 1.5,
"total_spend": 7.25,
"budget_id": "own",
"budget_source": "custom",
"max_budget_in_team": 20.0,
"budget_duration": "30d",
"budget_reset_at": reset_at,
"tpm_limit": 1000,
"rpm_limit": 10,
"allowed_models": ("gpt-a", "gpt-b"),
}

View file

@ -9427,6 +9427,44 @@ export interface paths {
patch?: never;
trace?: never;
};
"/management/v1/teams/{team_id}/members": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* List Team Members
* @description One page of a team's members, with each member's spend in the team and the limits on their budget row.
*
* Readable by whoever can read `/team/info` for the team: proxy admins and admin viewers, a key issued to
* the team, any member of the team, and admins of the team's organization. An unknown team is a 404.
*
* Members come in the order they joined unless `sort` names a comma-separated list of `user_alias`,
* `user_email`, `user_id`, `role`, `spend`, `total_spend`, `max_budget_in_team` or `budget_reset_at`,
* each optionally prefixed with `-` for descending. `q` is a case-insensitive substring match on
* `user_id` or `user_email`, and `filter[role]=admin` or `filter[role][in]=admin,user` filters by role.
* `page_size` defaults to 50 and is capped at 100. `meta.total_count` counts the members matching `q`
* and the filters.
*
* `budget_source` is `custom` when the member has their own budget row, `team_default` when they follow
* the team's member budget, and `none` when the team has no member budget.
*
* Example curl:
* ```
* curl --location --globoff 'http://0.0.0.0:4000/management/v1/teams/team-1/members?q=acme&filter[role]=admin&page_size=25' --header 'Authorization: Bearer sk-1234'
* ```
*/
get: operations["list_team_members_management_v1_teams__team_id__members_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/management/v1/teams/{team_id}/members/bulk_delete": {
parameters: {
query?: never;
@ -33867,6 +33905,13 @@ export interface components {
links: components["schemas"]["ListLinks"];
meta: components["schemas"]["ListMeta"];
};
/** ListResponse[TeamMemberListItem] */
ListResponse_TeamMemberListItem_: {
/** Data */
data: components["schemas"]["TeamMemberListItem"][];
links: components["schemas"]["ListLinks"];
meta: components["schemas"]["ListMeta"];
};
/**
* ListRunsResponse
* @description Response from listing runs
@ -45886,6 +45931,49 @@ export interface components {
/** User Id */
user_id: string;
};
/**
* TeamMemberListItem
* @description One roster entry with the member's spend in the team and the budget row their membership points at.
*
* The limits are that row's, exactly as `/team/info` returns it under `team_memberships`, so a member
* with no row of their own reads null limits even when `budget_source` is `team_default`.
*/
TeamMemberListItem: {
/** Allowed Models */
allowed_models: string[];
/** Budget Duration */
budget_duration: string | null;
/** Budget Id */
budget_id: string | null;
/** Budget Reset At */
budget_reset_at: string | null;
/**
* Budget Source
* @enum {string}
*/
budget_source: "team_default" | "custom" | "none";
/** Max Budget In Team */
max_budget_in_team: number | null;
/**
* Role
* @enum {string}
*/
role: "admin" | "user";
/** Rpm Limit */
rpm_limit: number | null;
/** Spend */
spend: number;
/** Total Spend */
total_spend: number;
/** Tpm Limit */
tpm_limit: number | null;
/** User Alias */
user_alias: string | null;
/** User Email */
user_email: string | null;
/** User Id */
user_id: string | null;
};
/**
* TeamMemberRef
* @description One member, named by exactly one of `user_id` or `user_email`.
@ -62896,6 +62984,37 @@ export interface operations {
};
};
};
list_team_members_management_v1_teams__team_id__members_get: {
parameters: {
query?: never;
header?: never;
path: {
team_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ListResponse_TeamMemberListItem_"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
bulk_delete_team_members_action_management_v1_teams__team_id__members_bulk_delete_post: {
parameters: {
query?: never;