mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-11 03:38:38 +00:00
refactor(team): plan-then-batch bulk member budget writes
Replace per-member inline prisma loops on PATCH /v2/team/{team_id}/members
with a pure write planner and set-oriented apply path. Load memberships and
budgets once, plan disconnect/update/create-and-attach ops without prisma,
then apply in one transaction via create_many plus attach/update. Also
invalidate team membership caches after bulk updates so auth does not keep
stale allowed_models or limits
This commit is contained in:
parent
bf3a058781
commit
81717e3582
14 changed files with 1751 additions and 57 deletions
|
|
@ -600,6 +600,7 @@ class LiteLLMRoutes(enum.Enum):
|
|||
"/team/permissions_list",
|
||||
"/team/permissions_update",
|
||||
"/team/permissions_bulk_update",
|
||||
"/v2/team/{team_id}/members",
|
||||
"/team/daily/activity",
|
||||
# model
|
||||
"/model/new",
|
||||
|
|
@ -741,6 +742,7 @@ class LiteLLMRoutes(enum.Enum):
|
|||
"/team/member_add",
|
||||
"/team/member_delete",
|
||||
"/team/member_update",
|
||||
"/v2/team/{team_id}/members",
|
||||
"/team/permissions_list",
|
||||
"/team/permissions_update",
|
||||
"/team/daily/activity",
|
||||
|
|
@ -1011,10 +1013,10 @@ class LiteLLM_ObjectPermissionBase(LiteLLMPydanticObjectBase):
|
|||
mcp_tool_search_enabled: Optional[bool] = None
|
||||
|
||||
|
||||
from litellm.models.team import BudgetLimitEntry as BudgetLimitEntry # noqa: E402
|
||||
from litellm.types.object_permission import ( # noqa: E402
|
||||
ObjectPermissionDict as ObjectPermissionDict,
|
||||
)
|
||||
from litellm.models.team import BudgetLimitEntry as BudgetLimitEntry # noqa: E402
|
||||
|
||||
|
||||
class GenerateRequestBase(LiteLLMPydanticObjectBase):
|
||||
|
|
@ -3715,6 +3717,47 @@ class TeamMemberUpdateResponse(MemberUpdateResponse):
|
|||
allowed_models: Optional[List[str]] = None
|
||||
|
||||
|
||||
class TeamMemberBulkUpdateFields(LiteLLMPydanticObjectBase):
|
||||
max_budget_in_team: float | None = None
|
||||
role: Literal["admin", "user"] | None = None
|
||||
tpm_limit: int | None = None
|
||||
rpm_limit: int | None = None
|
||||
budget_duration: str | None = None
|
||||
allowed_models: list[str] | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_at_least_one_field(self):
|
||||
if not self.model_fields_set:
|
||||
raise ValueError("update_fields must specify at least one field to update")
|
||||
return self
|
||||
|
||||
|
||||
class BulkTeamMemberUpdateRequest(LiteLLMPydanticObjectBase):
|
||||
user_ids: list[str] | None = None
|
||||
all_members_in_team: bool = False
|
||||
update_fields: TeamMemberBulkUpdateFields
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_selection(self):
|
||||
has_user_ids = self.user_ids is not None and len(self.user_ids) > 0
|
||||
if has_user_ids and self.all_members_in_team:
|
||||
raise ValueError("Provide either user_ids or all_members_in_team=True, not both")
|
||||
if not has_user_ids and not self.all_members_in_team:
|
||||
raise ValueError("Must provide either user_ids (non-empty) or all_members_in_team=True")
|
||||
return self
|
||||
|
||||
|
||||
class FailedTeamMemberUpdate(MemberUpdateResponse):
|
||||
failed_reason: str
|
||||
|
||||
|
||||
class BulkTeamMemberUpdateResponse(LiteLLMPydanticObjectBase):
|
||||
team_id: str
|
||||
total_requested: int
|
||||
successful_updates: list[TeamMemberUpdateResponse]
|
||||
failed_updates: list[FailedTeamMemberUpdate]
|
||||
|
||||
|
||||
class TeamModelAddRequest(BaseModel):
|
||||
"""Request to add models to a team"""
|
||||
|
||||
|
|
|
|||
|
|
@ -423,6 +423,20 @@ def _has_meaningful_budget_limit(budget_values: Dict[str, Any]) -> bool:
|
|||
return any(_is_set_budget_value(budget_values.get(field)) for field in _TEAM_MEMBER_BUDGET_LIMIT_FIELDS)
|
||||
|
||||
|
||||
def _budget_patch_to_write_data(budget_patch: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Turn an RFC 7396-style budget patch into the budget-table write payload:
|
||||
setting budget_duration also recomputes budget_reset_at, clearing the
|
||||
duration clears budget_reset_at, and a patch that never mentions the
|
||||
duration leaves the reset timestamp alone."""
|
||||
if "budget_duration" not in budget_patch:
|
||||
return dict(budget_patch)
|
||||
duration = budget_patch["budget_duration"]
|
||||
return {
|
||||
**budget_patch,
|
||||
"budget_reset_at": get_budget_reset_time(budget_duration=duration) if duration is not None else None,
|
||||
}
|
||||
|
||||
|
||||
async def _upsert_budget_and_membership(
|
||||
tx,
|
||||
*,
|
||||
|
|
@ -450,12 +464,7 @@ async def _upsert_budget_and_membership(
|
|||
if not budget_patch:
|
||||
return
|
||||
|
||||
write_data = dict(budget_patch)
|
||||
if "budget_duration" in write_data:
|
||||
duration = write_data["budget_duration"]
|
||||
write_data["budget_reset_at"] = (
|
||||
get_budget_reset_time(budget_duration=duration) if duration is not None else None
|
||||
)
|
||||
write_data = _budget_patch_to_write_data(budget_patch)
|
||||
|
||||
is_shared_default = (
|
||||
existing_budget_id is not None
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import json
|
|||
import math
|
||||
import traceback
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated, Any, Dict, List, Optional, Tuple, Union, cast
|
||||
from typing import Annotated, Any, Dict, List, Optional, Sequence, Tuple, Union, cast
|
||||
|
||||
import fastapi
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
|
||||
|
|
@ -28,8 +28,11 @@ from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
|
|||
from litellm.proxy._types import (
|
||||
UI_TEAM_ID,
|
||||
BlockTeamRequest,
|
||||
BulkTeamMemberUpdateRequest,
|
||||
BulkTeamMemberUpdateResponse,
|
||||
CommonProxyErrors,
|
||||
DeleteTeamRequest,
|
||||
FailedTeamMemberUpdate,
|
||||
LiteLLM_AuditLogs,
|
||||
LiteLLM_DeletedTeamTable,
|
||||
LiteLLM_ManagementEndpoint_MetadataFields,
|
||||
|
|
@ -57,6 +60,7 @@ from litellm.proxy._types import (
|
|||
TeamInfoResponseObjectTeamTable,
|
||||
TeamListResponseObject,
|
||||
TeamMemberAddRequest,
|
||||
TeamMemberBulkUpdateFields,
|
||||
TeamMemberDeleteRequest,
|
||||
TeamMemberUpdateRequest,
|
||||
TeamMemberUpdateResponse,
|
||||
|
|
@ -93,6 +97,15 @@ from litellm.proxy.management_endpoints.organization_endpoints import (
|
|||
from litellm.proxy.management_endpoints.tag_management_endpoints import (
|
||||
get_daily_activity,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.team_member_budget_writes import (
|
||||
BudgetFieldSnapshot,
|
||||
MembershipBudgetSnapshot,
|
||||
PrismaTeamMemberBudgetDb,
|
||||
apply_member_budget_write_plan,
|
||||
budget_snapshot_from_row,
|
||||
invalidate_team_membership_caches,
|
||||
plan_member_budget_writes,
|
||||
)
|
||||
from litellm.proxy.management_helpers.object_permission_utils import (
|
||||
_set_object_permission,
|
||||
enforce_all_proxy_mcp_servers_grant_is_admin_only,
|
||||
|
|
@ -2814,7 +2827,7 @@ _MEMBER_BUDGET_PATCH_FIELDS = {
|
|||
}
|
||||
|
||||
|
||||
def _build_member_budget_patch(data: TeamMemberUpdateRequest) -> Dict[str, Any]:
|
||||
def _build_member_budget_patch(data: TeamMemberUpdateRequest | TeamMemberBulkUpdateFields) -> dict[str, Any]:
|
||||
"""Map the budget fields the request actually set (merge-patch: a sent
|
||||
value updates, an explicit null clears, an absent field is left untouched)
|
||||
to their budget-table columns."""
|
||||
|
|
@ -2826,6 +2839,37 @@ def _build_member_budget_patch(data: TeamMemberUpdateRequest) -> Dict[str, Any]:
|
|||
}
|
||||
|
||||
|
||||
async def _load_member_budget_snapshots(
|
||||
*,
|
||||
prisma_client: PrismaClient,
|
||||
team_id: str,
|
||||
user_ids: Sequence[str],
|
||||
team_default_budget_id: str | None,
|
||||
) -> tuple[tuple[MembershipBudgetSnapshot, ...], dict[str, BudgetFieldSnapshot]]:
|
||||
if not user_ids:
|
||||
return (), {}
|
||||
|
||||
raw_memberships = await TeamMembershipRepository(prisma_client).table.find_many(
|
||||
where={"team_id": team_id, "user_id": {"in": list(user_ids)}}
|
||||
)
|
||||
budget_id_by_user = {membership.user_id: membership.budget_id for membership in raw_memberships}
|
||||
membership_snapshots = tuple(
|
||||
MembershipBudgetSnapshot(user_id=user_id, budget_id=budget_id_by_user.get(user_id)) for user_id in user_ids
|
||||
)
|
||||
|
||||
budget_ids = {budget_id for budget_id in budget_id_by_user.values() if budget_id is not None}
|
||||
if team_default_budget_id is not None:
|
||||
budget_ids.add(team_default_budget_id)
|
||||
if not budget_ids:
|
||||
return membership_snapshots, {}
|
||||
|
||||
raw_budgets = await BudgetRepository(prisma_client).table.find_many(where={"budget_id": {"in": list(budget_ids)}})
|
||||
budgets_by_id = {
|
||||
budget.budget_id: budget_snapshot_from_row(budget.budget_id, budget.model_dump()) for budget in raw_budgets
|
||||
}
|
||||
return membership_snapshots, budgets_by_id
|
||||
|
||||
|
||||
def _validate_budget_duration(budget_duration: Optional[str]) -> None:
|
||||
"""Reject budget durations that can't be parsed, are non-positive, or
|
||||
overflow date math, so a bad value can't be persisted and later crash the
|
||||
|
|
@ -2922,6 +2966,23 @@ async def team_member_update(
|
|||
user_api_key_dict=user_api_key_dict,
|
||||
)
|
||||
|
||||
return await _apply_team_member_update(
|
||||
data=data,
|
||||
returned_team_info=returned_team_info,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
)
|
||||
|
||||
|
||||
async def _apply_team_member_update(
|
||||
data: TeamMemberUpdateRequest,
|
||||
returned_team_info: TeamInfoResponseObject,
|
||||
prisma_client: PrismaClient,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
) -> TeamMemberUpdateResponse:
|
||||
"""Apply a single member update against already-fetched team info so a bulk
|
||||
caller can resolve team_info once and reuse it for every member, instead of
|
||||
re-scanning the team, its keys, and all memberships per member."""
|
||||
team_table = returned_team_info["team_info"]
|
||||
|
||||
## get user id
|
||||
|
|
@ -2929,7 +2990,7 @@ async def team_member_update(
|
|||
if data.user_id is not None:
|
||||
received_user_id = data.user_id
|
||||
elif data.user_email is not None:
|
||||
for member in returned_team_info["team_info"].members_with_roles:
|
||||
for member in team_table.members_with_roles:
|
||||
if member.user_email is not None and member.user_email == data.user_email:
|
||||
received_user_id = member.user_id
|
||||
break
|
||||
|
|
@ -3003,6 +3064,171 @@ async def team_member_update(
|
|||
)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/v2/team/{team_id}/members",
|
||||
tags=["team management"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
response_model=BulkTeamMemberUpdateResponse,
|
||||
)
|
||||
@management_endpoint_wrapper
|
||||
async def bulk_update_team_members(
|
||||
team_id: str,
|
||||
data: BulkTeamMemberUpdateRequest,
|
||||
http_request: Request,
|
||||
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
|
||||
):
|
||||
from litellm.proxy.proxy_server import (
|
||||
premium_user,
|
||||
prisma_client,
|
||||
proxy_logging_obj,
|
||||
user_api_key_cache,
|
||||
)
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(status_code=500, detail={"error": "No db connected"})
|
||||
|
||||
if data.update_fields.role == "admin" and not premium_user:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Assigning team admins is a premium feature. You must be a LiteLLM Enterprise user to use this feature. If you have a license please set `LITELLM_LICENSE` in your env. Get a 7 day trial key here: https://www.litellm.ai/#trial. Pricing: https://www.litellm.ai/#pricing",
|
||||
)
|
||||
|
||||
_validate_budget_duration(data.update_fields.budget_duration)
|
||||
|
||||
existing_team_row = await TeamRepository(prisma_client).table.find_unique(where={"team_id": team_id})
|
||||
if existing_team_row is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"error": "Team id={} does not exist in db".format(team_id)},
|
||||
)
|
||||
existing_team = LiteLLM_TeamTable(**existing_team_row.model_dump())
|
||||
if (
|
||||
user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value
|
||||
and not _is_user_team_admin(user_api_key_dict=user_api_key_dict, team_obj=existing_team)
|
||||
and not await _is_user_org_admin_for_team(user_api_key_dict=user_api_key_dict, team_obj=existing_team)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={
|
||||
"error": "Call not allowed. User not proxy admin OR team admin. route={}, team_id={}".format(
|
||||
"/v2/team/{team_id}/members", team_id
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
if data.all_members_in_team:
|
||||
user_ids = list(
|
||||
dict.fromkeys(member.user_id for member in existing_team.members_with_roles if member.user_id is not None)
|
||||
)
|
||||
else:
|
||||
user_ids = list(dict.fromkeys(data.user_ids or []))
|
||||
|
||||
max_batch_size = 500
|
||||
if len(user_ids) > max_batch_size:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": "Maximum {} team members can be updated at once. Found {} user_ids.".format(
|
||||
max_batch_size, len(user_ids)
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
member_user_ids = {member.user_id for member in existing_team.members_with_roles if member.user_id is not None}
|
||||
valid_user_ids = [user_id for user_id in user_ids if user_id in member_user_ids]
|
||||
failed_updates = [
|
||||
FailedTeamMemberUpdate(
|
||||
user_id=user_id, failed_reason="User id={} is not a member of team {}".format(user_id, team_id)
|
||||
)
|
||||
for user_id in user_ids
|
||||
if user_id not in member_user_ids
|
||||
]
|
||||
|
||||
budget_patch = _build_member_budget_patch(data.update_fields)
|
||||
|
||||
raw_default_budget_id = (existing_team.metadata or {}).get("team_member_budget_id")
|
||||
default_budget_id = raw_default_budget_id if isinstance(raw_default_budget_id, str) else None
|
||||
|
||||
budget_target_user_ids = valid_user_ids if budget_patch else []
|
||||
membership_snapshots, budgets_by_id = await _load_member_budget_snapshots(
|
||||
prisma_client=prisma_client,
|
||||
team_id=team_id,
|
||||
user_ids=budget_target_user_ids,
|
||||
team_default_budget_id=default_budget_id,
|
||||
)
|
||||
budget_plan = plan_member_budget_writes(
|
||||
memberships=membership_snapshots,
|
||||
budgets_by_id=budgets_by_id,
|
||||
budget_patch=budget_patch,
|
||||
team_default_budget_id=default_budget_id,
|
||||
actor_user_id=user_api_key_dict.user_id or "",
|
||||
)
|
||||
|
||||
new_role = data.update_fields.role
|
||||
valid_user_id_set = frozenset(valid_user_ids)
|
||||
members_with_roles_json = (
|
||||
json.dumps(
|
||||
[
|
||||
Member(
|
||||
user_id=member.user_id,
|
||||
role=new_role,
|
||||
user_email=member.user_email,
|
||||
).model_dump()
|
||||
if member.user_id in valid_user_id_set
|
||||
else member.model_dump()
|
||||
for member in existing_team.members_with_roles
|
||||
]
|
||||
)
|
||||
if new_role is not None
|
||||
else None
|
||||
)
|
||||
|
||||
has_budget_writes = len(budget_plan.writes) > 0
|
||||
has_role_writes = members_with_roles_json is not None
|
||||
refreshed_team_row = None
|
||||
if has_budget_writes or has_role_writes:
|
||||
async with prisma_client.db.tx() as tx:
|
||||
refreshed_team_row = await apply_member_budget_write_plan(
|
||||
db=PrismaTeamMemberBudgetDb(tx),
|
||||
team_id=team_id,
|
||||
plan=budget_plan,
|
||||
members_with_roles_json=members_with_roles_json,
|
||||
)
|
||||
|
||||
if refreshed_team_row is not None:
|
||||
await _refresh_cached_team(
|
||||
team_row=refreshed_team_row,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
|
||||
if has_budget_writes or has_role_writes:
|
||||
await invalidate_team_membership_caches(
|
||||
user_ids=valid_user_ids,
|
||||
team_id=team_id,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
)
|
||||
|
||||
successful_updates = [
|
||||
TeamMemberUpdateResponse(
|
||||
team_id=team_id,
|
||||
user_id=user_id,
|
||||
max_budget_in_team=data.update_fields.max_budget_in_team,
|
||||
tpm_limit=data.update_fields.tpm_limit,
|
||||
rpm_limit=data.update_fields.rpm_limit,
|
||||
budget_duration=data.update_fields.budget_duration,
|
||||
allowed_models=data.update_fields.allowed_models,
|
||||
)
|
||||
for user_id in valid_user_ids
|
||||
]
|
||||
return BulkTeamMemberUpdateResponse(
|
||||
team_id=team_id,
|
||||
total_requested=len(user_ids),
|
||||
successful_updates=successful_updates,
|
||||
failed_updates=failed_updates,
|
||||
)
|
||||
|
||||
|
||||
def _create_results_from_response(
|
||||
members: List[Member],
|
||||
response: TeamAddMemberResponse,
|
||||
|
|
|
|||
246
litellm/proxy/management_endpoints/team_member_budget_writes.py
Normal file
246
litellm/proxy/management_endpoints/team_member_budget_writes.py
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Mapping, Protocol, Sequence
|
||||
from uuid import uuid4
|
||||
|
||||
from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time
|
||||
from litellm.proxy.management_endpoints.common_utils import (
|
||||
_TEAM_MEMBER_BUDGET_LIMIT_FIELDS,
|
||||
_budget_patch_to_write_data,
|
||||
_has_meaningful_budget_limit,
|
||||
_is_set_budget_value,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MembershipBudgetSnapshot:
|
||||
user_id: str
|
||||
budget_id: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BudgetFieldSnapshot:
|
||||
budget_id: str
|
||||
fields: Mapping[str, Any]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DisconnectBudget:
|
||||
user_id: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UpdateBudget:
|
||||
budget_id: str
|
||||
write_data: Mapping[str, Any]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CreateAndAttachBudget:
|
||||
user_id: str
|
||||
budget_id: str
|
||||
create_data: Mapping[str, Any]
|
||||
|
||||
|
||||
MemberBudgetWrite = DisconnectBudget | UpdateBudget | CreateAndAttachBudget
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MemberBudgetWritePlan:
|
||||
writes: tuple[MemberBudgetWrite, ...]
|
||||
|
||||
|
||||
def _limit_fields_from_row(row: Mapping[str, Any]) -> dict[str, Any]:
|
||||
return {field: row.get(field) for field in _TEAM_MEMBER_BUDGET_LIMIT_FIELDS if field in row}
|
||||
|
||||
|
||||
def budget_snapshot_from_row(budget_id: str, row: Mapping[str, Any]) -> BudgetFieldSnapshot:
|
||||
return BudgetFieldSnapshot(budget_id=budget_id, fields=_limit_fields_from_row(row))
|
||||
|
||||
|
||||
def _build_create_data(
|
||||
*,
|
||||
actor_user_id: str,
|
||||
write_data: Mapping[str, Any],
|
||||
shared_default_fields: Mapping[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
create_data: dict[str, Any] = {
|
||||
"created_by": actor_user_id,
|
||||
"updated_by": actor_user_id,
|
||||
}
|
||||
if shared_default_fields is not None:
|
||||
for field in _TEAM_MEMBER_BUDGET_LIMIT_FIELDS:
|
||||
value = shared_default_fields.get(field)
|
||||
if _is_set_budget_value(value):
|
||||
create_data[field] = value
|
||||
create_data.update(write_data)
|
||||
if create_data.get("budget_duration") is not None:
|
||||
create_data["budget_reset_at"] = get_budget_reset_time(budget_duration=create_data["budget_duration"])
|
||||
else:
|
||||
create_data.pop("budget_reset_at", None)
|
||||
return create_data
|
||||
|
||||
|
||||
def plan_member_budget_writes(
|
||||
*,
|
||||
memberships: Sequence[MembershipBudgetSnapshot],
|
||||
budgets_by_id: Mapping[str, BudgetFieldSnapshot],
|
||||
budget_patch: Mapping[str, Any],
|
||||
team_default_budget_id: str | None,
|
||||
actor_user_id: str,
|
||||
new_budget_id_factory: Callable[[], str] = lambda: str(uuid4()),
|
||||
) -> MemberBudgetWritePlan:
|
||||
if not budget_patch:
|
||||
return MemberBudgetWritePlan(writes=())
|
||||
|
||||
write_data = _budget_patch_to_write_data(dict(budget_patch))
|
||||
writes: list[MemberBudgetWrite] = []
|
||||
|
||||
for membership in memberships:
|
||||
existing_budget_id = membership.budget_id
|
||||
is_shared_default = (
|
||||
existing_budget_id is not None
|
||||
and team_default_budget_id is not None
|
||||
and existing_budget_id == team_default_budget_id
|
||||
)
|
||||
|
||||
if existing_budget_id is not None and not is_shared_default:
|
||||
existing = budgets_by_id.get(existing_budget_id)
|
||||
merged = dict(existing.fields) if existing is not None else {}
|
||||
merged.update(write_data)
|
||||
if not _has_meaningful_budget_limit(merged):
|
||||
writes.append(DisconnectBudget(user_id=membership.user_id))
|
||||
continue
|
||||
writes.append(
|
||||
UpdateBudget(
|
||||
budget_id=existing_budget_id,
|
||||
write_data={"updated_by": actor_user_id, **write_data},
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
shared_fields = None
|
||||
if is_shared_default and existing_budget_id is not None:
|
||||
default_snap = budgets_by_id.get(existing_budget_id)
|
||||
if default_snap is not None:
|
||||
shared_fields = default_snap.fields
|
||||
|
||||
create_data = _build_create_data(
|
||||
actor_user_id=actor_user_id,
|
||||
write_data=write_data,
|
||||
shared_default_fields=shared_fields,
|
||||
)
|
||||
if not _has_meaningful_budget_limit(create_data):
|
||||
if existing_budget_id is not None:
|
||||
writes.append(DisconnectBudget(user_id=membership.user_id))
|
||||
continue
|
||||
|
||||
new_budget_id = new_budget_id_factory()
|
||||
writes.append(
|
||||
CreateAndAttachBudget(
|
||||
user_id=membership.user_id,
|
||||
budget_id=new_budget_id,
|
||||
create_data={**create_data, "budget_id": new_budget_id},
|
||||
)
|
||||
)
|
||||
|
||||
return MemberBudgetWritePlan(writes=tuple(writes))
|
||||
|
||||
|
||||
class TeamMemberBudgetDb(Protocol):
|
||||
async def create_budgets(self, rows: Sequence[Mapping[str, Any]]) -> None: ...
|
||||
|
||||
async def update_budget(self, budget_id: str, data: Mapping[str, Any]) -> None: ...
|
||||
|
||||
async def disconnect_membership_budget(self, *, team_id: str, user_id: str) -> None: ...
|
||||
|
||||
async def attach_membership_budget(self, *, team_id: str, user_id: str, budget_id: str) -> None: ...
|
||||
|
||||
async def update_team_members_with_roles(self, *, team_id: str, members_with_roles_json: str) -> Any: ...
|
||||
|
||||
|
||||
async def apply_member_budget_write_plan(
|
||||
*,
|
||||
db: TeamMemberBudgetDb,
|
||||
team_id: str,
|
||||
plan: MemberBudgetWritePlan,
|
||||
members_with_roles_json: str | None,
|
||||
) -> Any | None:
|
||||
creates = tuple(write for write in plan.writes if isinstance(write, CreateAndAttachBudget))
|
||||
updates = tuple(write for write in plan.writes if isinstance(write, UpdateBudget))
|
||||
disconnects = tuple(write for write in plan.writes if isinstance(write, DisconnectBudget))
|
||||
|
||||
if creates:
|
||||
await db.create_budgets(tuple(write.create_data for write in creates))
|
||||
for write in creates:
|
||||
await db.attach_membership_budget(
|
||||
team_id=team_id,
|
||||
user_id=write.user_id,
|
||||
budget_id=write.budget_id,
|
||||
)
|
||||
|
||||
for write in updates:
|
||||
await db.update_budget(write.budget_id, write.write_data)
|
||||
|
||||
for write in disconnects:
|
||||
await db.disconnect_membership_budget(team_id=team_id, user_id=write.user_id)
|
||||
|
||||
if members_with_roles_json is None:
|
||||
return None
|
||||
return await db.update_team_members_with_roles(
|
||||
team_id=team_id,
|
||||
members_with_roles_json=members_with_roles_json,
|
||||
)
|
||||
|
||||
|
||||
class PrismaTeamMemberBudgetDb:
|
||||
def __init__(self, tx: Any):
|
||||
self._tx = tx
|
||||
|
||||
async def create_budgets(self, rows: Sequence[Mapping[str, Any]]) -> None:
|
||||
if not rows:
|
||||
return
|
||||
await self._tx.litellm_budgettable.create_many(data=list(rows))
|
||||
|
||||
async def update_budget(self, budget_id: str, data: Mapping[str, Any]) -> None:
|
||||
await self._tx.litellm_budgettable.update(where={"budget_id": budget_id}, data=dict(data))
|
||||
|
||||
async def disconnect_membership_budget(self, *, team_id: str, user_id: str) -> None:
|
||||
await self._tx.litellm_teammembership.update(
|
||||
where={"user_id_team_id": {"user_id": user_id, "team_id": team_id}},
|
||||
data={"litellm_budget_table": {"disconnect": True}},
|
||||
)
|
||||
|
||||
async def attach_membership_budget(self, *, team_id: str, user_id: str, budget_id: str) -> None:
|
||||
await self._tx.litellm_teammembership.upsert(
|
||||
where={"user_id_team_id": {"user_id": user_id, "team_id": team_id}},
|
||||
data={
|
||||
"create": {
|
||||
"user_id": user_id,
|
||||
"team_id": team_id,
|
||||
"litellm_budget_table": {"connect": {"budget_id": budget_id}},
|
||||
},
|
||||
"update": {
|
||||
"litellm_budget_table": {"connect": {"budget_id": budget_id}},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
async def update_team_members_with_roles(self, *, team_id: str, members_with_roles_json: str) -> Any:
|
||||
return await self._tx.litellm_teamtable.update(
|
||||
where={"team_id": team_id},
|
||||
data={"members_with_roles": members_with_roles_json},
|
||||
include={"object_permission": True},
|
||||
)
|
||||
|
||||
|
||||
async def invalidate_team_membership_caches(
|
||||
*,
|
||||
user_ids: Sequence[str],
|
||||
team_id: str,
|
||||
user_api_key_cache: Any,
|
||||
) -> None:
|
||||
for user_id in user_ids:
|
||||
await user_api_key_cache.async_delete_cache(f"team_membership:{user_id}:{team_id}")
|
||||
await user_api_key_cache.async_delete_cache(f"{team_id}_{user_id}")
|
||||
293
tests/proxy_behavior/management/test_team_bulk_member_update.py
Normal file
293
tests/proxy_behavior/management/test_team_bulk_member_update.py
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
import pytest
|
||||
|
||||
from .actors import Actor
|
||||
from .conftest import create_scratch_team
|
||||
|
||||
pytestmark = pytest.mark.asyncio(loop_scope="session")
|
||||
|
||||
ROUTE = "/v2/team/{team_id}/members"
|
||||
|
||||
|
||||
def _role_of(row, user_id: str):
|
||||
for m in row.members_with_roles or []:
|
||||
if m["user_id"] == user_id:
|
||||
return m["role"]
|
||||
return None
|
||||
|
||||
|
||||
# PATCH /v2/team/{team_id}/members — same coarse route gate and admin checks as
|
||||
# POST /team/member_update, so the actor x team-shape matrix mirrors it: the
|
||||
# scratch team is raw-seeded with a "user"-role member and each scenario bulk
|
||||
# promotes it to "admin". PROXY_ADMIN, the team's team admin, or an org admin of
|
||||
# the team's org may update; everyone else is 403. (The harness forces
|
||||
# premium_user, so the admin-role premium gate never decides the outcome.)
|
||||
_MATRIX = [
|
||||
("alpha/proxy_admin", Actor.PROXY_ADMIN, "alpha", 200),
|
||||
("alpha/org_admin", Actor.ORG_ADMIN, "alpha", 200),
|
||||
("alpha/team_admin", Actor.TEAM_ADMIN, "alpha", 200),
|
||||
("alpha/internal_user", Actor.INTERNAL_USER, "alpha", 403),
|
||||
("alpha/owner", Actor.OWNER, "alpha", 403),
|
||||
("alpha/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "alpha", 403),
|
||||
("alpha/cross_org_user", Actor.CROSS_ORG_USER, "alpha", 403),
|
||||
("alpha/service_account", Actor.SERVICE_ACCOUNT, "alpha", 403),
|
||||
("alpha/org_b_admin", Actor.ORG_B_ADMIN, "alpha", 403),
|
||||
("beta/proxy_admin", Actor.PROXY_ADMIN, "beta", 200),
|
||||
("beta/org_admin", Actor.ORG_ADMIN, "beta", 403),
|
||||
("beta/team_admin", Actor.TEAM_ADMIN, "beta", 403),
|
||||
("beta/internal_user", Actor.INTERNAL_USER, "beta", 403),
|
||||
("beta/owner", Actor.OWNER, "beta", 403),
|
||||
("beta/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "beta", 403),
|
||||
("beta/cross_org_user", Actor.CROSS_ORG_USER, "beta", 403),
|
||||
("beta/service_account", Actor.SERVICE_ACCOUNT, "beta", 403),
|
||||
("beta/org_b_admin", Actor.ORG_B_ADMIN, "beta", 200),
|
||||
]
|
||||
|
||||
|
||||
async def _seed_target(prisma, world, shape: str, team_id: str, member_id: str) -> None:
|
||||
if shape == "alpha":
|
||||
await create_scratch_team(
|
||||
prisma,
|
||||
team_id,
|
||||
organization_id=world.org_a_id,
|
||||
admin_user_ids=[world.keys[Actor.TEAM_ADMIN].user_id],
|
||||
member_user_ids=[member_id],
|
||||
)
|
||||
elif shape == "beta":
|
||||
await create_scratch_team(
|
||||
prisma,
|
||||
team_id,
|
||||
organization_id=world.org_b_id,
|
||||
member_user_ids=[member_id],
|
||||
)
|
||||
else: # pragma: no cover - guard
|
||||
pytest.fail(f"unknown shape={shape}")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"actor,shape,expected_status",
|
||||
[(a, sh, s) for (_id, a, sh, s) in _MATRIX],
|
||||
ids=[s[0] for s in _MATRIX],
|
||||
)
|
||||
async def test_bulk_member_update_authz_matrix(
|
||||
actor: Actor,
|
||||
shape: str,
|
||||
expected_status: int,
|
||||
proxy_client,
|
||||
prisma,
|
||||
scratch,
|
||||
world,
|
||||
):
|
||||
member_id = scratch.tag("member")
|
||||
await _seed_target(prisma, world, shape, scratch.prefix, member_id)
|
||||
caller = world.keys[actor]
|
||||
|
||||
resp = await proxy_client.patch(
|
||||
ROUTE.format(team_id=scratch.prefix),
|
||||
headers={"Authorization": f"Bearer {caller.cleartext}"},
|
||||
json={"user_ids": [member_id], "update_fields": {"role": "admin"}},
|
||||
)
|
||||
assert (
|
||||
resp.status_code == expected_status
|
||||
), f"{actor.value} {shape}: {resp.status_code} {resp.text}"
|
||||
|
||||
row = await prisma.db.litellm_teamtable.find_unique(
|
||||
where={"team_id": scratch.prefix}
|
||||
)
|
||||
assert row is not None
|
||||
if expected_status == 200:
|
||||
assert _role_of(row, member_id) == "admin"
|
||||
else:
|
||||
assert _role_of(row, member_id) == "user", "denied but role changed"
|
||||
|
||||
|
||||
async def test_bulk_member_update_reports_non_members_without_failing_batch(
|
||||
proxy_client, prisma, scratch, world
|
||||
):
|
||||
"""Valid members are all promoted in one write; a user_id that is not a
|
||||
member of the team lands in failed_updates while the rest still succeed."""
|
||||
m1 = scratch.tag("m1")
|
||||
m2 = scratch.tag("m2")
|
||||
stranger = scratch.tag("stranger")
|
||||
await create_scratch_team(
|
||||
prisma,
|
||||
scratch.prefix,
|
||||
organization_id=world.org_a_id,
|
||||
member_user_ids=[m1, m2],
|
||||
)
|
||||
|
||||
resp = await proxy_client.patch(
|
||||
ROUTE.format(team_id=scratch.prefix),
|
||||
headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"},
|
||||
json={
|
||||
"user_ids": [m1, m2, stranger],
|
||||
"update_fields": {"role": "admin"},
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["total_requested"] == 3
|
||||
assert {u["user_id"] for u in body["successful_updates"]} == {m1, m2}
|
||||
assert [f["user_id"] for f in body["failed_updates"]] == [stranger]
|
||||
|
||||
row = await prisma.db.litellm_teamtable.find_unique(
|
||||
where={"team_id": scratch.prefix}
|
||||
)
|
||||
assert row is not None
|
||||
assert _role_of(row, m1) == "admin"
|
||||
assert _role_of(row, m2) == "admin"
|
||||
|
||||
|
||||
async def test_bulk_member_update_all_members_in_team_promotes_everyone(
|
||||
proxy_client, prisma, scratch, world
|
||||
):
|
||||
"""all_members_in_team=True applies the patch to every current member."""
|
||||
m1 = scratch.tag("m1")
|
||||
m2 = scratch.tag("m2")
|
||||
await create_scratch_team(
|
||||
prisma,
|
||||
scratch.prefix,
|
||||
organization_id=world.org_a_id,
|
||||
member_user_ids=[m1, m2],
|
||||
)
|
||||
|
||||
resp = await proxy_client.patch(
|
||||
ROUTE.format(team_id=scratch.prefix),
|
||||
headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"},
|
||||
json={"all_members_in_team": True, "update_fields": {"role": "admin"}},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["total_requested"] == 2
|
||||
|
||||
row = await prisma.db.litellm_teamtable.find_unique(
|
||||
where={"team_id": scratch.prefix}
|
||||
)
|
||||
assert row is not None
|
||||
assert _role_of(row, m1) == "admin"
|
||||
assert _role_of(row, m2) == "admin"
|
||||
|
||||
|
||||
async def test_bulk_member_update_writes_member_budget_row(
|
||||
proxy_client, prisma, scratch, world
|
||||
):
|
||||
"""A limit patch on a member with no budget row creates one membership +
|
||||
budget carrying the patched value; this is the set-based write path."""
|
||||
member_id = scratch.tag("member")
|
||||
await create_scratch_team(
|
||||
prisma,
|
||||
scratch.prefix,
|
||||
organization_id=world.org_a_id,
|
||||
member_user_ids=[member_id],
|
||||
)
|
||||
|
||||
resp = await proxy_client.patch(
|
||||
ROUTE.format(team_id=scratch.prefix),
|
||||
headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"},
|
||||
json={"user_ids": [member_id], "update_fields": {"tpm_limit": 4242}},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
membership = await prisma.db.litellm_teammembership.find_unique(
|
||||
where={"user_id_team_id": {"user_id": member_id, "team_id": scratch.prefix}},
|
||||
include={"litellm_budget_table": True},
|
||||
)
|
||||
assert membership is not None and membership.litellm_budget_table is not None
|
||||
assert membership.litellm_budget_table.tpm_limit == 4242
|
||||
|
||||
|
||||
async def test_bulk_member_update_clearing_last_limit_disconnects_private_budget(
|
||||
proxy_client, prisma, scratch, world
|
||||
):
|
||||
member_id = scratch.tag("member")
|
||||
budget_id = scratch.tag("bud")
|
||||
await create_scratch_team(
|
||||
prisma,
|
||||
scratch.prefix,
|
||||
organization_id=world.org_a_id,
|
||||
member_user_ids=[member_id],
|
||||
)
|
||||
await prisma.db.litellm_budgettable.create(
|
||||
data={"budget_id": budget_id, "tpm_limit": 999, "created_by": "t", "updated_by": "t"}
|
||||
)
|
||||
await prisma.db.litellm_teammembership.create(
|
||||
data={"user_id": member_id, "team_id": scratch.prefix, "budget_id": budget_id}
|
||||
)
|
||||
|
||||
resp = await proxy_client.patch(
|
||||
ROUTE.format(team_id=scratch.prefix),
|
||||
headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"},
|
||||
json={"user_ids": [member_id], "update_fields": {"tpm_limit": None}},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
membership = await prisma.db.litellm_teammembership.find_unique(
|
||||
where={"user_id_team_id": {"user_id": member_id, "team_id": scratch.prefix}},
|
||||
include={"litellm_budget_table": True},
|
||||
)
|
||||
assert membership is not None
|
||||
assert membership.budget_id is None, "cleared budget must be disconnected"
|
||||
assert membership.litellm_budget_table is None
|
||||
|
||||
|
||||
async def test_bulk_member_update_does_not_leak_default_limits_to_null_budget_members(
|
||||
proxy_client, prisma, scratch, world
|
||||
):
|
||||
on_default = scratch.tag("ondefault")
|
||||
no_budget = scratch.tag("nobudget")
|
||||
default_budget_id = scratch.tag("defbud")
|
||||
await create_scratch_team(
|
||||
prisma,
|
||||
scratch.prefix,
|
||||
organization_id=world.org_a_id,
|
||||
member_user_ids=[on_default, no_budget],
|
||||
metadata={"team_member_budget_id": default_budget_id},
|
||||
)
|
||||
await prisma.db.litellm_budgettable.create(
|
||||
data={"budget_id": default_budget_id, "max_budget": 100.0, "created_by": "t", "updated_by": "t"}
|
||||
)
|
||||
await prisma.db.litellm_teammembership.create(
|
||||
data={"user_id": on_default, "team_id": scratch.prefix, "budget_id": default_budget_id}
|
||||
)
|
||||
await prisma.db.litellm_teammembership.create(
|
||||
data={"user_id": no_budget, "team_id": scratch.prefix}
|
||||
)
|
||||
|
||||
resp = await proxy_client.patch(
|
||||
ROUTE.format(team_id=scratch.prefix),
|
||||
headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"},
|
||||
json={"user_ids": [on_default, no_budget], "update_fields": {"tpm_limit": 42}},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
on_default_m = await prisma.db.litellm_teammembership.find_unique(
|
||||
where={"user_id_team_id": {"user_id": on_default, "team_id": scratch.prefix}},
|
||||
include={"litellm_budget_table": True},
|
||||
)
|
||||
assert on_default_m is not None and on_default_m.litellm_budget_table is not None
|
||||
assert on_default_m.budget_id != default_budget_id, "must clone, not patch the shared row"
|
||||
assert on_default_m.litellm_budget_table.tpm_limit == 42
|
||||
assert on_default_m.litellm_budget_table.max_budget == 100.0, "clone inherits the default's limits"
|
||||
|
||||
no_budget_m = await prisma.db.litellm_teammembership.find_unique(
|
||||
where={"user_id_team_id": {"user_id": no_budget, "team_id": scratch.prefix}},
|
||||
include={"litellm_budget_table": True},
|
||||
)
|
||||
assert no_budget_m is not None and no_budget_m.litellm_budget_table is not None
|
||||
assert no_budget_m.litellm_budget_table.tpm_limit == 42
|
||||
assert no_budget_m.litellm_budget_table.max_budget is None, "null-budget member must not inherit default limits"
|
||||
|
||||
default_row = await prisma.db.litellm_budgettable.find_unique(where={"budget_id": default_budget_id})
|
||||
assert default_row is not None and default_row.max_budget == 100.0, "shared default must be untouched"
|
||||
|
||||
|
||||
async def test_bulk_member_update_over_max_batch_is_400(
|
||||
proxy_client, prisma, scratch, world
|
||||
):
|
||||
"""More than the 500-member cap of user_ids is rejected 400."""
|
||||
await create_scratch_team(prisma, scratch.prefix, organization_id=world.org_a_id)
|
||||
user_ids = [f"{scratch.prefix}-u{i}" for i in range(501)]
|
||||
resp = await proxy_client.patch(
|
||||
ROUTE.format(team_id=scratch.prefix),
|
||||
headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"},
|
||||
json={"user_ids": user_ids, "update_fields": {"role": "user"}},
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
|
|
@ -2882,6 +2882,46 @@ def test_patch_team_gate_rejects_view_only_admin():
|
|||
)
|
||||
|
||||
|
||||
def test_bulk_member_update_route_has_same_reach_as_member_update():
|
||||
"""PATCH /v2/team/{team_id}/members must be reachable by the same coarse gate
|
||||
as /team/member_update (self_managed_routes; the endpoint enforces proxy /
|
||||
team / org admin itself), without the resolved path colliding with static
|
||||
siblings like /v2/team/list."""
|
||||
from litellm.proxy._types import LiteLLMRoutes
|
||||
|
||||
assert RouteChecks.check_route_access(
|
||||
route="/v2/team/team-1/members", allowed_routes=LiteLLMRoutes.self_managed_routes.value
|
||||
)
|
||||
assert RouteChecks.check_route_access(
|
||||
route="/v2/team/team-1/members", allowed_routes=LiteLLMRoutes.management_routes.value
|
||||
)
|
||||
assert not RouteChecks.check_route_access(
|
||||
route="/v2/team/list", allowed_routes=LiteLLMRoutes.self_managed_routes.value
|
||||
)
|
||||
|
||||
|
||||
def test_bulk_member_update_gate_rejects_view_only_admin():
|
||||
"""A view-only proxy admin cannot PATCH /v2/team/{team_id}/members: the
|
||||
templated path never exact-matches the write blocklists, so the unsafe-method
|
||||
default-deny is what has to catch it."""
|
||||
user_obj = LiteLLM_UserTable(
|
||||
user_id="viewer",
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
|
||||
)
|
||||
valid_token = UserAPIKeyAuth(user_id="viewer", user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
RouteChecks.non_proxy_admin_allowed_routes_check(
|
||||
user_obj=user_obj,
|
||||
_user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
|
||||
route="/v2/team/team-1/members",
|
||||
request=_patch_team_request(),
|
||||
valid_token=valid_token,
|
||||
request_data={},
|
||||
)
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_pass_through_registers_wildcard_for_auth_subpath():
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -0,0 +1,134 @@
|
|||
import pytest
|
||||
|
||||
from litellm.proxy.management_endpoints.team_member_budget_writes import (
|
||||
BudgetFieldSnapshot,
|
||||
CreateAndAttachBudget,
|
||||
DisconnectBudget,
|
||||
MembershipBudgetSnapshot,
|
||||
UpdateBudget,
|
||||
apply_member_budget_write_plan,
|
||||
plan_member_budget_writes,
|
||||
)
|
||||
|
||||
|
||||
def test_plan_updates_private_budget_in_place():
|
||||
plan = plan_member_budget_writes(
|
||||
memberships=(MembershipBudgetSnapshot(user_id="u1", budget_id="b1"),),
|
||||
budgets_by_id={"b1": BudgetFieldSnapshot(budget_id="b1", fields={"tpm_limit": 1})},
|
||||
budget_patch={"tpm_limit": 9},
|
||||
team_default_budget_id=None,
|
||||
actor_user_id="admin",
|
||||
)
|
||||
|
||||
assert len(plan.writes) == 1
|
||||
write = plan.writes[0]
|
||||
assert isinstance(write, UpdateBudget)
|
||||
assert write.budget_id == "b1"
|
||||
assert write.write_data["tpm_limit"] == 9
|
||||
assert write.write_data["updated_by"] == "admin"
|
||||
|
||||
|
||||
def test_plan_disconnects_when_last_private_limit_cleared():
|
||||
plan = plan_member_budget_writes(
|
||||
memberships=(MembershipBudgetSnapshot(user_id="u1", budget_id="b1"),),
|
||||
budgets_by_id={"b1": BudgetFieldSnapshot(budget_id="b1", fields={"tpm_limit": 1})},
|
||||
budget_patch={"tpm_limit": None},
|
||||
team_default_budget_id=None,
|
||||
actor_user_id="admin",
|
||||
)
|
||||
|
||||
assert plan.writes == (DisconnectBudget(user_id="u1"),)
|
||||
|
||||
|
||||
def test_plan_clone_on_write_for_shared_default_only():
|
||||
plan = plan_member_budget_writes(
|
||||
memberships=(
|
||||
MembershipBudgetSnapshot(user_id="on-default", budget_id="default"),
|
||||
MembershipBudgetSnapshot(user_id="no-budget", budget_id=None),
|
||||
),
|
||||
budgets_by_id={
|
||||
"default": BudgetFieldSnapshot(budget_id="default", fields={"max_budget": 50.0}),
|
||||
},
|
||||
budget_patch={"tpm_limit": 3},
|
||||
team_default_budget_id="default",
|
||||
actor_user_id="admin",
|
||||
new_budget_id_factory=iter(["nb-1", "nb-2"]).__next__,
|
||||
)
|
||||
|
||||
assert len(plan.writes) == 2
|
||||
first, second = plan.writes
|
||||
assert isinstance(first, CreateAndAttachBudget)
|
||||
assert first.user_id == "on-default"
|
||||
assert first.budget_id == "nb-1"
|
||||
assert first.create_data["max_budget"] == 50.0
|
||||
assert first.create_data["tpm_limit"] == 3
|
||||
assert isinstance(second, CreateAndAttachBudget)
|
||||
assert second.user_id == "no-budget"
|
||||
assert second.budget_id == "nb-2"
|
||||
assert "max_budget" not in second.create_data
|
||||
assert second.create_data["tpm_limit"] == 3
|
||||
|
||||
|
||||
def test_plan_empty_patch_is_noop():
|
||||
plan = plan_member_budget_writes(
|
||||
memberships=(MembershipBudgetSnapshot(user_id="u1", budget_id="b1"),),
|
||||
budgets_by_id={"b1": BudgetFieldSnapshot(budget_id="b1", fields={"tpm_limit": 1})},
|
||||
budget_patch={},
|
||||
team_default_budget_id=None,
|
||||
actor_user_id="admin",
|
||||
)
|
||||
assert plan.writes == ()
|
||||
|
||||
|
||||
class _RecordingDb:
|
||||
def __init__(self):
|
||||
self.created = []
|
||||
self.updated = []
|
||||
self.disconnected = []
|
||||
self.attached = []
|
||||
self.team_role_json = None
|
||||
|
||||
async def create_budgets(self, rows):
|
||||
self.created.extend(rows)
|
||||
|
||||
async def update_budget(self, budget_id, data):
|
||||
self.updated.append((budget_id, dict(data)))
|
||||
|
||||
async def disconnect_membership_budget(self, *, team_id, user_id):
|
||||
self.disconnected.append((team_id, user_id))
|
||||
|
||||
async def attach_membership_budget(self, *, team_id, user_id, budget_id):
|
||||
self.attached.append((team_id, user_id, budget_id))
|
||||
|
||||
async def update_team_members_with_roles(self, *, team_id, members_with_roles_json):
|
||||
self.team_role_json = members_with_roles_json
|
||||
return {"team_id": team_id}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_runs_set_oriented_write_groups():
|
||||
plan = plan_member_budget_writes(
|
||||
memberships=(
|
||||
MembershipBudgetSnapshot(user_id="u1", budget_id="b1"),
|
||||
MembershipBudgetSnapshot(user_id="u2", budget_id=None),
|
||||
),
|
||||
budgets_by_id={"b1": BudgetFieldSnapshot(budget_id="b1", fields={"tpm_limit": 1})},
|
||||
budget_patch={"tpm_limit": 9},
|
||||
team_default_budget_id=None,
|
||||
actor_user_id="admin",
|
||||
new_budget_id_factory=lambda: "created-1",
|
||||
)
|
||||
db = _RecordingDb()
|
||||
team_row = await apply_member_budget_write_plan(
|
||||
db=db,
|
||||
team_id="team-1",
|
||||
plan=plan,
|
||||
members_with_roles_json='[{"user_id":"u1","role":"user"}]',
|
||||
)
|
||||
|
||||
assert team_row == {"team_id": "team-1"}
|
||||
assert db.created[0]["budget_id"] == "created-1"
|
||||
assert db.attached == [("team-1", "u2", "created-1")]
|
||||
assert db.updated[0][0] == "b1"
|
||||
assert db.updated[0][1]["tpm_limit"] == 9
|
||||
assert db.team_role_json == '[{"user_id":"u1","role":"user"}]'
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import json
|
||||
import types
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
|
|
@ -8,13 +9,23 @@ from starlette.requests import Request
|
|||
import litellm.proxy.proxy_server as proxy_server
|
||||
import litellm.proxy.management_endpoints.team_endpoints as team_endpoints
|
||||
from litellm.proxy._types import (
|
||||
BulkTeamMemberUpdateRequest,
|
||||
LiteLLM_TeamMembership,
|
||||
LiteLLM_TeamTable,
|
||||
LitellmUserRoles,
|
||||
Member,
|
||||
TeamMemberBulkUpdateFields,
|
||||
TeamMemberUpdateRequest,
|
||||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.team_endpoints import team_member_update
|
||||
from litellm.proxy.management_endpoints.team_endpoints import (
|
||||
bulk_update_team_members,
|
||||
team_member_update,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.team_member_budget_writes import (
|
||||
BudgetFieldSnapshot,
|
||||
MembershipBudgetSnapshot,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -81,9 +92,7 @@ def happy_path_upsert(monkeypatch):
|
|||
AsyncMock(
|
||||
return_value={
|
||||
"team_info": team_row,
|
||||
"team_memberships": [
|
||||
types.SimpleNamespace(user_id="user-1", budget_id="bud-1")
|
||||
],
|
||||
"team_memberships": [LiteLLM_TeamMembership(user_id="user-1", team_id="team-1234", budget_id="bud-1")],
|
||||
}
|
||||
),
|
||||
)
|
||||
|
|
@ -93,9 +102,7 @@ def happy_path_upsert(monkeypatch):
|
|||
|
||||
|
||||
def _member_update_request(**overrides):
|
||||
data = TeamMemberUpdateRequest(
|
||||
team_id="team-1234", user_id="user-1", role="user", **overrides
|
||||
)
|
||||
data = TeamMemberUpdateRequest(team_id="team-1234", user_id="user-1", role="user", **overrides)
|
||||
request = Request({"type": "http", "method": "POST", "path": "/team/member_update"})
|
||||
auth = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN.value, user_id="admin")
|
||||
return data, request, auth
|
||||
|
|
@ -105,9 +112,7 @@ def _member_update_request(**overrides):
|
|||
async def test_team_member_update_sends_provided_fields_as_patch(happy_path_upsert):
|
||||
"""Fields the request sets must reach _upsert_budget_and_membership as a
|
||||
budget patch, otherwise the member budget is never written/reset."""
|
||||
data, request, auth = _member_update_request(
|
||||
max_budget_in_team=10.0, budget_duration="30d"
|
||||
)
|
||||
data, request, auth = _member_update_request(max_budget_in_team=10.0, budget_duration="30d")
|
||||
|
||||
response = await team_member_update(data, request, auth)
|
||||
|
||||
|
|
@ -127,9 +132,7 @@ async def test_team_member_update_explicit_null_clears_field(happy_path_upsert):
|
|||
|
||||
await team_member_update(data, request, auth)
|
||||
|
||||
assert happy_path_upsert.await_args.kwargs["budget_patch"] == {
|
||||
"budget_duration": None
|
||||
}
|
||||
assert happy_path_upsert.await_args.kwargs["budget_patch"] == {"budget_duration": None}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -153,9 +156,7 @@ async def test_team_member_update_omits_unset_fields_from_patch(happy_path_upser
|
|||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_team_member_update_rejects_invalid_budget_duration(
|
||||
monkeypatch, bad_duration
|
||||
):
|
||||
async def test_team_member_update_rejects_invalid_budget_duration(monkeypatch, bad_duration):
|
||||
"""An invalid budget_duration must be rejected with a 400 before any DB
|
||||
write, so it can never be persisted and later break the budget reset job."""
|
||||
monkeypatch.setattr(proxy_server, "prisma_client", object())
|
||||
|
|
@ -178,3 +179,344 @@ async def test_team_member_update_rejects_invalid_budget_duration(
|
|||
assert exc_info.value.status_code == 400
|
||||
assert "budget_duration" in str(exc_info.value.detail)
|
||||
upsert_mock.assert_not_called()
|
||||
|
||||
|
||||
class _FakeTx:
|
||||
def __init__(self, team_row, recorder):
|
||||
self._team_row = team_row
|
||||
self._recorder = recorder
|
||||
self.litellm_teamtable = types.SimpleNamespace(update=self._team_update)
|
||||
self.litellm_budgettable = types.SimpleNamespace(
|
||||
create_many=self._create_many,
|
||||
update=self._budget_update,
|
||||
)
|
||||
self.litellm_teammembership = types.SimpleNamespace(
|
||||
update=self._membership_update,
|
||||
upsert=self._membership_upsert,
|
||||
)
|
||||
|
||||
async def _team_update(self, **kwargs):
|
||||
self._recorder.team_updates.append(kwargs)
|
||||
return self._team_row
|
||||
|
||||
async def _create_many(self, data):
|
||||
self._recorder.budget_creates.extend(data)
|
||||
return {"count": len(data)}
|
||||
|
||||
async def _budget_update(self, **kwargs):
|
||||
self._recorder.budget_updates.append(kwargs)
|
||||
return types.SimpleNamespace(budget_id=kwargs["where"]["budget_id"])
|
||||
|
||||
async def _membership_update(self, **kwargs):
|
||||
self._recorder.membership_updates.append(kwargs)
|
||||
return types.SimpleNamespace()
|
||||
|
||||
async def _membership_upsert(self, **kwargs):
|
||||
self._recorder.membership_upserts.append(kwargs)
|
||||
return types.SimpleNamespace()
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
return False
|
||||
|
||||
|
||||
class _FakeBulkDb:
|
||||
def __init__(self, team_row):
|
||||
self.team_row = team_row
|
||||
self.team_updates: list = []
|
||||
self.budget_creates: list = []
|
||||
self.budget_updates: list = []
|
||||
self.membership_updates: list = []
|
||||
self.membership_upserts: list = []
|
||||
self.litellm_teamtable = types.SimpleNamespace(find_unique=AsyncMock(return_value=team_row))
|
||||
|
||||
def tx(self):
|
||||
return _FakeTx(self.team_row, self)
|
||||
|
||||
|
||||
def _bulk_setup(
|
||||
monkeypatch,
|
||||
team_row,
|
||||
*,
|
||||
membership_snapshots=(),
|
||||
budgets_by_id=None,
|
||||
):
|
||||
db = _FakeBulkDb(team_row)
|
||||
cache = types.SimpleNamespace(async_delete_cache=AsyncMock())
|
||||
monkeypatch.setattr(proxy_server, "prisma_client", types.SimpleNamespace(db=db))
|
||||
monkeypatch.setattr(proxy_server, "premium_user", False)
|
||||
monkeypatch.setattr(proxy_server, "user_api_key_cache", cache)
|
||||
monkeypatch.setattr(proxy_server, "proxy_logging_obj", object())
|
||||
load_mock = AsyncMock(return_value=(tuple(membership_snapshots), budgets_by_id or {}))
|
||||
monkeypatch.setattr(team_endpoints, "_load_member_budget_snapshots", load_mock)
|
||||
refresh_mock = AsyncMock()
|
||||
monkeypatch.setattr(team_endpoints, "_refresh_cached_team", refresh_mock)
|
||||
return db, load_mock, refresh_mock, cache
|
||||
|
||||
|
||||
def _bulk_request():
|
||||
return Request({"type": "http", "method": "PATCH", "path": "/v2/team/team-1234/members"})
|
||||
|
||||
|
||||
_ADMIN = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN.value, user_id="admin")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_update_plans_budget_writes_without_per_member_inline_prisma(monkeypatch):
|
||||
team_row = LiteLLM_TeamTable(
|
||||
team_id="team-1234",
|
||||
members_with_roles=[
|
||||
Member(user_id="user-1", role="user"),
|
||||
Member(user_id="user-2", role="user"),
|
||||
Member(user_id="user-3", role="user"),
|
||||
],
|
||||
)
|
||||
db, load_mock, refresh_mock, cache = _bulk_setup(
|
||||
monkeypatch,
|
||||
team_row,
|
||||
membership_snapshots=(
|
||||
MembershipBudgetSnapshot(user_id="user-1", budget_id="bud-1"),
|
||||
MembershipBudgetSnapshot(user_id="user-2", budget_id="bud-2"),
|
||||
),
|
||||
budgets_by_id={
|
||||
"bud-1": BudgetFieldSnapshot(budget_id="bud-1", fields={"tpm_limit": 1}),
|
||||
"bud-2": BudgetFieldSnapshot(budget_id="bud-2", fields={"tpm_limit": 2}),
|
||||
},
|
||||
)
|
||||
|
||||
response = await bulk_update_team_members(
|
||||
team_id="team-1234",
|
||||
data=BulkTeamMemberUpdateRequest(
|
||||
user_ids=["user-1", "user-2", "user-1"],
|
||||
update_fields=TeamMemberBulkUpdateFields(tpm_limit=42),
|
||||
),
|
||||
http_request=_bulk_request(),
|
||||
user_api_key_dict=_ADMIN,
|
||||
)
|
||||
|
||||
load_mock.assert_awaited_once()
|
||||
assert load_mock.await_args.kwargs["user_ids"] == ["user-1", "user-2"]
|
||||
assert {update["where"]["budget_id"] for update in db.budget_updates} == {"bud-1", "bud-2"}
|
||||
assert db.team_updates == []
|
||||
refresh_mock.assert_not_awaited()
|
||||
assert cache.async_delete_cache.await_count == 4
|
||||
assert response.total_requested == 2
|
||||
assert [member.user_id for member in response.successful_updates] == ["user-1", "user-2"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_update_clones_shared_default_instead_of_mutating_it(monkeypatch):
|
||||
team_row = LiteLLM_TeamTable(
|
||||
team_id="team-1234",
|
||||
members_with_roles=[
|
||||
Member(user_id="user-1", role="user"),
|
||||
Member(user_id="user-2", role="user"),
|
||||
],
|
||||
metadata={"team_member_budget_id": "default-bud"},
|
||||
)
|
||||
ids = iter(["new-bud-1", "new-bud-2"])
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.management_endpoints.team_member_budget_writes.uuid4",
|
||||
lambda: next(ids),
|
||||
)
|
||||
db, _load, _refresh, _cache = _bulk_setup(
|
||||
monkeypatch,
|
||||
team_row,
|
||||
membership_snapshots=(
|
||||
MembershipBudgetSnapshot(user_id="user-1", budget_id="default-bud"),
|
||||
MembershipBudgetSnapshot(user_id="user-2", budget_id=None),
|
||||
),
|
||||
budgets_by_id={
|
||||
"default-bud": BudgetFieldSnapshot(budget_id="default-bud", fields={"max_budget": 100.0}),
|
||||
},
|
||||
)
|
||||
|
||||
await bulk_update_team_members(
|
||||
team_id="team-1234",
|
||||
data=BulkTeamMemberUpdateRequest(
|
||||
user_ids=["user-1", "user-2"],
|
||||
update_fields=TeamMemberBulkUpdateFields(tpm_limit=42),
|
||||
),
|
||||
http_request=_bulk_request(),
|
||||
user_api_key_dict=_ADMIN,
|
||||
)
|
||||
|
||||
assert db.budget_updates == []
|
||||
created_ids = {row["budget_id"] for row in db.budget_creates}
|
||||
assert created_ids == {"new-bud-1", "new-bud-2"}
|
||||
user1_create = next(row for row in db.budget_creates if row["budget_id"] == "new-bud-1")
|
||||
assert user1_create["max_budget"] == 100.0
|
||||
assert user1_create["tpm_limit"] == 42
|
||||
user2_create = next(row for row in db.budget_creates if row["budget_id"] == "new-bud-2")
|
||||
assert "max_budget" not in user2_create
|
||||
assert user2_create["tpm_limit"] == 42
|
||||
assert len(db.membership_upserts) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_update_role_writes_team_row_once_and_refreshes_cache(monkeypatch):
|
||||
team_row = LiteLLM_TeamTable(
|
||||
team_id="team-1234",
|
||||
members_with_roles=[
|
||||
Member(user_id="user-1", role="admin"),
|
||||
Member(user_id="user-2", role="user", user_email="two@example.com"),
|
||||
Member(user_id="user-3", role="user"),
|
||||
],
|
||||
)
|
||||
db, load_mock, refresh_mock, cache = _bulk_setup(monkeypatch, team_row)
|
||||
|
||||
await bulk_update_team_members(
|
||||
team_id="team-1234",
|
||||
data=BulkTeamMemberUpdateRequest(
|
||||
user_ids=["user-1", "user-2"],
|
||||
update_fields=TeamMemberBulkUpdateFields(role="user"),
|
||||
),
|
||||
http_request=_bulk_request(),
|
||||
user_api_key_dict=_ADMIN,
|
||||
)
|
||||
|
||||
load_mock.assert_awaited_once()
|
||||
assert load_mock.await_args.kwargs["user_ids"] == []
|
||||
assert len(db.team_updates) == 1
|
||||
update = db.team_updates[0]
|
||||
assert update["where"] == {"team_id": "team-1234"}
|
||||
members = json.loads(update["data"]["members_with_roles"])
|
||||
assert [(member["user_id"], member["role"]) for member in members] == [
|
||||
("user-1", "user"),
|
||||
("user-2", "user"),
|
||||
("user-3", "user"),
|
||||
]
|
||||
assert members[1]["user_email"] == "two@example.com"
|
||||
refresh_mock.assert_awaited_once()
|
||||
assert refresh_mock.await_args.kwargs["team_row"] is team_row
|
||||
assert cache.async_delete_cache.await_count == 4
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_update_all_members_in_team_dedups_members(monkeypatch):
|
||||
team_row = LiteLLM_TeamTable(
|
||||
team_id="team-1234",
|
||||
members_with_roles=[
|
||||
Member(user_id="user-1", role="user"),
|
||||
Member(user_id="user-1", role="user"),
|
||||
Member(user_id="user-2", role="user"),
|
||||
],
|
||||
)
|
||||
db, load_mock, _refresh, _cache = _bulk_setup(
|
||||
monkeypatch,
|
||||
team_row,
|
||||
membership_snapshots=(
|
||||
MembershipBudgetSnapshot(user_id="user-1", budget_id="bud-1"),
|
||||
MembershipBudgetSnapshot(user_id="user-2", budget_id="bud-2"),
|
||||
),
|
||||
budgets_by_id={
|
||||
"bud-1": BudgetFieldSnapshot(budget_id="bud-1", fields={"tpm_limit": 1}),
|
||||
"bud-2": BudgetFieldSnapshot(budget_id="bud-2", fields={"tpm_limit": 2}),
|
||||
},
|
||||
)
|
||||
|
||||
response = await bulk_update_team_members(
|
||||
team_id="team-1234",
|
||||
data=BulkTeamMemberUpdateRequest(
|
||||
all_members_in_team=True,
|
||||
update_fields=TeamMemberBulkUpdateFields(tpm_limit=42),
|
||||
),
|
||||
http_request=_bulk_request(),
|
||||
user_api_key_dict=_ADMIN,
|
||||
)
|
||||
|
||||
assert load_mock.await_args.kwargs["user_ids"] == ["user-1", "user-2"]
|
||||
assert {update["where"]["budget_id"] for update in db.budget_updates} == {"bud-1", "bud-2"}
|
||||
assert response.total_requested == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_update_reports_non_members_as_failed(monkeypatch):
|
||||
team_row = LiteLLM_TeamTable(
|
||||
team_id="team-1234",
|
||||
members_with_roles=[Member(user_id="user-1", role="user")],
|
||||
)
|
||||
db, load_mock, _refresh, _cache = _bulk_setup(
|
||||
monkeypatch,
|
||||
team_row,
|
||||
membership_snapshots=(MembershipBudgetSnapshot(user_id="user-1", budget_id="bud-1"),),
|
||||
budgets_by_id={"bud-1": BudgetFieldSnapshot(budget_id="bud-1", fields={"tpm_limit": 1})},
|
||||
)
|
||||
|
||||
response = await bulk_update_team_members(
|
||||
team_id="team-1234",
|
||||
data=BulkTeamMemberUpdateRequest(
|
||||
user_ids=["user-1", "ghost-user"],
|
||||
update_fields=TeamMemberBulkUpdateFields(tpm_limit=42),
|
||||
),
|
||||
http_request=_bulk_request(),
|
||||
user_api_key_dict=_ADMIN,
|
||||
)
|
||||
|
||||
assert response.total_requested == 2
|
||||
assert [member.user_id for member in response.successful_updates] == ["user-1"]
|
||||
assert response.failed_updates[0].user_id == "ghost-user"
|
||||
assert "not a member" in response.failed_updates[0].failed_reason
|
||||
assert load_mock.await_args.kwargs["user_ids"] == ["user-1"]
|
||||
assert {update["where"]["budget_id"] for update in db.budget_updates} == {"bud-1"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_update_explicit_null_duration_disconnects_private_budget(monkeypatch):
|
||||
team_row = LiteLLM_TeamTable(
|
||||
team_id="team-1234",
|
||||
members_with_roles=[Member(user_id="user-1", role="user")],
|
||||
)
|
||||
db, _load, _refresh, _cache = _bulk_setup(
|
||||
monkeypatch,
|
||||
team_row,
|
||||
membership_snapshots=(MembershipBudgetSnapshot(user_id="user-1", budget_id="bud-1"),),
|
||||
budgets_by_id={
|
||||
"bud-1": BudgetFieldSnapshot(budget_id="bud-1", fields={"budget_duration": "1d"}),
|
||||
},
|
||||
)
|
||||
|
||||
await bulk_update_team_members(
|
||||
team_id="team-1234",
|
||||
data=BulkTeamMemberUpdateRequest(
|
||||
user_ids=["user-1"],
|
||||
update_fields=TeamMemberBulkUpdateFields(budget_duration=None),
|
||||
),
|
||||
http_request=_bulk_request(),
|
||||
user_api_key_dict=_ADMIN,
|
||||
)
|
||||
|
||||
assert db.budget_updates == []
|
||||
assert len(db.membership_updates) == 1
|
||||
assert db.membership_updates[0]["data"] == {"litellm_budget_table": {"disconnect": True}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_update_admin_role_requires_premium(monkeypatch):
|
||||
monkeypatch.setattr(proxy_server, "prisma_client", object())
|
||||
monkeypatch.setattr(proxy_server, "premium_user", False)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await bulk_update_team_members(
|
||||
team_id="team-1234",
|
||||
data=BulkTeamMemberUpdateRequest(
|
||||
user_ids=["user-1"],
|
||||
update_fields=TeamMemberBulkUpdateFields(role="admin"),
|
||||
),
|
||||
http_request=_bulk_request(),
|
||||
user_api_key_dict=_ADMIN,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "premium feature" in str(exc_info.value.detail)
|
||||
|
||||
|
||||
def test_bulk_team_member_update_requires_exactly_one_member_selector():
|
||||
with pytest.raises(ValueError, match="either user_ids or all_members_in_team"):
|
||||
BulkTeamMemberUpdateRequest(
|
||||
user_ids=["user-1"],
|
||||
all_members_in_team=True,
|
||||
update_fields=TeamMemberBulkUpdateFields(tpm_limit=42),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ export interface MemberTableProps {
|
|||
extraColumns?: ColumnsType<Member>;
|
||||
showDeleteForMember?: (member: Member) => boolean;
|
||||
emptyText?: string;
|
||||
rowSelection?: React.ComponentProps<typeof Table<Member>>["rowSelection"];
|
||||
extraActions?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function MemberTable({
|
||||
|
|
@ -31,6 +33,8 @@ export default function MemberTable({
|
|||
extraColumns = [],
|
||||
showDeleteForMember,
|
||||
emptyText,
|
||||
rowSelection,
|
||||
extraActions,
|
||||
}: MemberTableProps) {
|
||||
const baseColumns: ColumnsType<Member> = [
|
||||
{
|
||||
|
|
@ -106,16 +110,22 @@ export default function MemberTable({
|
|||
<Table
|
||||
columns={baseColumns}
|
||||
dataSource={members}
|
||||
rowSelection={rowSelection}
|
||||
rowKey={(record) => record.user_id ?? record.user_email ?? JSON.stringify(record)}
|
||||
pagination={false}
|
||||
size="small"
|
||||
scroll={{ x: "max-content" }}
|
||||
locale={emptyText ? { emptyText } : undefined}
|
||||
/>
|
||||
{onAddMember && canEdit && (
|
||||
<Button icon={<UserAddOutlined />} type="primary" onClick={onAddMember}>
|
||||
Add Member
|
||||
</Button>
|
||||
{(onAddMember || extraActions) && canEdit && (
|
||||
<Space>
|
||||
{onAddMember && (
|
||||
<Button icon={<UserAddOutlined />} type="primary" onClick={onAddMember}>
|
||||
Add Member
|
||||
</Button>
|
||||
)}
|
||||
{extraActions}
|
||||
</Space>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -896,6 +896,12 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
setSelectedEditMember={setSelectedEditMember}
|
||||
setIsEditMemberModalVisible={setIsEditMemberModalVisible}
|
||||
setIsAddMemberModalVisible={setIsAddMemberModalVisible}
|
||||
onMembersUpdated={async () => {
|
||||
if (!accessToken) return;
|
||||
const updatedTeamData = await teamInfoCall(accessToken, teamId);
|
||||
setTeamData(updatedTeamData);
|
||||
onUpdate(updatedTeamData);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -182,6 +182,25 @@ describe("TeamMembersComponent", () => {
|
|||
expect(screen.getByText("Add Member")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show checkboxes after Select Members is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<TeamMembersComponent
|
||||
teamData={createMockTeamData()}
|
||||
canEditTeam={true}
|
||||
handleMemberDelete={mockHandleMemberDelete}
|
||||
setSelectedEditMember={mockSetSelectedEditMember}
|
||||
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
|
||||
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Select Members" }));
|
||||
|
||||
expect(screen.getByRole("button", { name: "Bulk Edit (0 selected)" })).toBeDisabled();
|
||||
expect(screen.getAllByRole("checkbox")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("should display dash when user email is null", () => {
|
||||
renderWithProviders(
|
||||
<TeamMembersComponent
|
||||
|
|
|
|||
|
|
@ -1,13 +1,18 @@
|
|||
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { Member } from "@/components/networking";
|
||||
import BudgetDurationDropdown from "@/components/common_components/budget_duration_dropdown";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
import NumericalInput from "@/components/shared/numerical_input";
|
||||
import { DateCell, MoneyCell } from "@/components/shared/table_cells";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { isProxyAdminRole, isUserTeamAdminForSingleTeam } from "@/utils/roles";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { Space, Tooltip, Typography } from "antd";
|
||||
import { Button, Checkbox, Form, Modal, Select, Space, Tooltip, Typography } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { useState } from "react";
|
||||
import MemberTable from "@/components/common_components/MemberTable";
|
||||
import { TeamMemberBulkUpdateFields, teamMemberBulkUpdateCall } from "./teamMemberBulkUpdate";
|
||||
import { TeamData } from "./TeamInfo";
|
||||
|
||||
interface TeamMemberTabProps {
|
||||
|
|
@ -17,6 +22,7 @@ interface TeamMemberTabProps {
|
|||
setSelectedEditMember: (member: Member) => void;
|
||||
setIsEditMemberModalVisible: (visible: boolean) => void;
|
||||
setIsAddMemberModalVisible: (visible: boolean) => void;
|
||||
onMembersUpdated?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export default function TeamMemberTab({
|
||||
|
|
@ -26,7 +32,13 @@ export default function TeamMemberTab({
|
|||
setSelectedEditMember,
|
||||
setIsEditMemberModalVisible,
|
||||
setIsAddMemberModalVisible,
|
||||
onMembersUpdated,
|
||||
}: TeamMemberTabProps) {
|
||||
const [isBulkUpdateVisible, setIsBulkUpdateVisible] = useState(false);
|
||||
const [isBulkUpdating, setIsBulkUpdating] = useState(false);
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const [selectedMembers, setSelectedMembers] = useState<Member[]>([]);
|
||||
const [bulkUpdateForm] = Form.useForm();
|
||||
const formatNumber = (value: number | null): string => {
|
||||
if (value === null || value === undefined) return "0";
|
||||
|
||||
|
|
@ -79,7 +91,7 @@ export default function TeamMemberTab({
|
|||
};
|
||||
|
||||
const { data: uiSettingsData } = useUISettings();
|
||||
const { userId, userRole } = useAuthorized();
|
||||
const { accessToken, userId, userRole } = useAuthorized();
|
||||
const disableTeamAdminDeleteTeamUser = Boolean(uiSettingsData?.values?.disable_team_admin_delete_team_user);
|
||||
const isUserTeamAdmin = isUserTeamAdminForSingleTeam(teamData.team_info.members_with_roles, userId || "");
|
||||
const isProxyAdmin = isProxyAdminRole(userRole || "");
|
||||
|
|
@ -183,31 +195,217 @@ export default function TeamMemberTab({
|
|||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<MemberTable
|
||||
members={teamData.team_info.members_with_roles}
|
||||
canEdit={canEditTeam}
|
||||
onEdit={(record) => {
|
||||
const membership = teamData.team_memberships.find((tm) => tm.user_id === record.user_id);
|
||||
const enhancedMember = {
|
||||
...record,
|
||||
max_budget_in_team: membership?.litellm_budget_table?.max_budget || null,
|
||||
tpm_limit: membership?.litellm_budget_table?.tpm_limit || null,
|
||||
rpm_limit: membership?.litellm_budget_table?.rpm_limit || null,
|
||||
budget_duration: membership?.litellm_budget_table?.budget_duration || null,
|
||||
allowed_models: membership?.litellm_budget_table?.allowed_models || [],
|
||||
};
|
||||
setSelectedEditMember(enhancedMember);
|
||||
setIsEditMemberModalVisible(true);
|
||||
}}
|
||||
onDelete={handleMemberDelete}
|
||||
onAddMember={() => setIsAddMemberModalVisible(true)}
|
||||
roleColumnTitle="Team Role"
|
||||
roleTooltip="This role applies only to this team and is independent from the user's proxy-level role."
|
||||
extraColumns={extraColumns}
|
||||
showDeleteForMember={() =>
|
||||
isProxyAdmin || (canEditTeam && !isUserTeamAdmin) || (isUserTeamAdmin && !disableTeamAdminDeleteTeamUser)
|
||||
const handleBulkUpdate = async (values: {
|
||||
apply_role?: boolean;
|
||||
role?: "admin" | "user";
|
||||
apply_max_budget?: boolean;
|
||||
max_budget_in_team?: number | null;
|
||||
apply_budget_duration?: boolean;
|
||||
budget_duration?: string | null;
|
||||
apply_tpm_limit?: boolean;
|
||||
tpm_limit?: number | null;
|
||||
apply_rpm_limit?: boolean;
|
||||
rpm_limit?: number | null;
|
||||
apply_allowed_models?: boolean;
|
||||
allowed_models?: string[];
|
||||
}) => {
|
||||
if (!accessToken) return;
|
||||
const userIds = selectedMembers.flatMap((member) => (member.user_id ? [member.user_id] : []));
|
||||
if (userIds.length === 0) {
|
||||
NotificationsManager.fromBackend("Select at least one team member");
|
||||
return;
|
||||
}
|
||||
const updateFields: TeamMemberBulkUpdateFields = {
|
||||
...(values.apply_role ? { role: values.role } : {}),
|
||||
...(values.apply_max_budget ? { max_budget_in_team: values.max_budget_in_team ?? null } : {}),
|
||||
...(values.apply_budget_duration ? { budget_duration: values.budget_duration ?? null } : {}),
|
||||
...(values.apply_tpm_limit ? { tpm_limit: values.tpm_limit ?? null } : {}),
|
||||
...(values.apply_rpm_limit ? { rpm_limit: values.rpm_limit ?? null } : {}),
|
||||
...(values.apply_allowed_models ? { allowed_models: values.allowed_models ?? [] } : {}),
|
||||
};
|
||||
if (Object.keys(updateFields).length === 0) {
|
||||
NotificationsManager.fromBackend("Select at least one field to update");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsBulkUpdating(true);
|
||||
try {
|
||||
const response = await teamMemberBulkUpdateCall(accessToken, teamData.team_id, userIds, updateFields);
|
||||
await onMembersUpdated?.();
|
||||
setIsBulkUpdateVisible(false);
|
||||
setSelectedMembers([]);
|
||||
setSelectionMode(false);
|
||||
bulkUpdateForm.resetFields();
|
||||
NotificationsManager.success(
|
||||
`${response.successful_updates.length} team member${response.successful_updates.length === 1 ? "" : "s"} updated`,
|
||||
);
|
||||
if (response.failed_updates.length > 0) {
|
||||
NotificationsManager.fromBackend(`${response.failed_updates.length} team member updates failed`);
|
||||
}
|
||||
/>
|
||||
} catch (error) {
|
||||
NotificationsManager.fromBackend(error instanceof Error ? error.message : "Failed to bulk update team members");
|
||||
} finally {
|
||||
setIsBulkUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MemberTable
|
||||
members={teamData.team_info.members_with_roles}
|
||||
canEdit={canEditTeam}
|
||||
onEdit={(record) => {
|
||||
const membership = teamData.team_memberships.find((tm) => tm.user_id === record.user_id);
|
||||
const enhancedMember = {
|
||||
...record,
|
||||
max_budget_in_team: membership?.litellm_budget_table?.max_budget || null,
|
||||
tpm_limit: membership?.litellm_budget_table?.tpm_limit || null,
|
||||
rpm_limit: membership?.litellm_budget_table?.rpm_limit || null,
|
||||
budget_duration: membership?.litellm_budget_table?.budget_duration || null,
|
||||
allowed_models: membership?.litellm_budget_table?.allowed_models || [],
|
||||
};
|
||||
setSelectedEditMember(enhancedMember);
|
||||
setIsEditMemberModalVisible(true);
|
||||
}}
|
||||
onDelete={handleMemberDelete}
|
||||
onAddMember={() => setIsAddMemberModalVisible(true)}
|
||||
extraActions={
|
||||
<>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSelectionMode((current) => !current);
|
||||
setSelectedMembers([]);
|
||||
}}
|
||||
type={selectionMode ? "primary" : "default"}
|
||||
>
|
||||
{selectionMode ? "Cancel Selection" : "Select Members"}
|
||||
</Button>
|
||||
{selectionMode && (
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={selectedMembers.length === 0}
|
||||
onClick={() => setIsBulkUpdateVisible(true)}
|
||||
>
|
||||
Bulk Edit ({selectedMembers.length} selected)
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
roleColumnTitle="Team Role"
|
||||
roleTooltip="This role applies only to this team and is independent from the user's proxy-level role."
|
||||
extraColumns={extraColumns}
|
||||
rowSelection={
|
||||
selectionMode
|
||||
? {
|
||||
selectedRowKeys: selectedMembers.flatMap((member) => (member.user_id ? [member.user_id] : [])),
|
||||
onChange: (_selectedRowKeys, selectedRows) => setSelectedMembers(selectedRows),
|
||||
getCheckboxProps: (member) => ({ disabled: member.user_id === null }),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
showDeleteForMember={() =>
|
||||
isProxyAdmin || (canEditTeam && !isUserTeamAdmin) || (isUserTeamAdmin && !disableTeamAdminDeleteTeamUser)
|
||||
}
|
||||
/>
|
||||
<Modal
|
||||
title={`Bulk Edit ${selectedMembers.length} Team Member${selectedMembers.length === 1 ? "" : "s"}`}
|
||||
open={isBulkUpdateVisible}
|
||||
onCancel={() => setIsBulkUpdateVisible(false)}
|
||||
onOk={() => bulkUpdateForm.submit()}
|
||||
okText="Update Members"
|
||||
confirmLoading={isBulkUpdating}
|
||||
>
|
||||
<Form form={bulkUpdateForm} layout="vertical" onFinish={handleBulkUpdate}>
|
||||
<Typography.Text type="secondary">Choose the fields to apply to every selected member.</Typography.Text>
|
||||
<Form.Item name="apply_role" valuePropName="checked" className="mt-3 mb-1">
|
||||
<Checkbox>Team role</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item noStyle shouldUpdate>
|
||||
{({ getFieldValue }) =>
|
||||
getFieldValue("apply_role") && (
|
||||
<Form.Item name="role" rules={[{ required: true, message: "Select a role" }]}>
|
||||
<Select
|
||||
placeholder="Select role"
|
||||
options={[
|
||||
{ label: "Admin", value: "admin" },
|
||||
{ label: "User", value: "user" },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
)
|
||||
}
|
||||
</Form.Item>
|
||||
<Form.Item name="apply_max_budget" valuePropName="checked" className="mb-1">
|
||||
<Checkbox>Team member budget (USD)</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item noStyle shouldUpdate>
|
||||
{({ getFieldValue }) =>
|
||||
getFieldValue("apply_max_budget") && (
|
||||
<Form.Item name="max_budget_in_team">
|
||||
<NumericalInput
|
||||
min={0}
|
||||
step={0.01}
|
||||
placeholder="Leave blank to clear the budget"
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</Form.Item>
|
||||
)
|
||||
}
|
||||
</Form.Item>
|
||||
<Form.Item name="apply_budget_duration" valuePropName="checked" className="mb-1">
|
||||
<Checkbox>Budget reset period</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item noStyle shouldUpdate>
|
||||
{({ getFieldValue }) =>
|
||||
getFieldValue("apply_budget_duration") && (
|
||||
<Form.Item name="budget_duration">
|
||||
<BudgetDurationDropdown />
|
||||
</Form.Item>
|
||||
)
|
||||
}
|
||||
</Form.Item>
|
||||
<Form.Item name="apply_tpm_limit" valuePropName="checked" className="mb-1">
|
||||
<Checkbox>TPM limit</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item noStyle shouldUpdate>
|
||||
{({ getFieldValue }) =>
|
||||
getFieldValue("apply_tpm_limit") && (
|
||||
<Form.Item name="tpm_limit">
|
||||
<NumericalInput min={0} placeholder="Leave blank to clear the TPM limit" style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
)
|
||||
}
|
||||
</Form.Item>
|
||||
<Form.Item name="apply_rpm_limit" valuePropName="checked" className="mb-1">
|
||||
<Checkbox>RPM limit</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item noStyle shouldUpdate>
|
||||
{({ getFieldValue }) =>
|
||||
getFieldValue("apply_rpm_limit") && (
|
||||
<Form.Item name="rpm_limit">
|
||||
<NumericalInput min={0} placeholder="Leave blank to clear the RPM limit" style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
)
|
||||
}
|
||||
</Form.Item>
|
||||
<Form.Item name="apply_allowed_models" valuePropName="checked" className="mb-1">
|
||||
<Checkbox>Allowed models</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item noStyle shouldUpdate>
|
||||
{({ getFieldValue }) =>
|
||||
getFieldValue("apply_allowed_models") && (
|
||||
<Form.Item name="allowed_models">
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder="Leave empty to inherit team models"
|
||||
options={(teamData.team_info.models || []).map((model) => ({ label: model, value: model }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
)
|
||||
}
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
import { getGlobalLitellmHeaderName, getProxyBaseUrl } from "@/components/networking";
|
||||
import { createApiClient } from "@/lib/http/client";
|
||||
|
||||
export interface TeamMemberBulkUpdateFields {
|
||||
role?: "admin" | "user" | null;
|
||||
max_budget_in_team?: number | null;
|
||||
tpm_limit?: number | null;
|
||||
rpm_limit?: number | null;
|
||||
budget_duration?: string | null;
|
||||
allowed_models?: string[] | null;
|
||||
}
|
||||
|
||||
const apiClient = createApiClient({
|
||||
getBaseUrl: getProxyBaseUrl,
|
||||
getAuthHeaderName: getGlobalLitellmHeaderName,
|
||||
});
|
||||
|
||||
export const teamMemberBulkUpdateCall = async (
|
||||
accessToken: string,
|
||||
teamId: string,
|
||||
userIds: string[],
|
||||
updateFields: TeamMemberBulkUpdateFields,
|
||||
) =>
|
||||
apiClient.patch(`/v2/team/${encodeURIComponent(teamId)}/members`, {
|
||||
accessToken,
|
||||
body: {
|
||||
user_ids: userIds,
|
||||
update_fields: updateFields,
|
||||
},
|
||||
});
|
||||
98
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
98
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -18943,6 +18943,23 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/v2/team/{team_id}/members": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
/** Bulk Update Team Members */
|
||||
patch: operations["bulk_update_team_members_v2_team__team_id__members_patch"];
|
||||
trace?: never;
|
||||
};
|
||||
"/v2/user/info": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -21481,6 +21498,28 @@ export interface components {
|
|||
[key: string]: unknown;
|
||||
} | null;
|
||||
};
|
||||
/** BulkTeamMemberUpdateRequest */
|
||||
BulkTeamMemberUpdateRequest: {
|
||||
/**
|
||||
* All Members In Team
|
||||
* @default false
|
||||
*/
|
||||
all_members_in_team: boolean;
|
||||
update_fields: components["schemas"]["TeamMemberBulkUpdateFields"];
|
||||
/** User Ids */
|
||||
user_ids?: string[] | null;
|
||||
};
|
||||
/** BulkTeamMemberUpdateResponse */
|
||||
BulkTeamMemberUpdateResponse: {
|
||||
/** Failed Updates */
|
||||
failed_updates: components["schemas"]["FailedTeamMemberUpdate"][];
|
||||
/** Successful Updates */
|
||||
successful_updates: components["schemas"]["TeamMemberUpdateResponse"][];
|
||||
/** Team Id */
|
||||
team_id: string;
|
||||
/** Total Requested */
|
||||
total_requested: number;
|
||||
};
|
||||
/**
|
||||
* BulkUpdateKeyRequest
|
||||
* @description Request for bulk key updates
|
||||
|
|
@ -23696,6 +23735,15 @@ export interface components {
|
|||
[key: string]: unknown;
|
||||
} | null;
|
||||
};
|
||||
/** FailedTeamMemberUpdate */
|
||||
FailedTeamMemberUpdate: {
|
||||
/** Failed Reason */
|
||||
failed_reason: string;
|
||||
/** User Email */
|
||||
user_email?: string | null;
|
||||
/** User Id */
|
||||
user_id: string;
|
||||
};
|
||||
/**
|
||||
* FallbackCreateRequest
|
||||
* @description Request model for creating/updating fallbacks
|
||||
|
|
@ -31355,6 +31403,21 @@ export interface components {
|
|||
/** User Id */
|
||||
user_id?: string | null;
|
||||
};
|
||||
/** TeamMemberBulkUpdateFields */
|
||||
TeamMemberBulkUpdateFields: {
|
||||
/** Allowed Models */
|
||||
allowed_models?: string[] | null;
|
||||
/** Budget Duration */
|
||||
budget_duration?: string | null;
|
||||
/** Max Budget In Team */
|
||||
max_budget_in_team?: number | null;
|
||||
/** Role */
|
||||
role?: ("admin" | "user") | null;
|
||||
/** Rpm Limit */
|
||||
rpm_limit?: number | null;
|
||||
/** Tpm Limit */
|
||||
tpm_limit?: number | null;
|
||||
};
|
||||
/** TeamMemberDeleteRequest */
|
||||
TeamMemberDeleteRequest: {
|
||||
/** Team Id */
|
||||
|
|
@ -57552,6 +57615,41 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
bulk_update_team_members_v2_team__team_id__members_patch: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
team_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["BulkTeamMemberUpdateRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["BulkTeamMemberUpdateResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
user_info_v2_v2_user_info_get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue