diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index b993a5d8d6d..de62a2f5b71 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -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", diff --git a/litellm/proxy/management/teams/access.py b/litellm/proxy/management/teams/access.py index 77af588c636..b80e290e7b3 100644 --- a/litellm/proxy/management/teams/access.py +++ b/litellm/proxy/management/teams/access.py @@ -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 diff --git a/litellm/proxy/management/teams/dependencies.py b/litellm/proxy/management/teams/dependencies.py index d3be5c6791e..c562d5d57ac 100644 --- a/litellm/proxy/management/teams/dependencies.py +++ b/litellm/proxy/management/teams/dependencies.py @@ -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 diff --git a/litellm/proxy/management/teams/endpoints.py b/litellm/proxy/management/teams/endpoints.py new file mode 100644 index 00000000000..ac3fa9b7694 --- /dev/null +++ b/litellm/proxy/management/teams/endpoints.py @@ -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.", + ) + ) diff --git a/litellm/proxy/management/teams/repository.py b/litellm/proxy/management/teams/repository.py new file mode 100644 index 00000000000..aeaae0d8494 --- /dev/null +++ b/litellm/proxy/management/teams/repository.py @@ -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)) diff --git a/litellm/proxy/management/teams/schemas.py b/litellm/proxy/management/teams/schemas.py new file mode 100644 index 00000000000..9ce5c2120b0 --- /dev/null +++ b/litellm/proxy/management/teams/schemas.py @@ -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, ...] diff --git a/litellm/proxy/management/teams/service.py b/litellm/proxy/management/teams/service.py new file mode 100644 index 00000000000..7da7a089a25 --- /dev/null +++ b/litellm/proxy/management/teams/service.py @@ -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", +) diff --git a/litellm/proxy/management_endpoints/management_v1/__init__.py b/litellm/proxy/management_endpoints/management_v1/__init__.py index b29b7fe5dd5..868c9d72d0c 100644 --- a/litellm/proxy/management_endpoints/management_v1/__init__.py +++ b/litellm/proxy/management_endpoints/management_v1/__init__.py @@ -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"] diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index e452afccef9..986a60f8068 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -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), } ) ) diff --git a/tests/proxy_behavior/management/test_team_members_list.py b/tests/proxy_behavior/management/test_team_members_list.py new file mode 100644 index 00000000000..24d15c6b94e --- /dev/null +++ b/tests/proxy_behavior/management/test_team_members_list.py @@ -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" diff --git a/tests/unit/proxy/management/teams/test_access.py b/tests/unit/proxy/management/teams/test_access.py index 019be7afaa5..0b62865d3dd 100644 --- a/tests/unit/proxy/management/teams/test_access.py +++ b/tests/unit/proxy/management/teams/test_access.py @@ -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 diff --git a/tests/unit/proxy/management/teams/test_service.py b/tests/unit/proxy/management/teams/test_service.py new file mode 100644 index 00000000000..ea4bd54d935 --- /dev/null +++ b/tests/unit/proxy/management/teams/test_service.py @@ -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"), + } diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 73336ca1823..7620a2ffc9f 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -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;