mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-12 03:48:56 +00:00
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:
parent
14913548d1
commit
ff284b7693
13 changed files with 936 additions and 9 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
116
litellm/proxy/management/teams/endpoints.py
Normal file
116
litellm/proxy/management/teams/endpoints.py
Normal 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.",
|
||||
)
|
||||
)
|
||||
99
litellm/proxy/management/teams/repository.py
Normal file
99
litellm/proxy/management/teams/repository.py
Normal 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))
|
||||
31
litellm/proxy/management/teams/schemas.py
Normal file
31
litellm/proxy/management/teams/schemas.py
Normal 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, ...]
|
||||
90
litellm/proxy/management/teams/service.py
Normal file
90
litellm/proxy/management/teams/service.py
Normal 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",
|
||||
)
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
|
|
|||
279
tests/proxy_behavior/management/test_team_members_list.py
Normal file
279
tests/proxy_behavior/management/test_team_members_list.py
Normal 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"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
111
tests/unit/proxy/management/teams/test_service.py
Normal file
111
tests/unit/proxy/management/teams/test_service.py
Normal 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"),
|
||||
}
|
||||
119
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
119
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue