Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/management_endpoints/team_endpoints.py: 60%

2012 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-10 12:01 +0000

1""" 

2TEAM MANAGEMENT 

3 

4All /team management endpoints 

5 

6/team/new 

7/team/info 

8/team/update 

9/team/delete 

10""" 

11 

12import asyncio 

13import copy 

14import json 

15import math 

16import traceback 

17from collections.abc import Iterable, Mapping, Sequence 

18from collections.abc import Set as AbstractSet 

19from dataclasses import dataclass 

20from datetime import datetime, timezone 

21from types import MappingProxyType 

22from typing import ( 

23 TYPE_CHECKING, 

24 Annotated, 

25 Final, 

26 Literal, 

27 NamedTuple, 

28 NoReturn, 

29 Protocol, 

30 TypeAlias, 

31 TypeVar, 

32 cast, 

33) 

34 

35import fastapi 

36from fastapi import APIRouter, Depends, Header, HTTPException, Request, status 

37from pydantic import BaseModel, JsonValue, TypeAdapter, ValidationError 

38from typing_extensions import ReadOnly, TypedDict, assert_never 

39 

40import litellm 

41from litellm._logging import verbose_proxy_logger 

42from litellm._uuid import uuid 

43from litellm.integrations.prometheus import PrometheusLogger 

44from litellm.litellm_core_utils.safe_json_dumps import safe_dumps 

45from litellm.proxy._types import ( 

46 UI_TEAM_ID, 

47 BlockTeamRequest, 

48 BudgetNewRequest, 

49 CommonProxyErrors, 

50 DeleteTeamRequest, 

51 LiteLLM_AuditLogs, 

52 LiteLLM_DeletedTeamTable, 

53 Litellm_EntityType, 

54 LiteLLM_ManagementEndpoint_MetadataFields, 

55 LiteLLM_ManagementEndpoint_MetadataFields_Premium, 

56 LiteLLM_ModelTable, 

57 LiteLLM_OrganizationTable, 

58 LiteLLM_OrganizationTableWithMembers, 

59 LiteLLM_TeamMembership, 

60 LiteLLM_TeamTable, 

61 LiteLLM_TeamTableCachedObj, 

62 LiteLLM_UserTable, 

63 LitellmTableNames, 

64 LitellmUserRoles, 

65 Member, 

66 NewTeamRequest, 

67 OrgMember, 

68 PatchTeamRequest, 

69 ProxyErrorTypes, 

70 ProxyException, 

71 ResetSpendRequest, 

72 SpecialManagementEndpointEnums, 

73 SpecialModelNames, 

74 SpecialProxyStrings, 

75 TeamAccessGroupModelGrant, 

76 TeamAddMemberResponse, 

77 TeamEditAccess, 

78 TeamEditAsTeamAdmin, 

79 TeamEditAsTeamAdminDisabled, 

80 TeamEditNone, 

81 TeamEditUnrestricted, 

82 TeamInfoMember, 

83 TeamInfoMembership, 

84 TeamInfoResponseObject, 

85 TeamInfoResponseObjectTeamTable, 

86 TeamListResponseObject, 

87 TeamMemberAddRequest, 

88 TeamMemberBudgetSource, 

89 TeamMemberDeleteRequest, 

90 TeamMemberResetBudgetResponse, 

91 TeamMemberUpdateRequest, 

92 TeamMemberUpdateResponse, 

93 TeamModelAddRequest, 

94 TeamModelDeleteRequest, 

95 UpdateTeamRequest, 

96 UserAPIKeyAuth, 

97) 

98from litellm.proxy.auth.auth_checks import ( 

99 OrganizationNotFoundError, 

100 _cache_team_object, 

101 allowed_route_check_inside_route, 

102 can_org_access_model, 

103 delete_cache_key_objects, 

104 delete_cache_team_object, 

105 get_jwt_key_mapping_cache_keys_for_tokens, 

106 get_org_object, 

107 get_team_membership, 

108 get_team_object, 

109 get_user_object, 

110 invalidate_team_member_spend_state, 

111) 

112from litellm.proxy.auth.auth_utils import ( 

113 enforce_batch_enqueued_token_limit_is_admin_only, 

114 enforce_output_token_estimates_are_admin_only, 

115) 

116from litellm.proxy.auth.user_api_key_auth import user_api_key_auth 

117from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import evict_and_broadcast 

118from litellm.proxy.common_utils.callback_utils import encrypt_callback_vars 

119from litellm.proxy.common_utils.json_merge_patch import apply_json_merge_patch 

120from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache 

121from litellm.proxy.hooks.key_management_event_hooks import KeyManagementEventHooks 

122from litellm.proxy.hooks.model_max_budget_limiter import ( 

123 build_model_max_budget_usage, 

124 resolve_model_budget, 

125) 

126from litellm.proxy.management_endpoints.common_daily_activity import ( 

127 get_daily_activity_aggregated, 

128) 

129from litellm.proxy.management_endpoints.common_utils import ( 

130 _check_disable_global_guardrails_caller_permission, 

131 _check_passthrough_routes_caller_permission, 

132 _is_user_org_admin_for_team, 

133 _is_user_team_admin, 

134 _set_object_metadata_field, 

135 _team_member_has_permission, 

136 _update_metadata_fields, 

137 _upsert_budget_and_membership, 

138 _user_has_admin_view, 

139 member_budget_patch, 

140 validate_budget_duration, 

141 validate_team_model_max_budget, 

142) 

143from litellm.proxy.management_endpoints.organization_endpoints import ( 

144 add_member_to_organization, 

145) 

146from litellm.proxy.management_endpoints.router_weights import validate_router_settings_weights 

147from litellm.proxy.management_endpoints.tag_management_endpoints import ( 

148 get_daily_activity, 

149) 

150from litellm.proxy.management_endpoints.team_admin_field_permissions import ( 

151 SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS, 

152 resolve_team_admin_editable_fields, 

153 team_admin_edit_verdict, 

154 team_admin_request_or_raise, 

155) 

156from litellm.proxy.management_helpers.access_group_team_sync import ( 

157 TEAM_ADVISORY_LOCK_SQL, 

158 AccessGroupSyncTx, 

159 invalidate_access_group_caches, 

160 reconcile_team_access_group_membership, 

161 sync_team_access_group_membership, 

162) 

163from litellm.proxy.management_helpers.object_permission_utils import ( 

164 _set_object_permission, 

165 enforce_all_proxy_mcp_servers_grant_is_admin_only, 

166 handle_update_object_permission_common, 

167) 

168from litellm.proxy.management_helpers.team_member_permission_checks import ( 

169 TeamMemberPermissionChecks, 

170) 

171from litellm.proxy.management_helpers.team_metadata_validation import ( 

172 TEAM_METADATA_SCHEMA_REGISTRY, 

173 validate_team_metadata_if_configured, 

174) 

175from litellm.proxy.management_helpers.utils import ( 

176 MemberWriteTx, 

177 add_new_member, 

178 management_endpoint_wrapper, 

179) 

180from litellm.proxy.utils import PrismaClient, ProxyLogging, handle_exception_on_proxy 

181from litellm.repositories.budget_repository import BudgetRepository 

182from litellm.repositories.organization_repository import OrganizationRepository 

183from litellm.repositories.prisma_protocols import TableActions 

184from litellm.repositories.table_repositories import ( 

185 AccessGroupRepository, 

186 DeletedTeamRepository, 

187 ModelTableRepository, 

188 OrganizationMembershipRepository, 

189 TeamMembershipRepository, 

190) 

191from litellm.repositories.team_repository import TeamRepository 

192from litellm.repositories.user_repository import UserRepository 

193from litellm.repositories.verification_token_repository import ( 

194 VerificationTokenRepository, 

195) 

196from litellm.router import Router 

197from litellm.types.proxy.auth.auth_checks import UserNotFoundError 

198from litellm.types.proxy.management_endpoints.common_daily_activity import ( 

199 SpendAnalyticsPaginatedResponse, 

200) 

201from litellm.types.proxy.management_endpoints.team_endpoints import ( 

202 BulkTeamMemberAddRequest, 

203 BulkTeamMemberAddResponse, 

204 BulkUpdateTeamMemberPermissionsRequest, 

205 BulkUpdateTeamMemberPermissionsResponse, 

206 GetTeamMemberPermissionsResponse, 

207 TeamIdSearchMatch, 

208 TeamListItem, 

209 TeamListResponse, 

210 TeamMemberAddResult, 

211 TeamMemberInfoResponse, 

212 TeamMetadataSchemaResponse, 

213 TeamUserSpendResponse, 

214 TeamUserSpendRow, 

215 UpdateTeamMemberPermissionsRequest, 

216) 

217from litellm.types.utils import BudgetConfig 

218 

219if TYPE_CHECKING: 219 ↛ 220line 219 didn't jump to line 220 because the condition on line 219 was never true

220 from prisma import Prisma 

221 from prisma import models as prisma_models 

222 from prisma import types as prisma_types 

223 

224router: Final = APIRouter() 

225 

226_DbRecordT = TypeVar("_DbRecordT") 

227 

228 

229class _TeamIdKeyCount(TypedDict): 

230 team_id: int 

231 

232 

233class _TeamIdGroupRow(TypedDict): 

234 team_id: str 

235 _count: _TeamIdKeyCount 

236 

237 

238def _as_object(value: object) -> object: 

239 return value 

240 

241 

242def _as_list(rows: Sequence[_DbRecordT]) -> list[_DbRecordT]: # mutable-ok: pydantic list[...] fields reject Sequence 

243 return cast( # cast-ok: prisma-client-py find_many returns a list; TableActions only widens it to Sequence 

244 "list[_DbRecordT]", rows 

245 ) 

246 

247 

248class _UserIdRow(Protocol): 

249 @property 

250 def user_id(self) -> str | None: ... 250 ↛ exitline 250 didn't return from function 'user_id' because

251 

252 

253def _user_id_rows_db(repo: UserRepository) -> "TableActions[_UserIdRow]": 

254 return repo.table 

255 

256 

257class _ModelDumpRow(Protocol): 

258 def model_dump(self) -> Mapping[str, object]: ... 258 ↛ exitline 258 didn't return from function 'model_dump' because

259 

260 

261class _TeamIdRow(Protocol): 

262 @property 

263 def team_id(self) -> str: ... 263 ↛ exitline 263 didn't return from function 'team_id' because

264 

265 

266class _CacheableTeamRow(_TeamIdRow, _ModelDumpRow, Protocol): ... 

267 

268 

269class _ObjectPermissionRow(Protocol): 

270 @property 

271 def object_permission_id(self) -> str | None: ... 271 ↛ exitline 271 didn't return from function 'object_permission_id' because

272 

273 

274class _TeamAliasBudgetRow(Protocol): 

275 @property 

276 def team_alias(self) -> str | None: ... 276 ↛ exitline 276 didn't return from function 'team_alias' because

277 

278 @property 

279 def budget_duration(self) -> str | None: ... 279 ↛ exitline 279 didn't return from function 'budget_duration' because

280 

281 

282class _TeamBudgetRow(_TeamAliasBudgetRow, Protocol): 

283 metadata: Mapping[str, JsonValue] | None 

284 

285 

286class _AuditableTeamRow(Protocol): 

287 def json(self, *, exclude_none: bool = False) -> str: ... 287 ↛ exitline 287 didn't return from function 'json' because

288 

289 

290class _RawTeamRow(_TeamIdRow, _ModelDumpRow, _ObjectPermissionRow, _TeamBudgetRow, _AuditableTeamRow, Protocol): 

291 @property 

292 def members_with_roles( 292 ↛ exitline 292 didn't return from function 'members_with_roles' because

293 self, 

294 ) -> Sequence[dict[str, object]] | None: ... # mutable-ok: prisma deserializes this JSON column into plain dicts 

295 

296 @property 

297 def organization_id(self) -> str | None: ... 297 ↛ exitline 297 didn't return from function 'organization_id' because

298 

299 @property 

300 def max_budget(self) -> float | None: ... 300 ↛ exitline 300 didn't return from function 'max_budget' because

301 

302 @property 

303 def soft_budget(self) -> float | None: ... 303 ↛ exitline 303 didn't return from function 'soft_budget' because

304 

305 @property 

306 def model_id(self) -> int | None: ... 306 ↛ exitline 306 didn't return from function 'model_id' because

307 

308 

309def _raw_team_db(repo: TeamRepository) -> "TableActions[_RawTeamRow]": 

310 return cast( # cast-ok: prisma types Json columns as str; the client hands back the deserialized value 

311 "TableActions[_RawTeamRow]", repo.table 

312 ) 

313 

314 

315class _BudgetIdRow(Protocol): 

316 @property 

317 def budget_id(self) -> str: ... 317 ↛ exitline 317 didn't return from function 'budget_id' because

318 

319 

320class _BudgetWriteCall(Protocol): 

321 async def __call__(self, budget_obj: BudgetNewRequest, user_api_key_dict: UserAPIKeyAuth) -> _BudgetIdRow: ... 321 ↛ exitline 321 didn't return from function '__call__' because

322 

323 

324def _as_budget_write(fn: "_BudgetWriteCall") -> "_BudgetWriteCall": 

325 return fn 

326 

327 

328class _TeamFindManyArgs(TypedDict, total=False): 

329 take: int 

330 skip: int 

331 order: Mapping[str, str] 

332 cursor: Mapping[str, object] 

333 

334 

335class _TeamUiViewFilters(TypedDict, total=False): 

336 team_id: Mapping[str, str] 

337 team_alias: Mapping[str, str] 

338 

339 

340class _TeamIdInFilter(TypedDict, total=False): 

341 team_id: Mapping[str, Sequence[str]] 

342 

343 

344class _DeletedTeamsResult(TypedDict): 

345 deleted_teams: ReadOnly[Sequence[str]] 

346 

347 

348class _ErrorDetail(TypedDict): 

349 error: ReadOnly[str] 

350 

351 

352class _TeamIdWhere(TypedDict): 

353 team_id: ReadOnly[str] 

354 

355 

356class _TeamIdAndBudgetWhere(_TeamIdWhere): 

357 max_budget: ReadOnly[float | None] 

358 

359 

360class _TeamCreateTx(AccessGroupSyncTx, Protocol): 

361 @property 

362 def litellm_teamtable(self) -> "TableActions[prisma_models.LiteLLM_TeamTable]": ... 362 ↛ exitline 362 didn't return from function 'litellm_teamtable' because

363 

364 

365class _MemberDeleteTx(Protocol): 

366 """The tables `/team/member_delete` reads while it holds the team's advisory lock. 

367 

368 Reading them off the transaction keeps the whole endpoint on the one pooled connection 

369 it already checked out: a request that has the lock but still needs another connection 

370 can be starved by the lock waiters, which is a deadlock rather than a wait when enough 

371 of them hold the rest of the pool.""" 

372 

373 @property 

374 def litellm_usertable(self) -> "TableActions[prisma_models.LiteLLM_UserTable]": ... 374 ↛ exitline 374 didn't return from function 'litellm_usertable' because

375 

376 @property 

377 def litellm_verificationtoken(self) -> "TableActions[prisma_models.LiteLLM_VerificationToken]": ... 377 ↛ exitline 377 didn't return from function 'litellm_verificationtoken' because

378 

379 

380class _TeamDeleteTx(AccessGroupSyncTx, Protocol): 

381 async def execute_raw(self, query: str, *args: object) -> int: ... 381 ↛ exitline 381 didn't return from function 'execute_raw' because

382 

383 @property 

384 def litellm_teamtable(self) -> "TableActions[prisma_models.LiteLLM_TeamTable]": ... 384 ↛ exitline 384 didn't return from function 'litellm_teamtable' because

385 

386 @property 

387 def litellm_teammembership(self) -> "TableActions[prisma_models.LiteLLM_TeamMembership]": ... 387 ↛ exitline 387 didn't return from function 'litellm_teammembership' because

388 

389 

390_STRIP_DELETED_TEAM_FROM_USERS_SQL: Final = """ 

391UPDATE "LiteLLM_UserTable" SET teams = array_remove(teams, $1) WHERE $1 = ANY(teams) 

392""" 

393 

394_INCLUDE_MODEL_TABLE: Final = MappingProxyType({"litellm_model_table": True}) 

395 

396 

397def _team_db(prisma_client: PrismaClient | None) -> "TableActions[prisma_models.LiteLLM_TeamTable]": 

398 return TeamRepository(prisma_client).table 

399 

400 

401def _team_tx_db(tx: "Prisma") -> "TableActions[prisma_models.LiteLLM_TeamTable]": 

402 return cast( # cast-ok: generated actions type Json columns as str; TableActions widens inputs to Mapping 

403 "TableActions[prisma_models.LiteLLM_TeamTable]", tx.litellm_teamtable 

404 ) 

405 

406 

407def _team_membership_db(prisma_client: PrismaClient | None) -> "TableActions[prisma_models.LiteLLM_TeamMembership]": 

408 return TeamMembershipRepository(prisma_client).table 

409 

410 

411def _user_db(prisma_client: PrismaClient | None) -> "TableActions[prisma_models.LiteLLM_UserTable]": 

412 return UserRepository(prisma_client).table 

413 

414 

415def _model_db(prisma_client: PrismaClient | None) -> "TableActions[prisma_models.LiteLLM_ModelTable]": 

416 return ModelTableRepository(prisma_client).table 

417 

418 

419def _org_db(prisma_client: PrismaClient | None) -> "TableActions[prisma_models.LiteLLM_OrganizationTable]": 

420 return OrganizationRepository(prisma_client).table 

421 

422 

423def _org_membership_db( 

424 prisma_client: PrismaClient | None, 

425) -> "TableActions[prisma_models.LiteLLM_OrganizationMembership]": 

426 return OrganizationMembershipRepository(prisma_client).table 

427 

428 

429def _budget_db(prisma_client: PrismaClient | None) -> "TableActions[prisma_models.LiteLLM_BudgetTable]": 

430 return BudgetRepository(prisma_client).table 

431 

432 

433def _deleted_team_db(prisma_client: PrismaClient | None) -> "TableActions[prisma_models.LiteLLM_DeletedTeamTable]": 

434 return DeletedTeamRepository(prisma_client).table 

435 

436 

437def _access_group_db(prisma_client: PrismaClient | None) -> "TableActions[prisma_models.LiteLLM_AccessGroupTable]": 

438 return AccessGroupRepository(prisma_client).table 

439 

440 

441def _tokens_db(prisma_client: PrismaClient | None) -> "TableActions[prisma_models.LiteLLM_VerificationToken]": 

442 return VerificationTokenRepository(prisma_client).table 

443 

444 

445def _sanitize_for_log(value: object) -> str: 

446 """Strip CR/LF from user-controlled values to prevent log injection.""" 

447 try: 

448 text = str(value) 

449 except Exception: 

450 text = repr(value) 

451 return text.replace("\r", "").replace("\n", "") 

452 

453 

454async def _refresh_cached_team( 

455 team_row: _CacheableTeamRow, 

456 user_api_key_cache: UserApiKeyCache, 

457 proxy_logging_obj: ProxyLogging, 

458) -> None: 

459 """ 

460 Refresh the in-memory cached team object after a DB write. 

461 

462 Every endpoint that mutates `litellm_teamtable` must call this so the 

463 cached `LiteLLM_TeamTableCachedObj` used by `common_checks` stays in 

464 sync. Without this, subsequent auth checks read a stale team and can 

465 403 on permissions the DB has already granted (or, symmetrically, 

466 keep granting permissions the DB has already revoked). 

467 

468 `team_row` is the Prisma row returned by `update`/`find_unique` on 

469 `litellm_teamtable`. It is converted to `LiteLLM_TeamTableCachedObj` 

470 via `model_dump()` to match the cache shape `_cache_team_object` 

471 expects. 

472 """ 

473 await _cache_team_object( 

474 team_id=team_row.team_id, 

475 team_table=LiteLLM_TeamTableCachedObj.model_validate(team_row.model_dump()), 

476 user_api_key_cache=user_api_key_cache, 

477 proxy_logging_obj=proxy_logging_obj, 

478 ) 

479 

480 

481TeamAccessRole: TypeAlias = Literal["proxy_admin", "org_admin", "team_admin"] 

482 

483 

484def _raise_team_access_denied() -> NoReturn: 

485 raise HTTPException( 

486 status_code=status.HTTP_403_FORBIDDEN, 

487 detail="You do not have access to this team", 

488 ) 

489 

490 

491async def _resolve_team_access( 

492 team_obj: LiteLLM_TeamTable, 

493 user_api_key_dict: UserAPIKeyAuth, 

494) -> TeamAccessRole | None: 

495 """Strongest role the caller holds over ``team_obj``, or None when they hold none. 

496 

497 Org admin outranks team admin so a caller holding both keeps unrestricted edits. 

498 """ 

499 if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN: 499 ↛ 502line 499 didn't jump to line 502 because the condition on line 499 was always true

500 return "proxy_admin" 

501 

502 if await _is_user_org_admin_for_team(user_api_key_dict=user_api_key_dict, team_obj=team_obj): 

503 return "org_admin" 

504 

505 if _is_user_team_admin(user_api_key_dict=user_api_key_dict, team_obj=team_obj): 

506 return "team_admin" 

507 

508 return None 

509 

510 

511async def _verify_team_access( 

512 team_obj: LiteLLM_TeamTable, 

513 user_api_key_dict: UserAPIKeyAuth, 

514) -> None: 

515 """Raise 403 unless the caller is a proxy admin, an org admin for the team's org, or a team admin.""" 

516 if await _resolve_team_access(team_obj=team_obj, user_api_key_dict=user_api_key_dict) is None: 516 ↛ 517line 516 didn't jump to line 517 because the condition on line 516 was never true

517 _raise_team_access_denied() 

518 

519 

520_GENERAL_SETTINGS: Final = TypeAdapter(dict[str, object]) 

521 

522 

523def _general_settings() -> Mapping[str, object]: 

524 from litellm.proxy.proxy_server import general_settings 

525 

526 return _GENERAL_SETTINGS.validate_python(general_settings) 

527 

528 

529def _caller_edit_access(role: TeamAccessRole | None, general_settings: Mapping[str, object]) -> TeamEditAccess: 

530 """What the caller may change on /team/update, reported on /team/info so the dashboard never re-derives it.""" 

531 match role: 

532 case "proxy_admin" | "org_admin": 532 ↛ 534line 532 didn't jump to line 534 because the pattern on line 532 always matched

533 return TeamEditUnrestricted() 

534 case "team_admin": 

535 permitted: Final = resolve_team_admin_editable_fields( 

536 general_settings, SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS 

537 ) 

538 if not permitted: 

539 return TeamEditAsTeamAdminDisabled() 

540 return TeamEditAsTeamAdmin(editable_fields=tuple(sorted(permitted))) 

541 case None: 

542 return TeamEditNone() 

543 case _: 

544 assert_never(role) 

545 

546 

547class TeamMemberBudgetHandler: 

548 """Helper class to handle team member budget, RPM, and TPM limit operations""" 

549 

550 # Metadata keys that are owned and set by the server. Callers must not be 

551 # able to inject or overwrite these via request payloads. 

552 SYSTEM_MANAGED_METADATA_KEYS = ("team_member_budget_id",) 

553 

554 @staticmethod 

555 def strip_system_managed_metadata_keys(metadata: dict | None) -> None: 

556 """Remove server-owned metadata keys from a caller-supplied dict.""" 

557 if not isinstance(metadata, dict): 557 ↛ 558line 557 didn't jump to line 558 because the condition on line 557 was never true

558 return 

559 for key in TeamMemberBudgetHandler.SYSTEM_MANAGED_METADATA_KEYS: 

560 metadata.pop(key, None) 

561 

562 @staticmethod 

563 def should_create_budget( 

564 team_member_budget: float | None = None, 

565 team_member_rpm_limit: int | None = None, 

566 team_member_tpm_limit: int | None = None, 

567 team_member_budget_duration: str | None = None, 

568 ) -> bool: 

569 """Check if any team member limits are provided""" 

570 return any( 

571 [ 

572 team_member_budget is not None, 

573 team_member_rpm_limit is not None, 

574 team_member_tpm_limit is not None, 

575 team_member_budget_duration is not None, 

576 ] 

577 ) 

578 

579 @staticmethod 

580 async def create_team_member_budget_table( 

581 data: NewTeamRequest | _TeamAliasBudgetRow, 

582 new_team_data_json: dict, 

583 user_api_key_dict: UserAPIKeyAuth, 

584 team_member_budget: float | None = None, 

585 team_member_rpm_limit: int | None = None, 

586 team_member_tpm_limit: int | None = None, 

587 team_member_budget_duration: str | None = None, 

588 explicitly_set_fields: AbstractSet[str] = frozenset(), 

589 ) -> dict: 

590 """Create team member budget table with provided limits. 

591 

592 The team's own reset period is only inherited when the caller left the 

593 member duration out, so an explicit null means "never resets". 

594 """ 

595 from litellm.proxy._types import BudgetNewRequest 

596 from litellm.proxy.management_endpoints.budget_management_endpoints import ( 

597 new_budget, 

598 ) 

599 

600 if data.team_alias is not None: 600 ↛ 601line 600 didn't jump to line 601 because the condition on line 600 was never true

601 budget_id = f"team-{data.team_alias.replace(' ', '-')}-budget-{uuid.uuid4().hex}" 

602 else: 

603 budget_id = f"team-budget-{uuid.uuid4().hex}" 

604 

605 # Create budget request with all provided limits 

606 budget_request: Final = BudgetNewRequest( 

607 budget_id=budget_id, 

608 budget_duration=( 

609 team_member_budget_duration 

610 if "team_member_budget_duration" in explicitly_set_fields 

611 else data.budget_duration or team_member_budget_duration 

612 ), 

613 ) 

614 

615 if team_member_budget is not None: 

616 budget_request.max_budget = team_member_budget 

617 if team_member_rpm_limit is not None: 

618 budget_request.rpm_limit = team_member_rpm_limit 

619 if team_member_tpm_limit is not None: 

620 budget_request.tpm_limit = team_member_tpm_limit 

621 if team_member_budget_duration is not None: 621 ↛ 622line 621 didn't jump to line 622 because the condition on line 621 was never true

622 budget_request.budget_duration = team_member_budget_duration 

623 

624 team_member_budget_table: Final = await _as_budget_write(new_budget)( 

625 budget_obj=budget_request, 

626 user_api_key_dict=user_api_key_dict, 

627 ) 

628 

629 # Add team_member_budget_id as metadata field to team table 

630 if new_team_data_json.get("metadata") is None: 

631 new_team_data_json["metadata"] = {} 

632 new_team_data_json["metadata"]["team_member_budget_id"] = team_member_budget_table.budget_id 

633 

634 # Remove team member fields from new_team_data_json 

635 TeamMemberBudgetHandler._clean_team_member_fields(new_team_data_json) 

636 

637 return new_team_data_json 

638 

639 @staticmethod 

640 async def upsert_team_member_budget_table( 

641 team_table: _TeamBudgetRow, 

642 user_api_key_dict: UserAPIKeyAuth, 

643 updated_kv: dict, 

644 team_member_budget: float | None = None, 

645 team_member_rpm_limit: int | None = None, 

646 team_member_tpm_limit: int | None = None, 

647 team_member_budget_duration: str | None = None, 

648 explicitly_set_fields: AbstractSet[str] = frozenset(), 

649 ) -> dict: 

650 """Upsert team member budget table with provided limits. 

651 

652 A field the caller explicitly sent as null is written as null, so a 

653 team can keep a member budget while dropping its reset period. 

654 """ 

655 from litellm.proxy._types import BudgetNewRequest 

656 from litellm.proxy.management_endpoints.budget_management_endpoints import ( 

657 update_budget, 

658 ) 

659 

660 if team_table.metadata is None: 660 ↛ 661line 660 didn't jump to line 661 because the condition on line 660 was never true

661 team_table.metadata = {} 

662 

663 team_member_budget_id: Final = team_table.metadata.get("team_member_budget_id") 

664 if team_member_budget_id is not None and isinstance(team_member_budget_id, str): 664 ↛ 666line 664 didn't jump to line 666 because the condition on line 664 was never true

665 # Budget exists - create update request with only provided values 

666 budget_request: Final = BudgetNewRequest(budget_id=team_member_budget_id) 

667 

668 if team_member_budget is not None or "team_member_budget" in explicitly_set_fields: 

669 budget_request.max_budget = team_member_budget 

670 if team_member_rpm_limit is not None or "team_member_rpm_limit" in explicitly_set_fields: 

671 budget_request.rpm_limit = team_member_rpm_limit 

672 if team_member_tpm_limit is not None or "team_member_tpm_limit" in explicitly_set_fields: 

673 budget_request.tpm_limit = team_member_tpm_limit 

674 if team_member_budget_duration is not None or "team_member_budget_duration" in explicitly_set_fields: 

675 budget_request.budget_duration = team_member_budget_duration 

676 if team_member_budget_duration is None: 

677 budget_request.budget_reset_at = None 

678 

679 budget_row: Final = await _as_budget_write(update_budget)( 

680 budget_obj=budget_request, 

681 user_api_key_dict=user_api_key_dict, 

682 ) 

683 verbose_proxy_logger.info( 

684 "Updated team member budget table: %s, with team_member_budget=%s, team_member_rpm_limit=%s, team_member_tpm_limit=%s", 

685 budget_row.budget_id, 

686 team_member_budget, 

687 team_member_rpm_limit, 

688 team_member_tpm_limit, 

689 ) 

690 if updated_kv.get("metadata") is None: 

691 updated_kv["metadata"] = {} 

692 updated_kv["metadata"]["team_member_budget_id"] = budget_row.budget_id 

693 

694 else: # budget does not exist 

695 updated_kv = await TeamMemberBudgetHandler.create_team_member_budget_table( 

696 data=team_table, 

697 new_team_data_json=updated_kv, 

698 user_api_key_dict=user_api_key_dict, 

699 team_member_budget=team_member_budget, 

700 team_member_rpm_limit=team_member_rpm_limit, 

701 team_member_tpm_limit=team_member_tpm_limit, 

702 team_member_budget_duration=team_member_budget_duration, 

703 explicitly_set_fields=explicitly_set_fields, 

704 ) 

705 

706 # Remove team member fields from updated_kv 

707 TeamMemberBudgetHandler._clean_team_member_fields(updated_kv) 

708 return updated_kv 

709 

710 @staticmethod 

711 def _clean_team_member_fields(data_dict: dict) -> None: 

712 """Remove team member fields from data dictionary""" 

713 data_dict.pop("team_member_budget", None) 

714 data_dict.pop("team_member_budget_duration", None) 

715 data_dict.pop("team_member_rpm_limit", None) 

716 data_dict.pop("team_member_tpm_limit", None) 

717 

718 @staticmethod 

719 async def clear_team_member_budget_fields( 

720 team_table: _TeamBudgetRow, 

721 user_api_key_dict: "UserAPIKeyAuth", 

722 updated_kv: dict, 

723 explicitly_set_fields: set, 

724 ) -> dict: 

725 """Clear explicitly-nulled fields on the team member budget row.""" 

726 from litellm.proxy._types import BudgetNewRequest 

727 from litellm.proxy.management_endpoints.budget_management_endpoints import ( 

728 update_budget, 

729 ) 

730 

731 if team_table.metadata is None: 731 ↛ 732line 731 didn't jump to line 732 because the condition on line 731 was never true

732 team_table.metadata = {} 

733 

734 team_member_budget_id: Final = team_table.metadata.get("team_member_budget_id") 

735 if team_member_budget_id is not None and isinstance(team_member_budget_id, str): 735 ↛ 736line 735 didn't jump to line 736 because the condition on line 735 was never true

736 budget_request: Final = BudgetNewRequest(budget_id=team_member_budget_id) 

737 if "team_member_budget" in explicitly_set_fields: 

738 budget_request.max_budget = None 

739 if "team_member_budget_duration" in explicitly_set_fields: 

740 budget_request.budget_duration = None 

741 budget_request.budget_reset_at = None 

742 if "team_member_rpm_limit" in explicitly_set_fields: 

743 budget_request.rpm_limit = None 

744 if "team_member_tpm_limit" in explicitly_set_fields: 

745 budget_request.tpm_limit = None 

746 await update_budget( 

747 budget_obj=budget_request, 

748 user_api_key_dict=user_api_key_dict, 

749 ) 

750 

751 TeamMemberBudgetHandler._clean_team_member_fields(updated_kv) 

752 return updated_kv 

753 

754 @staticmethod 

755 async def backfill_team_member_budget_entries( 

756 team_id: str, 

757 members_with_roles: Sequence[Member | dict[str, object]], 

758 team_member_budget_id: str, 

759 prisma_client: PrismaClient, 

760 ) -> None: 

761 """ 

762 Ensure every team member has a TeamMembership row linked to the 

763 team_member_budget. 

764 

765 Called after team_member_budget is set/updated on a team. Creates 

766 rows for members who don't have one, and populates budget_id on 

767 existing rows where it is NULL. Rows with a non-NULL budget_id 

768 are left untouched, which preserves per-member overrides but also 

769 means rows pointing to a prior team-default budget_id are not 

770 migrated to the new one. 

771 """ 

772 if not members_with_roles: 772 ↛ 773line 772 didn't jump to line 773 because the condition on line 772 was never true

773 return 

774 

775 # Batch-fetch existing memberships for this team (avoids N+1 queries) 

776 existing_memberships: Final = await _team_membership_db(prisma_client).find_many(where={"team_id": team_id}) 

777 existing_user_ids: Final = {m.user_id for m in existing_memberships} 

778 

779 # Identify members with no existing membership row. 

780 # members_with_roles may contain Member instances or raw dicts depending 

781 # on how the team was fetched/deserialized. 

782 missing: Final = [] 

783 for m in members_with_roles: 

784 user_id = m.get("user_id") if isinstance(m, dict) else m.user_id 

785 if user_id is not None and user_id not in existing_user_ids: 785 ↛ 786line 785 didn't jump to line 786 because the condition on line 785 was never true

786 missing.append( 

787 { 

788 "team_id": team_id, 

789 "user_id": user_id, 

790 "budget_id": team_member_budget_id, 

791 } 

792 ) 

793 

794 if missing: 794 ↛ 795line 794 didn't jump to line 795 because the condition on line 794 was never true

795 await _team_membership_db(prisma_client).create_many( 

796 data=missing, 

797 skip_duplicates=True, # safety net against concurrent races 

798 ) 

799 verbose_proxy_logger.info( 

800 "Backfilled %d team_memberships for team %s with budget %s", 

801 len(missing), 

802 _sanitize_for_log(team_id), 

803 _sanitize_for_log(team_member_budget_id), 

804 ) 

805 

806 # Heal existing membership rows that predate the team_member_budget 

807 # configuration: populate budget_id where it is currently NULL. 

808 # Rows with an explicit budget_id (per-member override) are left alone. 

809 updated: Final = await _team_membership_db(prisma_client).update_many( 

810 where={"team_id": team_id, "budget_id": None}, 

811 data={"budget_id": team_member_budget_id}, 

812 ) 

813 if updated: 813 ↛ exitline 813 didn't return from function 'backfill_team_member_budget_entries' because the condition on line 813 was always true

814 verbose_proxy_logger.info( 

815 "Populated budget_id on %d existing team_memberships for team %s with budget %s", 

816 updated, 

817 _sanitize_for_log(team_id), 

818 _sanitize_for_log(team_member_budget_id), 

819 ) 

820 

821 

822def _get_default_team_param(field: str) -> object: 

823 """ 

824 Returns a default value for the given field from litellm.default_team_params config. 

825 Returns None if no default is configured. 

826 

827 For list fields containing enums (e.g. team_member_permissions), converts enum values to strings. 

828 """ 

829 default_params: Final = litellm.default_team_params 

830 if default_params is None: 830 ↛ 831line 830 didn't jump to line 831 because the condition on line 830 was never true

831 return None 

832 if isinstance(default_params, dict): 832 ↛ 835line 832 didn't jump to line 835 because the condition on line 832 was always true

833 value = default_params.get(field) 

834 else: 

835 value = getattr(default_params, field, None) 

836 if value is None: 

837 return None 

838 # Convert enum values in lists to strings 

839 if isinstance(value, list): 

840 return [v.value if hasattr(v, "value") else v for v in value] 

841 return value 

842 

843 

844def _is_available_team(team_id: str, user_api_key_dict: UserAPIKeyAuth) -> bool: 

845 if litellm.default_internal_user_params is None: 

846 return False 

847 if "available_teams" in litellm.default_internal_user_params: 

848 return team_id in litellm.default_internal_user_params["available_teams"] 

849 return False 

850 

851 

852async def get_all_team_memberships( 

853 prisma_client: PrismaClient, team_ids: list[str], user_id: str | None = None 

854) -> list[LiteLLM_TeamMembership]: 

855 """Get all team memberships for a given user""" 

856 ## GET ALL MEMBERSHIPS ## 

857 where_obj: Final[dict[str, dict[str, list[str]]]] = {"team_id": {"in": team_ids}} 

858 if user_id is not None: 

859 where_obj["user_id"] = {"in": [user_id]} 

860 # if user_id is None: 

861 # where_obj = {"team_id": {"in": team_id}} 

862 # else: 

863 # where_obj = {"user_id": str(user_id), "team_id": {"in": team_id}} 

864 

865 team_memberships: Final = await _team_membership_db(prisma_client).find_many( 

866 where=where_obj, 

867 include={"litellm_budget_table": True}, 

868 ) 

869 

870 returned_tm: Final[list[LiteLLM_TeamMembership]] = [] 

871 for tm in team_memberships: 

872 returned_tm.append(LiteLLM_TeamMembership.model_validate(tm.model_dump())) 

873 

874 return returned_tm 

875 

876 

877def _check_team_model_specific_limits( 

878 teams: list[LiteLLM_TeamTable], 

879 data: NewTeamRequest | UpdateTeamRequest, 

880 entity_rpm_limit: int | None, 

881 entity_tpm_limit: int | None, 

882 entity_model_rpm_limit_dict: dict[str, int], 

883 entity_model_tpm_limit_dict: dict[str, int], 

884 entity_type: str, # "organization" 

885) -> None: 

886 """ 

887 Generic function to check if a team is allocating model specific limits. 

888 Raises an error if we're overallocating. 

889 """ 

890 model_rpm_limit: Final = getattr(data, "model_rpm_limit", None) or ( 

891 data.metadata.get("model_rpm_limit", None) if data.metadata else None 

892 ) 

893 model_tpm_limit: Final = getattr(data, "model_tpm_limit", None) or ( 

894 data.metadata.get("model_tpm_limit", None) if data.metadata else None 

895 ) 

896 if model_rpm_limit is None and model_tpm_limit is None: 

897 return 

898 

899 # get total model specific tpm/rpm limit 

900 model_specific_rpm_limit: Final[dict[str, int]] = {} 

901 model_specific_tpm_limit: Final[dict[str, int]] = {} 

902 

903 for team in teams: 

904 if team.metadata and team.metadata.get("model_rpm_limit", None) is not None: 

905 for model, rpm_limit in team.metadata.get("model_rpm_limit", {}).items(): 

906 model_specific_rpm_limit[model] = model_specific_rpm_limit.get(model, 0) + rpm_limit 

907 if team.metadata and team.metadata.get("model_tpm_limit", None) is not None: 

908 for model, tpm_limit in team.metadata.get("model_tpm_limit", {}).items(): 

909 model_specific_tpm_limit[model] = model_specific_tpm_limit.get(model, 0) + tpm_limit 

910 

911 if model_rpm_limit is not None: 

912 for model, rpm_limit in model_rpm_limit.items(): 

913 if entity_rpm_limit is not None and model_specific_rpm_limit.get(model, 0) + rpm_limit > entity_rpm_limit: 

914 raise HTTPException( 

915 status_code=400, 

916 detail=f"Allocated RPM limit={model_specific_rpm_limit.get(model, 0)} + Team RPM limit={rpm_limit} is greater than {entity_type} RPM limit={entity_rpm_limit}", 

917 ) 

918 elif entity_model_rpm_limit_dict: 

919 entity_model_specific_rpm_limit = entity_model_rpm_limit_dict.get(model) 

920 if ( 

921 entity_model_specific_rpm_limit 

922 and model_specific_rpm_limit.get(model, 0) + rpm_limit > entity_model_specific_rpm_limit 

923 ): 

924 raise HTTPException( 

925 status_code=400, 

926 detail=f"Allocated RPM limit={model_specific_rpm_limit.get(model, 0)} + Team RPM limit={rpm_limit} is greater than {entity_type} RPM limit={entity_model_specific_rpm_limit}", 

927 ) 

928 

929 if model_tpm_limit is not None: 

930 for model, tpm_limit in model_tpm_limit.items(): 

931 if entity_tpm_limit is not None and model_specific_tpm_limit.get(model, 0) + tpm_limit > entity_tpm_limit: 

932 raise HTTPException( 

933 status_code=400, 

934 detail=f"Allocated TPM limit={model_specific_tpm_limit.get(model, 0)} + Team TPM limit={tpm_limit} is greater than {entity_type} TPM limit={entity_tpm_limit}", 

935 ) 

936 elif entity_model_tpm_limit_dict: 

937 entity_model_specific_tpm_limit = entity_model_tpm_limit_dict.get(model) 

938 if ( 

939 entity_model_specific_tpm_limit 

940 and model_specific_tpm_limit.get(model, 0) + tpm_limit > entity_model_specific_tpm_limit 

941 ): 

942 raise HTTPException( 

943 status_code=400, 

944 detail=f"Allocated TPM limit={model_specific_tpm_limit.get(model, 0)} + Team TPM limit={tpm_limit} is greater than {entity_type} TPM limit={entity_model_specific_tpm_limit}", 

945 ) 

946 

947 

948def _check_team_rpm_tpm_limits( 

949 teams: list[LiteLLM_TeamTable], 

950 data: NewTeamRequest | UpdateTeamRequest, 

951 entity_rpm_limit: int | None, 

952 entity_tpm_limit: int | None, 

953 entity_type: str, # "organization" 

954) -> None: 

955 """ 

956 Generic function to check if a team is allocating rpm/tpm limits. 

957 Raises an error if we're overallocating. 

958 """ 

959 if teams is not None and len(teams) > 0: 

960 allocated_tpm = sum(team.tpm_limit for team in teams if team.tpm_limit is not None) 

961 allocated_rpm = sum(team.rpm_limit for team in teams if team.rpm_limit is not None) 

962 else: 

963 allocated_tpm = 0 

964 allocated_rpm = 0 

965 

966 if ( 

967 data.tpm_limit is not None 

968 and entity_tpm_limit is not None 

969 and data.tpm_limit + allocated_tpm > entity_tpm_limit 

970 ): 

971 raise HTTPException( 

972 status_code=400, 

973 detail=f"Allocated TPM limit={allocated_tpm} + Team TPM limit={data.tpm_limit} is greater than {entity_type} TPM limit={entity_tpm_limit}", 

974 ) 

975 if ( 

976 data.rpm_limit is not None 

977 and entity_rpm_limit is not None 

978 and data.rpm_limit + allocated_rpm > entity_rpm_limit 

979 ): 

980 raise HTTPException( 

981 status_code=400, 

982 detail=f"Allocated RPM limit={allocated_rpm} + Team RPM limit={data.rpm_limit} is greater than {entity_type} RPM limit={entity_rpm_limit}", 

983 ) 

984 

985 

986def check_org_team_model_specific_limits( 

987 teams: list[LiteLLM_TeamTable], 

988 org_table: LiteLLM_OrganizationTable, 

989 data: NewTeamRequest | UpdateTeamRequest, 

990) -> None: 

991 """ 

992 Check if the organization team is allocating model specific limits. If so, raise an error if we're overallocating. 

993 """ 

994 

995 # Get org limits from budget table if available 

996 entity_rpm_limit = None 

997 entity_tpm_limit = None 

998 entity_model_rpm_limit_dict = {} 

999 entity_model_tpm_limit_dict = {} 

1000 

1001 if org_table.litellm_budget_table is not None: 

1002 entity_rpm_limit = org_table.litellm_budget_table.rpm_limit 

1003 entity_tpm_limit = org_table.litellm_budget_table.tpm_limit 

1004 

1005 if org_table.metadata: 

1006 entity_model_rpm_limit_dict = org_table.metadata.get("model_rpm_limit", {}) 

1007 entity_model_tpm_limit_dict = org_table.metadata.get("model_tpm_limit", {}) 

1008 

1009 _check_team_model_specific_limits( 

1010 teams=teams, 

1011 data=data, 

1012 entity_rpm_limit=entity_rpm_limit, 

1013 entity_tpm_limit=entity_tpm_limit, 

1014 entity_model_rpm_limit_dict=entity_model_rpm_limit_dict, 

1015 entity_model_tpm_limit_dict=entity_model_tpm_limit_dict, 

1016 entity_type="organization", 

1017 ) 

1018 

1019 

1020def check_org_team_rpm_tpm_limits( 

1021 teams: list[LiteLLM_TeamTable], 

1022 org_table: LiteLLM_OrganizationTable, 

1023 data: NewTeamRequest | UpdateTeamRequest, 

1024) -> None: 

1025 """ 

1026 Check if the organization team is allocating rpm/tpm limits. If so, raise an error if we're overallocating. 

1027 """ 

1028 # Get org limits from budget table if available 

1029 entity_rpm_limit = None 

1030 entity_tpm_limit = None 

1031 

1032 if org_table.litellm_budget_table is not None: 

1033 entity_rpm_limit = org_table.litellm_budget_table.rpm_limit 

1034 entity_tpm_limit = org_table.litellm_budget_table.tpm_limit 

1035 

1036 _check_team_rpm_tpm_limits( 

1037 teams=teams, 

1038 data=data, 

1039 entity_rpm_limit=entity_rpm_limit, 

1040 entity_tpm_limit=entity_tpm_limit, 

1041 entity_type="organization", 

1042 ) 

1043 

1044 

1045async def _check_org_team_limits( 

1046 org_table: LiteLLM_OrganizationTable, 

1047 data: NewTeamRequest | UpdateTeamRequest, 

1048 prisma_client: PrismaClient, 

1049) -> None: 

1050 """ 

1051 Check organization team limits including: 

1052 - Team budget vs organization's max_budget 

1053 - Team models vs organization's allowed models 

1054 - Guaranteed throughput limits (tpm/rpm) if applicable 

1055 """ 

1056 

1057 # Validate team budget against organization's max_budget 

1058 if ( 

1059 data.max_budget is not None 

1060 and org_table.litellm_budget_table is not None 

1061 and org_table.litellm_budget_table.max_budget is not None 

1062 and data.max_budget > org_table.litellm_budget_table.max_budget 

1063 ): 

1064 raise HTTPException( 

1065 status_code=400, 

1066 detail={ 

1067 "error": f"Team max_budget ({data.max_budget}) exceeds organization's max_budget ({org_table.litellm_budget_table.max_budget}). Organization: {org_table.organization_id}" 

1068 }, 

1069 ) 

1070 

1071 # Validate team models against organization's allowed models 

1072 if data.models is not None and len(org_table.models) > 0: 

1073 # If organization has 'all-proxy-models', skip validation as it allows all models 

1074 if SpecialModelNames.all_proxy_models.value in org_table.models: 

1075 pass 

1076 else: 

1077 for m in data.models: 

1078 if m not in org_table.models: 

1079 raise HTTPException( 

1080 status_code=400, 

1081 detail={ 

1082 "error": f"Model '{m}' not in organization's allowed models. Organization allowed models={org_table.models}. Organization: {org_table.organization_id}" 

1083 }, 

1084 ) 

1085 

1086 # Validate team TPM/RPM against organization's TPM/RPM limits (direct comparison) 

1087 if ( 

1088 data.tpm_limit is not None 

1089 and org_table.litellm_budget_table is not None 

1090 and org_table.litellm_budget_table.tpm_limit is not None 

1091 and data.tpm_limit > org_table.litellm_budget_table.tpm_limit 

1092 ): 

1093 raise HTTPException( 

1094 status_code=400, 

1095 detail={ 

1096 "error": f"Team tpm_limit ({data.tpm_limit}) exceeds organization's tpm_limit ({org_table.litellm_budget_table.tpm_limit}). Organization: {org_table.organization_id}" 

1097 }, 

1098 ) 

1099 

1100 if ( 

1101 data.rpm_limit is not None 

1102 and org_table.litellm_budget_table is not None 

1103 and org_table.litellm_budget_table.rpm_limit is not None 

1104 and data.rpm_limit > org_table.litellm_budget_table.rpm_limit 

1105 ): 

1106 raise HTTPException( 

1107 status_code=400, 

1108 detail={ 

1109 "error": f"Team rpm_limit ({data.rpm_limit}) exceeds organization's rpm_limit ({org_table.litellm_budget_table.rpm_limit}). Organization: {org_table.organization_id}" 

1110 }, 

1111 ) 

1112 

1113 # Check guaranteed throughput limits (only if applicable) 

1114 rpm_limit_type: Final = getattr(data, "rpm_limit_type", None) or ( 

1115 data.metadata.get("rpm_limit_type", None) if data.metadata else None 

1116 ) 

1117 tpm_limit_type: Final = getattr(data, "tpm_limit_type", None) or ( 

1118 data.metadata.get("tpm_limit_type", None) if data.metadata else None 

1119 ) 

1120 

1121 if tpm_limit_type != "guaranteed_throughput" and rpm_limit_type != "guaranteed_throughput": 

1122 return 

1123 # get all organization teams 

1124 # calculate allocated tpm/rpm limit 

1125 # check if specified tpm/rpm limit is greater than allocated tpm/rpm limit 

1126 

1127 teams: Final = await _team_db(prisma_client).find_many( 

1128 where={"organization_id": org_table.organization_id}, 

1129 ) 

1130 

1131 # Convert teams to LiteLLM_TeamTable objects 

1132 team_objs: Final[list[LiteLLM_TeamTable]] = [] 

1133 for team in teams: 

1134 team_objs.append(LiteLLM_TeamTable.model_validate(team.model_dump())) 

1135 

1136 check_org_team_model_specific_limits( 

1137 teams=team_objs, 

1138 org_table=org_table, 

1139 data=data, 

1140 ) 

1141 check_org_team_rpm_tpm_limits( 

1142 teams=team_objs, 

1143 org_table=org_table, 

1144 data=data, 

1145 ) 

1146 

1147 

1148async def _check_user_team_limits( 

1149 data: NewTeamRequest | UpdateTeamRequest, 

1150 user_api_key_dict: UserAPIKeyAuth, 

1151 prisma_client: PrismaClient, 

1152 user_api_key_cache: UserApiKeyCache, 

1153) -> None: 

1154 """ 

1155 Enforce the caller's personal limits when CREATING a standalone team. 

1156 

1157 This validates the requested team budget / models / tpm / rpm against the 

1158 caller's own limits, so a non-admin user cannot mint a brand-new team that 

1159 is richer than themselves. 

1160 

1161 Only used by /team/new for standalone teams (organization_id is None). 

1162 /team/update does NOT call this — an existing team's admin is already 

1163 authorized via _verify_team_access() and is not gated by their personal 

1164 wallet. Org-scoped teams use _check_org_team_limits() instead. 

1165 """ 

1166 # Validate team budget against user's max_budget 

1167 if data.max_budget is not None and user_api_key_dict.user_id is not None: 

1168 user_obj: Final = await get_user_object( 

1169 user_id=user_api_key_dict.user_id, 

1170 prisma_client=prisma_client, 

1171 user_api_key_cache=user_api_key_cache, 

1172 user_id_upsert=False, 

1173 ) 

1174 

1175 if user_obj is not None and user_obj.max_budget is not None and data.max_budget > user_obj.max_budget: 

1176 raise HTTPException( 

1177 status_code=400, 

1178 detail={ 

1179 "error": f"max budget higher than user max. User max budget={user_obj.max_budget}. User role={user_api_key_dict.user_role}" 

1180 }, 

1181 ) 

1182 

1183 # Validate team models against user's allowed models 

1184 if data.models is not None and len(user_api_key_dict.models) > 0: 

1185 for m in data.models: 

1186 if m not in user_api_key_dict.models: 

1187 raise HTTPException( 

1188 status_code=400, 

1189 detail={ 

1190 "error": f"Model not in allowed user models. User allowed models={user_api_key_dict.models}. User id={user_api_key_dict.user_id}" 

1191 }, 

1192 ) 

1193 

1194 # Validate team TPM/RPM against user's TPM/RPM limits 

1195 if ( 

1196 data.tpm_limit is not None 

1197 and user_api_key_dict.tpm_limit is not None 

1198 and data.tpm_limit > user_api_key_dict.tpm_limit 

1199 ): 

1200 raise HTTPException( 

1201 status_code=400, 

1202 detail={ 

1203 "error": f"tpm limit higher than user max. User tpm limit={user_api_key_dict.tpm_limit}. User role={user_api_key_dict.user_role}" 

1204 }, 

1205 ) 

1206 

1207 if ( 

1208 data.rpm_limit is not None 

1209 and user_api_key_dict.rpm_limit is not None 

1210 and data.rpm_limit > user_api_key_dict.rpm_limit 

1211 ): 

1212 raise HTTPException( 

1213 status_code=400, 

1214 detail={ 

1215 "error": f"rpm limit higher than user max. User rpm limit={user_api_key_dict.rpm_limit}. User role={user_api_key_dict.user_role}" 

1216 }, 

1217 ) 

1218 

1219 

1220@dataclass(frozen=True, slots=True) 

1221class _MaxBudgetGuard: 

1222 """The team write only lands while the stored max_budget still equals `expected`.""" 

1223 

1224 expected: float | None 

1225 

1226 

1227def _check_team_budget_update_authority( 

1228 data: UpdateTeamRequest, 

1229 user_api_key_dict: UserAPIKeyAuth, 

1230 existing_team_max_budget: float | None, 

1231) -> _MaxBudgetGuard | None: 

1232 """ 

1233 Restrict who can grow a team's spend ceiling on /team/update. 

1234 

1235 A team admin may keep or lower the team budget, but only a proxy admin may 

1236 grow it - by raising max_budget above the team's current value or by 

1237 removing the cap (setting it to None). Setting a finite budget on a team 

1238 that has no cap is a restriction and is allowed. Org admins editing 

1239 org-scoped teams are governed by _check_org_team_limits() instead. 

1240 

1241 The verdict holds only for the budget it was checked against, so a restricted 

1242 caller's budget write gets a guard; without it, a concurrent budget cut could 

1243 be overwritten with a higher value. 

1244 """ 

1245 if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN: 1245 ↛ 1248line 1245 didn't jump to line 1248 because the condition on line 1245 was always true

1246 return None 

1247 

1248 budget_explicitly_set: Final = "max_budget" in (getattr(data, "model_fields_set", None) or set()) 

1249 guard: Final = _MaxBudgetGuard(expected=existing_team_max_budget) if budget_explicitly_set else None 

1250 if existing_team_max_budget is None: 

1251 return guard 

1252 

1253 if budget_explicitly_set and data.max_budget is None: 

1254 raise HTTPException( 

1255 status_code=403, 

1256 detail={ 

1257 "error": f"Only a proxy admin can remove a team's max_budget. Team's current max_budget={existing_team_max_budget}." 

1258 }, 

1259 ) 

1260 

1261 if data.max_budget is not None and data.max_budget > existing_team_max_budget: 

1262 raise HTTPException( 

1263 status_code=403, 

1264 detail={ 

1265 "error": f"Only a proxy admin can raise a team's max_budget. Team's current max_budget={existing_team_max_budget}, requested={data.max_budget}." 

1266 }, 

1267 ) 

1268 return guard 

1269 

1270 

1271_TEAM_UPDATE_INCLUDE: Final = MappingProxyType( 

1272 { 

1273 "litellm_model_table": True, 

1274 # `object_permission` is included so `_refresh_cached_team` 

1275 # doesn't write a cached team with the relation nulled out. 

1276 # See team_model_add for the full rationale. 

1277 "object_permission": True, 

1278 } 

1279) 

1280 

1281 

1282async def _write_team_update( 

1283 prisma_client: PrismaClient | None, 

1284 team_id: str, 

1285 team_update_data: Mapping[str, object], 

1286 max_budget_guard: _MaxBudgetGuard | None, 

1287) -> "prisma_models.LiteLLM_TeamTable | None": 

1288 by_id: Final[_TeamIdWhere] = {"team_id": team_id} 

1289 if max_budget_guard is None: 1289 ↛ 1291line 1289 didn't jump to line 1291 because the condition on line 1289 was always true

1290 return await _team_db(prisma_client).update(where=by_id, data=team_update_data, include=_TEAM_UPDATE_INCLUDE) 

1291 by_id_and_budget: Final[_TeamIdAndBudgetWhere] = {"team_id": team_id, "max_budget": max_budget_guard.expected} 

1292 written: Final = await _team_db(prisma_client).update_many(where=by_id_and_budget, data=team_update_data) 

1293 if written == 0: 

1294 conflict: Final[_ErrorDetail] = { 

1295 "error": "The team's max_budget changed during this update. Reload the team and try again." 

1296 } 

1297 raise HTTPException(status_code=409, detail=conflict) 

1298 return await _team_db(prisma_client).find_unique(where=by_id, include=_TEAM_UPDATE_INCLUDE) 

1299 

1300 

1301def _existing_model_cap(raw_budget_config: object) -> BudgetConfig | None: 

1302 try: 

1303 return BudgetConfig.model_validate(raw_budget_config) 

1304 except ValidationError: 

1305 return None 

1306 

1307 

1308def _check_team_model_budget_update_authority( 

1309 data: UpdateTeamRequest, 

1310 user_api_key_dict: UserAPIKeyAuth, 

1311 existing_model_max_budget: Mapping[str, object] | None, 

1312) -> None: 

1313 """Like `_check_team_budget_update_authority`: only a proxy admin may raise, re-window or drop a per-model cap.""" 

1314 if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN: 1314 ↛ 1316line 1314 didn't jump to line 1316 because the condition on line 1314 was always true

1315 return 

1316 if "model_max_budget" not in data.model_fields_set or not existing_model_max_budget: 

1317 return 

1318 requested: Final[Mapping[str, BudgetConfig]] = data.model_max_budget or {} 

1319 for model_name, raw_existing in existing_model_max_budget.items(): 

1320 existing = _existing_model_cap(raw_existing) 

1321 if existing is None or existing.max_budget is None or model_name in requested: 

1322 continue 

1323 raise HTTPException( 

1324 status_code=403, 

1325 detail={ 

1326 "error": ( 

1327 f"Only a proxy admin can remove a team's model_max_budget for {model_name!r}. " 

1328 f"Current max_budget={existing.max_budget}." 

1329 ) 

1330 }, 

1331 ) 

1332 for model_name, proposed in requested.items(): 

1333 governing = resolve_model_budget(model=model_name, model_max_budget=existing_model_max_budget) 

1334 if governing is None: 

1335 continue 

1336 cap = governing.budget_config 

1337 if cap.max_budget is None: 

1338 continue 

1339 if ( 

1340 proposed.max_budget is None 

1341 or proposed.max_budget > cap.max_budget 

1342 or proposed.budget_duration != cap.budget_duration 

1343 ): 

1344 raise HTTPException( 

1345 status_code=403, 

1346 detail={ 

1347 "error": ( 

1348 f"Only a proxy admin can raise a team's model_max_budget for {model_name!r} or change its " 

1349 f"budget_duration. Current max_budget={cap.max_budget} per {cap.budget_duration} " 

1350 f"(entry {governing.budget_model!r}), requested={proposed.max_budget} per " 

1351 f"{proposed.budget_duration}." 

1352 ) 

1353 }, 

1354 ) 

1355 

1356 

1357def _should_auto_add_team_creator( 

1358 user_api_key_dict: UserAPIKeyAuth, 

1359 general_settings: Mapping[str, object], 

1360) -> bool: 

1361 if user_api_key_dict.user_id is None: 1361 ↛ 1362line 1361 didn't jump to line 1362 because the condition on line 1361 was never true

1362 return False 

1363 if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: 1363 ↛ 1364line 1363 didn't jump to line 1364 because the condition on line 1363 was never true

1364 return True 

1365 return general_settings.get("disable_auto_add_proxy_admin_to_teams") is not True 

1366 

1367 

1368#### TEAM MANAGEMENT #### 

1369@router.post( 

1370 "/team/new", 

1371 tags=["team management"], 

1372 dependencies=[Depends(user_api_key_auth)], 

1373 response_model=LiteLLM_TeamTable, 

1374) 

1375@management_endpoint_wrapper 

1376async def new_team( 

1377 data: NewTeamRequest, 

1378 http_request: Request, 

1379 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), 

1380 litellm_changed_by: str | None = Header( 

1381 None, 

1382 description="The litellm-changed-by header enables tracking of actions performed by authorized users on behalf of other users, providing an audit trail for accountability", 

1383 ), 

1384): 

1385 """ 

1386 Allow users to create a new team. Apply user permissions to their team. 

1387 

1388 👉 [Detailed Doc on setting team budgets](https://docs.litellm.ai/docs/proxy/team_budgets) 

1389 

1390 

1391 Parameters: 

1392 - team_alias: Optional[str] - User defined team alias 

1393 - team_id: Optional[str] - The team id of the user. If none passed, we'll generate it. 

1394 - members_with_roles: List[{"role": "admin" or "user", "user_id": "<user-id>"}] - A list of users and their roles in the team. Get user_id when making a new user via `/user/new`. 

1395 - team_member_permissions: Optional[List[str]] - A list of routes that non-admin team members can access. example: ["/key/generate", "/key/update", "/key/delete"] 

1396 - metadata: Optional[dict] - Metadata for team, store information for team. Example metadata = {"extra_info": "some info"} 

1397 - model_rpm_limit: Optional[Dict[str, int]] - The RPM (Requests Per Minute) limit for this team - applied across all keys for this team. 

1398 - model_tpm_limit: Optional[Dict[str, int]] - The TPM (Tokens Per Minute) limit for this team - applied across all keys for this team. 

1399 - default_estimated_output_tokens: Optional[int] - Expected output tokens reserved for TPM limiting when a request omits max_tokens, for keys on this team that do not set their own. Positive integer. 

1400 - default_estimated_output_tokens_per_model: Optional[Dict[str, int]] - Per-model override of the above. Example: {"gpt-4": 4096, "gpt-3.5-turbo": 1024} 

1401 - mcp_rpm_limit: Optional[Dict[str, int]] - Per-MCP-server RPM limit for this team, keyed by MCP server name (alias if set, else the configured name). Example: {"github": 100, "slack": 200}. Applied across all keys for this team. 

1402 - tpm_limit: Optional[int] - The TPM (Tokens Per Minute) limit for this team - all keys with this team_id will have at max this TPM limit 

1403 - rpm_limit: Optional[int] - The RPM (Requests Per Minute) limit for this team - all keys associated with this team_id will have at max this RPM limit 

1404 - tpd_limit: Optional[int] - The TPD (Tokens Per Day) limit for this team. Batch submissions are charged against it instead of tpm_limit/rpm_limit 

1405 - rpm_limit_type: Optional[Literal["guaranteed_throughput", "best_effort_throughput"]] - The type of RPM limit enforcement. Use "guaranteed_throughput" to raise an error if overallocating RPM, or "best_effort_throughput" for best effort enforcement. 

1406 - tpm_limit_type: Optional[Literal["guaranteed_throughput", "best_effort_throughput"]] - The type of TPM limit enforcement. Use "guaranteed_throughput" to raise an error if overallocating TPM, or "best_effort_throughput" for best effort enforcement. 

1407 - max_budget: Optional[float] - The maximum budget allocated to the team - all keys for this team_id will have at max this max_budget 

1408 - soft_budget: Optional[float] - The soft budget threshold for the team. If max_budget is set, soft_budget must be strictly lower than max_budget. Can be set independently if max_budget is not set. 

1409 - budget_duration: Optional[str] - The duration of the budget for the team. Doc [here](https://docs.litellm.ai/docs/proxy/team_budgets) 

1410 - models: Optional[list] - A list of models associated with the team - all keys for this team_id will have at most, these models. If empty, assumes all models are allowed. 

1411 - blocked: bool - Flag indicating if the team is blocked or not - will stop all calls from keys with this team_id. 

1412 - members: Optional[List] - Control team members via `/team/member/add` and `/team/member/delete`. 

1413 - tags: Optional[List[str]] - Tags for [tracking spend](https://litellm.vercel.app/docs/proxy/enterprise#tracking-spend-for-custom-tags) and/or doing [tag-based routing](https://litellm.vercel.app/docs/proxy/tag_routing). 

1414 - prompts: Optional[List[str]] - List of prompts that the team is allowed to use. 

1415 - organization_id: Optional[str] - The organization id of the team. Default is None. Create via `/organization/new`. 

1416 - model_aliases: Optional[dict] - Model aliases for the team. [Docs](https://docs.litellm.ai/docs/proxy/team_based_routing#create-team-with-model-alias) 

1417 - model_max_budget: Optional[dict] - Per-model max budget every key on the team inherits unless the key sets its own for that model. Example: {"gpt-4o": {"max_budget": 10, "budget_duration": "1d"}} 

1418 - guardrails: Optional[List[str]] - Guardrails for the team. [Docs](https://docs.litellm.ai/docs/proxy/guardrails) 

1419 - policies: Optional[List[str]] - Policies for the team. [Docs](https://docs.litellm.ai/docs/proxy/guardrails/guardrail_policies) 

1420 - disable_global_guardrails: Optional[bool] - Whether to disable global guardrails for the team. Proxy admin only. 

1421 - object_permission: Optional[LiteLLM_ObjectPermissionBase] - team-specific object permission. Example - {"vector_stores": ["vector_store_1", "vector_store_2"], "agents": ["agent_1", "agent_2"], "agent_access_groups": ["dev_group"]}. IF null or {} then no object permission. 

1422 - team_member_budget: Optional[float] - The maximum budget allocated to an individual team member. 

1423 - team_member_budget_duration: Optional[str] - The duration of the budget for the team member. Doc [here](https://docs.litellm.ai/docs/proxy/team_budgets) 

1424 - team_member_rpm_limit: Optional[int] - The RPM (Requests Per Minute) limit for individual team members. 

1425 - team_member_tpm_limit: Optional[int] - The TPM (Tokens Per Minute) limit for individual team members. 

1426 - team_member_key_duration: Optional[str] - The duration for a team member's key. e.g. "1d", "1w", "1mo" 

1427 - allowed_passthrough_routes: Optional[List[str]] - List of allowed pass through routes for the team. 

1428 - allowed_vector_store_indexes: Optional[List[dict]] - List of allowed vector store indexes for the key. Example - [{"index_name": "my-index", "index_permissions": ["write", "read"]}]. If specified, the key will only be able to use these specific vector store indexes. Create index, using `/v1/indexes` endpoint. 

1429 - secret_manager_settings: Optional[dict] - Secret manager settings for the team. [Docs](https://docs.litellm.ai/docs/secret_managers/overview) 

1430 - router_settings: Optional[UpdateRouterConfig] - team-specific router settings. Example - {"model_group_retry_policy": {"gpt-4": {"RateLimitErrorRetries": 5}}}. IF null or {} then no router settings. 

1431 - access_group_ids: Optional[List[str]] - List of access group IDs to associate with the team. Access groups define which models the team can access. Example - ["access_group_1", "access_group_2"]. 

1432 - enforced_file_expires_after: Optional[dict] - Enforced file expiration policy for the team. Keys created under this team will inherit this policy for file uploads. Example - {"anchor": "created_at", "days": 30}. 

1433 - enforced_batch_output_expires_after: Optional[dict] - Enforced batch output file expiration policy for the team. Keys created under this team will inherit this policy for batch output files. Example - {"anchor": "created_at", "days": 30}. 

1434 - budget_limits: Optional[list] - List of concurrent budget windows for the team. Each window specifies a budget_limit, time_period, and optional budget_duration. Example - [{"budget_limit": 10.0, "time_period": "1d"}, {"budget_limit": 50.0, "time_period": "7d"}]. 

1435 - default_team_member_models: Optional[List[str]] - Default models assigned to new team members when they join this team. Must be a subset of the team's models. 

1436 

1437 Returns: 

1438 - team_id: (str) Unique team id - used for tracking spend across multiple keys for same team id. 

1439 

1440 _deprecated_params: 

1441 - admins: list - A list of user_id's for the admin role 

1442 - users: list - A list of user_id's for the user role 

1443 

1444 Example Request: 

1445 ``` 

1446 curl --location 'http://0.0.0.0:4000/team/new' \ 

1447 --header 'Authorization: Bearer sk-1234' \ 

1448 --header 'Content-Type: application/json' \ 

1449 --data '{ 

1450 "team_alias": "my-new-team_2", 

1451 "members_with_roles": [{"role": "admin", "user_id": "user-1234"}, 

1452 {"role": "user", "user_id": "user-2434"}] 

1453 }' 

1454 

1455 ``` 

1456 

1457 ``` 

1458 curl --location 'http://0.0.0.0:4000/team/new' \ 

1459 --header 'Authorization: Bearer sk-1234' \ 

1460 --header 'Content-Type: application/json' \ 

1461 --data '{ 

1462 "team_alias": "QA Prod Bot", 

1463 "max_budget": 0.000000001, 

1464 "budget_duration": "1d" 

1465 }' 

1466 ``` 

1467 """ 

1468 try: 

1469 from litellm.proxy.management_helpers.audit_logs import ( 

1470 get_audit_log_changed_by, 

1471 is_audit_logging_enabled, 

1472 ) 

1473 from litellm.proxy.proxy_server import ( 

1474 _license_check, 

1475 create_audit_log_for_update, 

1476 general_settings, 

1477 litellm_proxy_admin_name, 

1478 llm_router, 

1479 premium_user, 

1480 prisma_client, 

1481 user_api_key_cache, 

1482 ) 

1483 

1484 if prisma_client is None: 1484 ↛ 1485line 1484 didn't jump to line 1485 because the condition on line 1484 was never true

1485 raise HTTPException(status_code=500, detail={"error": "No db connected"}) 

1486 

1487 # Validate budget values are not negative 

1488 if data.max_budget is not None and (not math.isfinite(data.max_budget) or data.max_budget < 0): 

1489 raise HTTPException( 

1490 status_code=400, 

1491 detail={"error": f"max_budget must be a non-negative finite number. Received: {data.max_budget}"}, 

1492 ) 

1493 if data.team_member_budget is not None and ( 1493 ↛ 1496line 1493 didn't jump to line 1496 because the condition on line 1493 was never true

1494 not math.isfinite(data.team_member_budget) or data.team_member_budget < 0 

1495 ): 

1496 raise HTTPException( 

1497 status_code=400, 

1498 detail={ 

1499 "error": f"team_member_budget must be a non-negative finite number. Received: {data.team_member_budget}" 

1500 }, 

1501 ) 

1502 if data.soft_budget is not None and (not math.isfinite(data.soft_budget) or data.soft_budget < 0): 1502 ↛ 1503line 1502 didn't jump to line 1503 because the condition on line 1502 was never true

1503 raise HTTPException( 

1504 status_code=400, 

1505 detail={"error": f"soft_budget must be a non-negative finite number. Received: {data.soft_budget}"}, 

1506 ) 

1507 

1508 validate_budget_duration(data.budget_duration) 

1509 validate_budget_duration(data.team_member_budget_duration) 

1510 validate_team_model_max_budget(model_max_budget=data.model_max_budget, premium_user=premium_user) 

1511 

1512 if data.soft_budget is not None: 1512 ↛ 1513line 1512 didn't jump to line 1513 because the condition on line 1512 was never true

1513 if data.max_budget is not None: 

1514 # If max_budget is set, soft_budget must be strictly lower than max_budget 

1515 if data.soft_budget >= data.max_budget: 

1516 raise HTTPException( 

1517 status_code=400, 

1518 detail={ 

1519 "error": f"soft_budget ({data.soft_budget}) must be strictly lower than max_budget ({data.max_budget})" 

1520 }, 

1521 ) 

1522 

1523 enforce_output_token_estimates_are_admin_only( 

1524 data=data, 

1525 existing_metadata=None, 

1526 user_api_key_dict=user_api_key_dict, 

1527 entity="team", 

1528 ) 

1529 enforce_batch_enqueued_token_limit_is_admin_only( 

1530 data=data, 

1531 existing_metadata=None, 

1532 user_api_key_dict=user_api_key_dict, 

1533 entity="team", 

1534 ) 

1535 

1536 # Check if license is over limit 

1537 total_teams: Final = await _team_db(prisma_client).count() 

1538 if total_teams and _license_check.is_team_count_over_limit(team_count=total_teams): 1538 ↛ 1539line 1538 didn't jump to line 1539 because the condition on line 1538 was never true

1539 raise HTTPException( 

1540 status_code=403, 

1541 detail="License is over limit. Please contact support@berri.ai to upgrade your license.", 

1542 ) 

1543 

1544 if data.team_id is None: 

1545 data.team_id = str(uuid.uuid4()) 

1546 else: 

1547 if data.team_id == UI_TEAM_ID: 1547 ↛ 1548line 1547 didn't jump to line 1548 because the condition on line 1547 was never true

1548 raise HTTPException( 

1549 status_code=400, 

1550 detail={ 

1551 "error": f"team_id '{UI_TEAM_ID}' is reserved for LiteLLM UI dashboard sessions and cannot be used for a real team. Please use a different team id." 

1552 }, 

1553 ) 

1554 # Check if team_id exists already 

1555 _existing_team_id: Final = await prisma_client.get_data( 

1556 team_id=data.team_id, table_name="team", query_type="find_unique" 

1557 ) 

1558 if _existing_team_id is not None: 1558 ↛ 1564line 1558 didn't jump to line 1564 because the condition on line 1558 was always true

1559 raise HTTPException( 

1560 status_code=400, 

1561 detail={"error": f"Team id = {data.team_id} already exists. Please use a different team id."}, 

1562 ) 

1563 

1564 if data.organization_id is None: 1564 ↛ 1572line 1564 didn't jump to line 1572 because the condition on line 1564 was always true

1565 default_organization_id: Final = _get_default_team_param("organization_id") 

1566 if isinstance(default_organization_id, str): 1566 ↛ 1567line 1566 didn't jump to line 1567 because the condition on line 1566 was never true

1567 data.organization_id = default_organization_id 

1568 

1569 # Apply defaults from litellm.default_team_params to null fields. 

1570 # budget_duration alone distinguishes explicit null (a deliberate 

1571 # never-resetting budget, which the default must not override) from omitted. 

1572 for field in ( 

1573 "max_budget", 

1574 "budget_duration", 

1575 "tpm_limit", 

1576 "rpm_limit", 

1577 "team_member_permissions", 

1578 ): 

1579 if getattr(data, field, None) is None and ( 

1580 field != "budget_duration" or field not in data.model_fields_set 

1581 ): 

1582 default_value = _get_default_team_param(field) 

1583 if default_value is not None: 

1584 setattr(data, field, default_value) 

1585 

1586 # Legacy fallback: apply max_budget from default_team_settings (YAML config) 

1587 # if still not set after checking default_team_params. 

1588 if data.max_budget is None: 1588 ↛ 1599line 1588 didn't jump to line 1599 because the condition on line 1588 was always true

1589 if ( 1589 ↛ 1594line 1589 didn't jump to line 1594 because the condition on line 1589 was never true

1590 isinstance(litellm.default_team_settings, list) 

1591 and len(litellm.default_team_settings) > 0 

1592 and isinstance(litellm.default_team_settings[0], dict) 

1593 ): 

1594 default_budget: Final = litellm.default_team_settings[0].get("max_budget") 

1595 if default_budget is not None: 

1596 data.max_budget = default_budget 

1597 

1598 # check org key limits - done here to handle inheriting org id from team 

1599 if data.organization_id is not None and prisma_client is not None: 1599 ↛ 1600line 1599 didn't jump to line 1600 because the condition on line 1599 was never true

1600 try: 

1601 org_table = await get_org_object( 

1602 org_id=data.organization_id, 

1603 user_api_key_cache=user_api_key_cache, 

1604 prisma_client=prisma_client, 

1605 include_budget_table=True, 

1606 ) 

1607 except OrganizationNotFoundError: 

1608 org_table = None 

1609 if org_table is None: 

1610 raise HTTPException( 

1611 status_code=400, 

1612 detail=f"Organization not found for organization_id={data.organization_id}", 

1613 ) 

1614 

1615 await _check_org_team_limits( 

1616 org_table=org_table, 

1617 data=data, 

1618 prisma_client=prisma_client, 

1619 ) 

1620 

1621 if ( 1621 ↛ 1626line 1621 didn't jump to line 1626 because the condition on line 1621 was never true

1622 user_api_key_dict.user_role is None or user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN 

1623 ): # don't restrict proxy admin 

1624 # Only validate user budget/models/tpm/rpm for standalone teams (not org-scoped) 

1625 # For org-scoped teams, validation is done by _check_org_team_limits() 

1626 if data.organization_id is None: 

1627 await _check_user_team_limits( 

1628 data=data, 

1629 user_api_key_dict=user_api_key_dict, 

1630 prisma_client=prisma_client, 

1631 user_api_key_cache=user_api_key_cache, 

1632 ) 

1633 

1634 if _should_auto_add_team_creator(user_api_key_dict, general_settings): 1634 ↛ 1641line 1634 didn't jump to line 1641 because the condition on line 1634 was always true

1635 creating_user_in_list: Final = any( 

1636 member.user_id == user_api_key_dict.user_id for member in data.members_with_roles 

1637 ) 

1638 if not creating_user_in_list: 1638 ↛ 1641line 1638 didn't jump to line 1641 because the condition on line 1638 was always true

1639 data.members_with_roles.append(Member(role="admin", user_id=user_api_key_dict.user_id)) 

1640 

1641 _check_passthrough_routes_caller_permission(data, user_api_key_dict, entity="team") 

1642 _check_disable_global_guardrails_caller_permission( 

1643 data.disable_global_guardrails, 

1644 data.metadata, # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # request models declare `metadata` as bare dict 

1645 user_api_key_dict, 

1646 entity="team", 

1647 ) 

1648 

1649 if isinstance(data.metadata, dict): 1649 ↛ 1650line 1649 didn't jump to line 1650 because the condition on line 1649 was never true

1650 TeamMemberBudgetHandler.strip_system_managed_metadata_keys(data.metadata) 

1651 

1652 await validate_team_metadata_if_configured( 

1653 operation="create", 

1654 metadata=data.metadata, 

1655 existing_metadata=None, 

1656 team_id=data.team_id, 

1657 team_alias=data.team_alias, 

1658 user_api_key_dict=user_api_key_dict, 

1659 ) 

1660 

1661 await validate_router_settings_weights( 

1662 data.router_settings, 

1663 team_id=data.team_id, 

1664 prisma_client=prisma_client, 

1665 llm_router=llm_router, 

1666 ) 

1667 

1668 ## ADD TO MODEL TABLE 

1669 _model_id = None 

1670 if data.model_aliases is not None and isinstance(data.model_aliases, dict): 1670 ↛ 1671line 1670 didn't jump to line 1671 because the condition on line 1670 was never true

1671 litellm_modeltable: Final = LiteLLM_ModelTable( 

1672 model_aliases=json.dumps(data.model_aliases), 

1673 created_by=user_api_key_dict.user_id or litellm_proxy_admin_name, 

1674 updated_by=user_api_key_dict.user_id or litellm_proxy_admin_name, 

1675 ) 

1676 model_dict: Final = await _model_db(prisma_client).create({**litellm_modeltable.json(exclude_none=True)}) 

1677 

1678 _model_id = model_dict.id 

1679 

1680 data_json = data.json() 

1681 

1682 ## Handle Object Permission - MCP, Vector Stores etc. 

1683 await enforce_all_proxy_mcp_servers_grant_is_admin_only( 

1684 requested_mcp_servers=(data.object_permission.mcp_servers if data.object_permission is not None else None), 

1685 existing_object_permission_id=None, 

1686 is_proxy_admin=user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN, 

1687 prisma_client=prisma_client, 

1688 ) 

1689 data_json = await _set_object_permission( 

1690 data_json=data_json, 

1691 prisma_client=prisma_client, 

1692 ) 

1693 

1694 if TeamMemberBudgetHandler.should_create_budget( 

1695 team_member_budget=data.team_member_budget, 

1696 team_member_rpm_limit=data.team_member_rpm_limit, 

1697 team_member_tpm_limit=data.team_member_tpm_limit, 

1698 team_member_budget_duration=data.team_member_budget_duration, 

1699 ): 

1700 data_json = await TeamMemberBudgetHandler.create_team_member_budget_table( 

1701 data=data, 

1702 new_team_data_json=data_json, 

1703 user_api_key_dict=user_api_key_dict, 

1704 team_member_budget=data.team_member_budget, 

1705 team_member_rpm_limit=data.team_member_rpm_limit, 

1706 team_member_tpm_limit=data.team_member_tpm_limit, 

1707 team_member_budget_duration=data.team_member_budget_duration, 

1708 explicitly_set_fields=data.model_fields_set, 

1709 ) 

1710 

1711 ## ADD TO TEAM TABLE 

1712 complete_team_data: Final = LiteLLM_TeamTable( 

1713 **data_json, 

1714 model_id=_model_id, 

1715 ) 

1716 

1717 # Set Management Endpoint Metadata Fields 

1718 for field in LiteLLM_ManagementEndpoint_MetadataFields_Premium: 

1719 if getattr(data, field, None) is not None: 

1720 _set_object_metadata_field( 

1721 object_data=complete_team_data, 

1722 field_name=field, 

1723 value=getattr(data, field), 

1724 ) 

1725 

1726 for field in LiteLLM_ManagementEndpoint_MetadataFields: 

1727 if getattr(data, field, None) is not None: 

1728 _set_object_metadata_field( 

1729 object_data=complete_team_data, 

1730 field_name=field, 

1731 value=getattr(data, field), 

1732 ) 

1733 

1734 # If budget_duration is set, set `budget_reset_at` 

1735 if complete_team_data.budget_duration is not None: 

1736 from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time 

1737 

1738 complete_team_data.budget_reset_at = get_budget_reset_time( 

1739 budget_duration=complete_team_data.budget_duration, 

1740 ) 

1741 

1742 # If budget_limits is set, initialize reset_at for each window 

1743 if complete_team_data.budget_limits: 1743 ↛ 1744line 1743 didn't jump to line 1744 because the condition on line 1743 was never true

1744 from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time 

1745 

1746 initialized_windows: Final = [] 

1747 for window in complete_team_data.budget_limits: 

1748 w = window if isinstance(window, dict) else window.model_dump() 

1749 w["reset_at"] = get_budget_reset_time(budget_duration=w["budget_duration"]).isoformat() 

1750 initialized_windows.append(w) 

1751 complete_team_data.budget_limits = initialized_windows 

1752 

1753 ## Add Team Member Budget Table 

1754 members_with_roles: list[Member] = [] 

1755 if complete_team_data.members_with_roles is not None: 1755 ↛ 1759line 1755 didn't jump to line 1759 because the condition on line 1755 was always true

1756 members_with_roles = complete_team_data.members_with_roles 

1757 complete_team_data.members_with_roles = [] 

1758 

1759 complete_team_data_dict = complete_team_data.model_dump(exclude_none=True) 

1760 

1761 # Serialize router_settings to JSON (matching key creation pattern) 

1762 router_settings_value: Final = getattr(data, "router_settings", None) 

1763 router_settings_json: Final = ( 

1764 safe_dumps(router_settings_value) if router_settings_value is not None else safe_dumps({}) 

1765 ) 

1766 complete_team_data_dict["router_settings"] = router_settings_json 

1767 

1768 if complete_team_data_dict.get("metadata") is not None: 

1769 complete_team_data_dict["metadata"] = encrypt_callback_vars(complete_team_data_dict["metadata"]) 

1770 

1771 complete_team_data_dict = prisma_client.jsonify_team_object(db_data=complete_team_data_dict) 

1772 team_creation_data: Final[Mapping[str, object]] = complete_team_data_dict 

1773 

1774 tx: _TeamCreateTx 

1775 async with prisma_client.db.tx() as tx: 

1776 team_row: Final[prisma_models.LiteLLM_TeamTable] = await tx.litellm_teamtable.create( 

1777 data=team_creation_data, 

1778 include=_INCLUDE_MODEL_TABLE, 

1779 ) 

1780 affected_access_groups: Final = await reconcile_team_access_group_membership(tx, team_row.team_id) 

1781 

1782 await invalidate_access_group_caches(affected_access_groups) 

1783 

1784 ## ADD TEAM ID TO USER TABLE ## 

1785 team_member_add_request: Final = TeamMemberAddRequest( 

1786 team_id=data.team_id, 

1787 member=members_with_roles, 

1788 ) 

1789 await _add_team_members_to_team( 

1790 data=team_member_add_request, 

1791 complete_team_data=team_row, 

1792 prisma_client=prisma_client, 

1793 user_api_key_dict=user_api_key_dict, 

1794 litellm_proxy_admin_name=litellm_proxy_admin_name, 

1795 ) 

1796 

1797 if is_audit_logging_enabled(): 1797 ↛ 1798line 1797 didn't jump to line 1798 because the condition on line 1797 was never true

1798 created_team_snapshot: Final = complete_team_data.model_copy( 

1799 update={"members_with_roles": list(team_row.members_with_roles)} 

1800 ) 

1801 _updated_values = created_team_snapshot.json(exclude_none=True) 

1802 

1803 _updated_values = json.dumps(_updated_values, default=str) 

1804 

1805 asyncio.create_task( 

1806 create_audit_log_for_update( 

1807 request_data=LiteLLM_AuditLogs( 

1808 id=str(uuid.uuid4()), 

1809 updated_at=datetime.now(timezone.utc), 

1810 changed_by=get_audit_log_changed_by( 

1811 litellm_changed_by=litellm_changed_by, 

1812 user_api_key_dict=user_api_key_dict, 

1813 litellm_proxy_admin_name=litellm_proxy_admin_name, 

1814 ), 

1815 changed_by_api_key=user_api_key_dict.api_key, 

1816 table_name=LitellmTableNames.TEAM_TABLE_NAME, 

1817 object_id=data.team_id, 

1818 action="created", 

1819 updated_values=_updated_values, 

1820 before_value=None, 

1821 ) 

1822 ) 

1823 ) 

1824 

1825 try: 

1826 return team_row.model_dump() 

1827 except Exception: 

1828 return team_row.dict() 

1829 except Exception as e: 

1830 raise handle_exception_on_proxy(e) 

1831 

1832 

1833async def _create_team_update_audit_log( 

1834 existing_team_row: _AuditableTeamRow, 

1835 updated_kv: dict, 

1836 team_id: str, 

1837 litellm_changed_by: str | None, 

1838 user_api_key_dict: UserAPIKeyAuth, 

1839 litellm_proxy_admin_name: str, 

1840) -> None: 

1841 """ 

1842 Create an audit log entry for team update operations. 

1843 

1844 Args: 

1845 existing_team_row: The team row before the update 

1846 updated_kv: Dictionary of updated key-value pairs 

1847 team_id: The ID of the team being updated 

1848 litellm_changed_by: Optional header indicating who made the change 

1849 user_api_key_dict: User API key authentication details 

1850 litellm_proxy_admin_name: Name of the proxy admin 

1851 """ 

1852 from litellm.proxy.management_helpers.audit_logs import ( 

1853 create_audit_log_for_update, 

1854 get_audit_log_changed_by, 

1855 ) 

1856 

1857 _before_value = existing_team_row.json(exclude_none=True) 

1858 _before_value = json.dumps(_before_value, default=str) 

1859 _after_value: Final[str] = json.dumps(updated_kv, default=str) 

1860 

1861 asyncio.create_task( 

1862 create_audit_log_for_update( 

1863 request_data=LiteLLM_AuditLogs( 

1864 id=str(uuid.uuid4()), 

1865 updated_at=datetime.now(timezone.utc), 

1866 changed_by=get_audit_log_changed_by( 

1867 litellm_changed_by=litellm_changed_by, 

1868 user_api_key_dict=user_api_key_dict, 

1869 litellm_proxy_admin_name=litellm_proxy_admin_name, 

1870 ), 

1871 changed_by_api_key=user_api_key_dict.api_key, 

1872 table_name=LitellmTableNames.TEAM_TABLE_NAME, 

1873 object_id=team_id, 

1874 action="updated", 

1875 updated_values=_after_value, 

1876 before_value=_before_value, 

1877 ) 

1878 ) 

1879 ) 

1880 

1881 

1882async def _update_model_table( 

1883 data: UpdateTeamRequest, 

1884 model_id: int | None, 

1885 prisma_client: PrismaClient, 

1886 user_api_key_dict: UserAPIKeyAuth, 

1887 litellm_proxy_admin_name: str, 

1888) -> int | None: 

1889 """ 

1890 Upsert model table and return the model id 

1891 """ 

1892 ## UPSERT MODEL TABLE 

1893 _model_id = model_id 

1894 if data.model_aliases is not None and isinstance(data.model_aliases, dict): 1894 ↛ 1895line 1894 didn't jump to line 1895 because the condition on line 1894 was never true

1895 litellm_modeltable: Final = LiteLLM_ModelTable( 

1896 model_aliases=json.dumps(data.model_aliases), 

1897 created_by=user_api_key_dict.user_id or litellm_proxy_admin_name, 

1898 updated_by=user_api_key_dict.user_id or litellm_proxy_admin_name, 

1899 ) 

1900 if model_id is None: 

1901 model_dict = await _model_db(prisma_client).create(data={**litellm_modeltable.json(exclude_none=True)}) 

1902 else: 

1903 model_dict = await _model_db(prisma_client).upsert( 

1904 where={"id": model_id}, 

1905 data={ 

1906 "update": {**litellm_modeltable.json(exclude_none=True)}, 

1907 "create": {**litellm_modeltable.json(exclude_none=True)}, 

1908 }, 

1909 ) 

1910 

1911 _model_id = model_dict.id 

1912 

1913 return _model_id 

1914 

1915 

1916async def _auto_add_team_members_to_organization( 

1917 team: LiteLLM_TeamTable, 

1918 organization: LiteLLM_OrganizationTableWithMembers, 

1919 prisma_client: PrismaClient, 

1920) -> None: 

1921 """ 

1922 When moving a team to an org, ensure all team members are also org members. 

1923 

1924 For SSO/Entra setups without SCIM, users join teams automatically on login but 

1925 are never explicitly added to organizations. This silently upserts missing members 

1926 rather than blocking the team move. 

1927 """ 

1928 org_member_ids: Final = {m.user_id for m in organization.members} if organization.members else set() 

1929 for member in team.members_with_roles: 

1930 if member.user_id is None: 

1931 continue 

1932 if member.user_id == SpecialProxyStrings.default_user_id.value: 

1933 continue 

1934 if member.user_id in org_member_ids: 

1935 continue 

1936 if organization.organization_id is None: 

1937 continue 

1938 try: 

1939 await add_member_to_organization( 

1940 member=OrgMember( 

1941 user_id=member.user_id, 

1942 role=LitellmUserRoles.INTERNAL_USER, 

1943 ), 

1944 organization_id=organization.organization_id, 

1945 prisma_client=prisma_client, 

1946 ) 

1947 except Exception as e: 

1948 verbose_proxy_logger.debug( 

1949 "_auto_add_team_members_to_organization: skipping user_id=%s - %s", 

1950 member.user_id, 

1951 e, 

1952 ) 

1953 

1954 

1955async def fetch_and_validate_organization( 

1956 organization_id: str, 

1957 existing_team_row: _ModelDumpRow, 

1958 llm_router: Router | None, 

1959 prisma_client: PrismaClient, 

1960 user_api_key_dict: UserAPIKeyAuth | None = None, 

1961) -> "prisma_models.LiteLLM_OrganizationTable": 

1962 """ 

1963 Fetch and validate an organization for team update operations. 

1964 

1965 Args: 

1966 organization_id: The organization ID to fetch 

1967 existing_team_row: The existing team row being updated 

1968 llm_router: The LLM router instance 

1969 prisma_client: The Prisma database client 

1970 

1971 Returns: 

1972 The organization row from the database 

1973 

1974 Raises: 

1975 HTTPException: If llm_router is None, organization not found, or validation fails 

1976 """ 

1977 if llm_router is None: 1977 ↛ 1978line 1977 didn't jump to line 1978 because the condition on line 1977 was never true

1978 raise HTTPException(status_code=500, detail={"error": CommonProxyErrors.no_llm_router.value}) 

1979 

1980 organization_row: Final = await _org_db(prisma_client).find_unique( 

1981 where={"organization_id": organization_id}, 

1982 include={"litellm_budget_table": True, "members": True, "teams": True}, 

1983 ) 

1984 

1985 if organization_row is None: 1985 ↛ 1991line 1985 didn't jump to line 1991 because the condition on line 1985 was always true

1986 raise HTTPException( 

1987 status_code=404, 

1988 detail={"error": f"Organization not found, passed organization_id={organization_id}"}, 

1989 ) 

1990 

1991 is_proxy_admin = user_api_key_dict is not None and user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN 

1992 organization: Final = LiteLLM_OrganizationTableWithMembers.model_validate(organization_row.model_dump()) 

1993 validate_team_org_change( 

1994 team=LiteLLM_TeamTable.model_validate(existing_team_row.model_dump()), 

1995 organization=organization, 

1996 llm_router=llm_router, 

1997 is_proxy_admin=is_proxy_admin, 

1998 ) 

1999 

2000 if is_proxy_admin: 

2001 await _auto_add_team_members_to_organization( 

2002 team=LiteLLM_TeamTable.model_validate(existing_team_row.model_dump()), 

2003 organization=organization, 

2004 prisma_client=prisma_client, 

2005 ) 

2006 

2007 return organization_row 

2008 

2009 

2010def validate_team_org_change( 

2011 team: LiteLLM_TeamTable, 

2012 organization: LiteLLM_OrganizationTableWithMembers, 

2013 llm_router: Router, 

2014 is_proxy_admin: bool = False, 

2015) -> bool: 

2016 """ 

2017 Validate that a team can be moved to an organization. 

2018 

2019 - The org must have access to the team's models 

2020 - The team budget cannot be greater than the org max_budget 

2021 - For non-proxy-admins: all team members must already be org members 

2022 - The team's tpm/rpm limit must be less than the org's tpm/rpm limit 

2023 

2024 Proxy admins bypass the membership check and instead trigger auto-add of 

2025 missing members (handled by the caller). This supports SSO/Entra setups 

2026 where org membership tables are empty but proxy admins still need to group 

2027 teams under orgs for budget/model governance. 

2028 """ 

2029 

2030 # If the team's organization is the same as the new organization, return True 

2031 # Since no changes are being made 

2032 if team.organization_id == organization.organization_id: 

2033 return True 

2034 

2035 # Check if the org has access to the team's models 

2036 if len(organization.models) > 0: 

2037 if SpecialModelNames.all_proxy_models.value in organization.models: 

2038 pass 

2039 elif team.models is None or len(team.models) == 0: 

2040 raise HTTPException( 

2041 status_code=403, 

2042 detail={ 

2043 "error": "Cannot move team to organization. Team has access to all proxy models, but the organization does not." 

2044 }, 

2045 ) 

2046 else: 

2047 for model in team.models: 

2048 can_org_access_model( 

2049 model=model, 

2050 org_object=organization, 

2051 llm_router=llm_router, 

2052 ) 

2053 

2054 # Check if the team's budget is less than the org's max_budget 

2055 if ( 

2056 team.max_budget is not None 

2057 and organization.litellm_budget_table is not None 

2058 and organization.litellm_budget_table.max_budget is not None 

2059 and team.max_budget > organization.litellm_budget_table.max_budget 

2060 ): 

2061 raise HTTPException( 

2062 status_code=403, 

2063 detail={ 

2064 "error": f"Cannot move team to organization. Team has max_budget {team.max_budget} that is greater than the organization's max_budget {organization.litellm_budget_table.max_budget}." 

2065 }, 

2066 ) 

2067 

2068 # For non-proxy-admins, require all team members to already be org members. 

2069 # This prevents a team admin from moving their team into an arbitrary org and 

2070 # thereby injecting members into that org without org admin approval. 

2071 if not is_proxy_admin: 

2072 team_members: Final = [m.user_id for m in team.members_with_roles] 

2073 org_members: Final = [m.user_id for m in organization.members] if organization.members else [] 

2074 not_in_org: Final = [ 

2075 m for m in team_members if m not in org_members and m != SpecialProxyStrings.default_user_id.value 

2076 ] 

2077 if len(not_in_org) > 0: 

2078 raise HTTPException( 

2079 status_code=403, 

2080 detail={ 

2081 "error": f"Cannot move team to organization. Team has user_id {not_in_org} that is not a member of the organization." 

2082 }, 

2083 ) 

2084 

2085 # Check if the team's tpm/rpm limit is less than the org's tpm/rpm limit 

2086 if ( 

2087 team.tpm_limit 

2088 and organization.litellm_budget_table 

2089 and organization.litellm_budget_table.tpm_limit 

2090 and team.tpm_limit > organization.litellm_budget_table.tpm_limit 

2091 ): 

2092 raise HTTPException( 

2093 status_code=403, 

2094 detail={ 

2095 "error": f"Cannot move team to organization. Team has tpm_limit {team.tpm_limit} that is greater than the organization's tpm_limit {organization.litellm_budget_table.tpm_limit}." 

2096 }, 

2097 ) 

2098 if ( 

2099 team.rpm_limit 

2100 and organization.litellm_budget_table 

2101 and organization.litellm_budget_table.rpm_limit 

2102 and team.rpm_limit > organization.litellm_budget_table.rpm_limit 

2103 ): 

2104 raise HTTPException( 

2105 status_code=403, 

2106 detail={ 

2107 "error": f"Cannot move team to organization. Team has rpm_limit {team.rpm_limit} that is greater than the organization's rpm_limit {organization.litellm_budget_table.rpm_limit}." 

2108 }, 

2109 ) 

2110 return True 

2111 

2112 

2113def _member_user_ids(members_with_roles: Sequence[dict[str, object]]) -> tuple[str, ...]: 

2114 """Extract the string ``user_id`` of each team member, dropping rows without one. 

2115 

2116 ``members_with_roles`` is a Prisma-deserialized JSON column, so its ``user_id`` is typed 

2117 ``object``; the ``isinstance`` narrows it to the ``str`` ``invalidate_team_member_spend_state`` needs. 

2118 """ 

2119 return tuple(user_id for member in members_with_roles if isinstance((user_id := member.get("user_id")), str)) 

2120 

2121 

2122async def _evict_created_membership_caches( 

2123 user_ids: Iterable[str], 

2124 team_id: str, 

2125 user_api_key_cache: UserApiKeyCache, 

2126) -> None: 

2127 """Evict the ``get_team_membership`` negative-cache sentinel for members whose row was just created. 

2128 

2129 A session-token request caches ``NO_TEAM_MEMBERSHIP_SENTINEL`` for a member with no 

2130 ``LiteLLM_TeamMembership`` row. When a create path (``/team/member_add`` or the ``/team/update`` 

2131 budget backfill) later writes that row with a per-member budget, the stale sentinel keeps the 

2132 member's budget unenforced until the membership cache TTL expires, so it must be evicted here. 

2133 """ 

2134 await asyncio.gather( 

2135 *( 

2136 invalidate_team_member_spend_state( 

2137 user_id=user_id, 

2138 team_id=team_id, 

2139 user_api_key_cache=user_api_key_cache, 

2140 ) 

2141 for user_id in user_ids 

2142 ) 

2143 ) 

2144 

2145 

2146@router.post("/team/update", tags=["team management"], dependencies=[Depends(user_api_key_auth)]) 

2147@management_endpoint_wrapper 

2148async def update_team( 

2149 data: UpdateTeamRequest, 

2150 http_request: Request, 

2151 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), 

2152 litellm_changed_by: str | None = Header( 

2153 None, 

2154 description="The litellm-changed-by header enables tracking of actions performed by authorized users on behalf of other users, providing an audit trail for accountability", 

2155 ), 

2156): 

2157 """ 

2158 Use `/team/member_add` AND `/team/member/delete` to add/remove new team members 

2159 

2160 You can now update team budget / rate limits via /team/update 

2161 

2162 Parameters: 

2163 - team_id: str - The team id of the user. Required param. 

2164 - team_alias: Optional[str] - User defined team alias 

2165 - team_member_permissions: Optional[List[str]] - A list of routes that non-admin team members can access. example: ["/key/generate", "/key/update", "/key/delete"] 

2166 - metadata: Optional[dict] - Metadata for team, store information for team. Example metadata = {"team": "core-infra", "app": "app2", "email": "ishaan@berri.ai" } 

2167 - tpm_limit: Optional[int] - The TPM (Tokens Per Minute) limit for this team - all keys with this team_id will have at max this TPM limit 

2168 - rpm_limit: Optional[int] - The RPM (Requests Per Minute) limit for this team - all keys associated with this team_id will have at max this RPM limit 

2169 - tpd_limit: Optional[int] - The TPD (Tokens Per Day) limit for this team. Batch submissions are charged against it instead of tpm_limit/rpm_limit 

2170 - max_budget: Optional[float] - The maximum budget allocated to the team - all keys for this team_id will have at max this max_budget 

2171 - soft_budget: Optional[float] - The soft budget threshold for the team. If max_budget is set (either in the request or existing), soft_budget must be strictly lower than max_budget. Can be set independently if max_budget is not set. 

2172 - budget_duration: Optional[str] - The duration of the budget for the team. Doc [here](https://docs.litellm.ai/docs/proxy/team_budgets) 

2173 - models: Optional[list] - A list of models associated with the team - all keys for this team_id will have at most, these models. If empty, assumes all models are allowed. 

2174 - prompts: Optional[List[str]] - List of prompts that the team is allowed to use. 

2175 - blocked: bool - Flag indicating if the team is blocked or not - will stop all calls from keys with this team_id. 

2176 - tags: Optional[List[str]] - Tags for [tracking spend](https://litellm.vercel.app/docs/proxy/enterprise#tracking-spend-for-custom-tags) and/or doing [tag-based routing](https://litellm.vercel.app/docs/proxy/tag_routing). 

2177 - organization_id: Optional[str] - The organization id of the team. Default is None. Create via `/organization/new`. 

2178 - model_aliases: Optional[dict] - Model aliases for the team. [Docs](https://docs.litellm.ai/docs/proxy/team_based_routing#create-team-with-model-alias) 

2179 - model_max_budget: Optional[dict] - Per-model max budget every key on the team inherits unless the key sets its own for that model. Example: {"gpt-4o": {"max_budget": 10, "budget_duration": "1d"}} 

2180 - guardrails: Optional[List[str]] - Guardrails for the team. [Docs](https://docs.litellm.ai/docs/proxy/guardrails) 

2181 - policies: Optional[List[str]] - Policies for the team. [Docs](https://docs.litellm.ai/docs/proxy/guardrails/guardrail_policies) 

2182 - disable_global_guardrails: Optional[bool] - Whether to disable global guardrails for the team. Proxy admin only. 

2183 - object_permission: Optional[LiteLLM_ObjectPermissionBase] - team-specific object permission. Example - {"vector_stores": ["vector_store_1", "vector_store_2"], "agents": ["agent_1", "agent_2"], "agent_access_groups": ["dev_group"]}. IF null or {} then no object permission. 

2184 - team_member_budget: Optional[float] - The maximum budget allocated to an individual team member. 

2185 - team_member_budget_duration: Optional[str] - The duration of the budget for the team member. Doc [here](https://docs.litellm.ai/docs/proxy/team_budgets) 

2186 - team_member_rpm_limit: Optional[int] - The RPM (Requests Per Minute) limit for individual team members. 

2187 - team_member_tpm_limit: Optional[int] - The TPM (Tokens Per Minute) limit for individual team members. 

2188 - team_member_key_duration: Optional[str] - The duration for a team member's key. e.g. "1d", "1w", "1mo" 

2189 - allowed_passthrough_routes: Optional[List[str]] - List of allowed pass through routes for the team. 

2190 - model_rpm_limit: Optional[Dict[str, int]] - The RPM (Requests Per Minute) limit per model for this team. Example: {"gpt-4": 100, "gpt-3.5-turbo": 200} 

2191 - model_tpm_limit: Optional[Dict[str, int]] - The TPM (Tokens Per Minute) limit per model for this team. Example: {"gpt-4": 10000, "gpt-3.5-turbo": 20000} 

2192 - default_estimated_output_tokens: Optional[int] - Expected output tokens reserved for TPM limiting when a request omits max_tokens, for keys on this team that do not set their own. Positive integer. 

2193 - default_estimated_output_tokens_per_model: Optional[Dict[str, int]] - Per-model override of the above. Example: {"gpt-4": 4096, "gpt-3.5-turbo": 1024} 

2194 - mcp_rpm_limit: Optional[Dict[str, int]] - Per-MCP-server RPM limit for this team, keyed by MCP server name (alias if set, else the configured name). Example: {"github": 100, "slack": 200}. Applied across all keys for this team. 

2195 Example - update team TPM Limit 

2196 - allowed_vector_store_indexes: Optional[List[dict]] - List of allowed vector store indexes for the key. Example - [{"index_name": "my-index", "index_permissions": ["write", "read"]}]. If specified, the key will only be able to use these specific vector store indexes. Create index, using `/v1/indexes` endpoint. 

2197 - secret_manager_settings: Optional[dict] - Secret manager settings for the team. [Docs](https://docs.litellm.ai/docs/secret_managers/overview) 

2198 - router_settings: Optional[UpdateRouterConfig] - team-specific router settings. Example - {"model_group_retry_policy": {"gpt-4": {"RateLimitErrorRetries": 5}}}. IF null or {} then no router settings. 

2199 - access_group_ids: Optional[List[str]] - List of access group IDs to associate with the team. Access groups define which models the team can access. Example - ["access_group_1", "access_group_2"]. 

2200 - enforced_file_expires_after: Optional[dict] - Enforced file expiration policy for the team. Keys created under this team will inherit this policy for file uploads. Example - {"anchor": "created_at", "days": 30}. 

2201 - enforced_batch_output_expires_after: Optional[dict] - Enforced batch output file expiration policy for the team. Keys created under this team will inherit this policy for batch output files. Example - {"anchor": "created_at", "days": 30}. 

2202 - budget_limits: Optional[list] - List of concurrent budget windows for the team. Each window specifies a budget_limit, time_period, and optional budget_duration. Example - [{"budget_limit": 10.0, "time_period": "1d"}, {"budget_limit": 50.0, "time_period": "7d"}]. 

2203 - default_team_member_models: Optional[List[str]] - Default models assigned to new team members when they join this team. Must be a subset of the team's models. 

2204 

2205 ``` 

2206 curl --location 'http://0.0.0.0:4000/team/update' \ 

2207 --header 'Authorization: Bearer sk-1234' \ 

2208 --header 'Content-Type: application/json' \ 

2209 --data-raw '{ 

2210 "team_id": "8d916b1c-510d-4894-a334-1c16a93344f5", 

2211 "tpm_limit": 100 

2212 }' 

2213 ``` 

2214 

2215 Example - Update Team `max_budget` budget 

2216 ``` 

2217 curl --location 'http://0.0.0.0:4000/team/update' \ 

2218 --header 'Authorization: Bearer sk-1234' \ 

2219 --header 'Content-Type: application/json' \ 

2220 --data-raw '{ 

2221 "team_id": "8d916b1c-510d-4894-a334-1c16a93344f5", 

2222 "max_budget": 10 

2223 }' 

2224 ``` 

2225 """ 

2226 try: 

2227 from litellm.proxy.management_helpers.audit_logs import is_audit_logging_enabled 

2228 from litellm.proxy.proxy_server import ( 

2229 litellm_proxy_admin_name, 

2230 llm_router, 

2231 premium_user, 

2232 prisma_client, 

2233 proxy_logging_obj, 

2234 user_api_key_cache, 

2235 ) 

2236 

2237 if prisma_client is None: 2237 ↛ 2238line 2237 didn't jump to line 2238 because the condition on line 2237 was never true

2238 raise HTTPException( 

2239 status_code=500, 

2240 detail={"error": CommonProxyErrors.db_not_connected_error.value}, 

2241 ) 

2242 

2243 if data.team_id is None: 2243 ↛ 2244line 2243 didn't jump to line 2244 because the condition on line 2243 was never true

2244 raise HTTPException(status_code=400, detail={"error": "No team id passed in"}) 

2245 verbose_proxy_logger.debug("/team/update - %s", data) 

2246 

2247 # Validate budget values are not negative 

2248 if data.max_budget is not None and (not math.isfinite(data.max_budget) or data.max_budget < 0): 

2249 raise HTTPException( 

2250 status_code=400, 

2251 detail={"error": f"max_budget must be a non-negative finite number. Received: {data.max_budget}"}, 

2252 ) 

2253 if data.team_member_budget is not None and ( 2253 ↛ 2256line 2253 didn't jump to line 2256 because the condition on line 2253 was never true

2254 not math.isfinite(data.team_member_budget) or data.team_member_budget < 0 

2255 ): 

2256 raise HTTPException( 

2257 status_code=400, 

2258 detail={ 

2259 "error": f"team_member_budget must be a non-negative finite number. Received: {data.team_member_budget}" 

2260 }, 

2261 ) 

2262 if data.soft_budget is not None and (not math.isfinite(data.soft_budget) or data.soft_budget < 0): 

2263 raise HTTPException( 

2264 status_code=400, 

2265 detail={"error": f"soft_budget must be a non-negative finite number. Received: {data.soft_budget}"}, 

2266 ) 

2267 

2268 validate_budget_duration(data.budget_duration) 

2269 validate_budget_duration(data.team_member_budget_duration) 

2270 validate_team_model_max_budget(model_max_budget=data.model_max_budget, premium_user=premium_user) 

2271 

2272 existing_team_row = await _raw_team_db(TeamRepository(prisma_client)).find_unique( 

2273 where={"team_id": data.team_id} 

2274 ) 

2275 

2276 if existing_team_row is None: 

2277 # Non-proxy-admins get the same 403 as an access denial so /team/update 

2278 # cannot be used to probe which team ids exist 

2279 if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: 2279 ↛ 2280line 2279 didn't jump to line 2280 because the condition on line 2279 was never true

2280 _raise_team_access_denied() 

2281 raise HTTPException( 

2282 status_code=404, 

2283 detail={"error": f"Team not found, passed team_id={data.team_id}"}, 

2284 ) 

2285 

2286 existing_team: Final = LiteLLM_TeamTable.model_validate(existing_team_row.model_dump()) 

2287 access_role: Final = await _resolve_team_access(team_obj=existing_team, user_api_key_dict=user_api_key_dict) 

2288 if access_role is None: 2288 ↛ 2289line 2288 didn't jump to line 2289 because the condition on line 2288 was never true

2289 _raise_team_access_denied() 

2290 if access_role == "team_admin": 2290 ↛ 2291line 2290 didn't jump to line 2291 because the condition on line 2290 was never true

2291 data = team_admin_request_or_raise( # rebind-ok: resent values must not reach the derived writes below 

2292 team_admin_edit_verdict( 

2293 data=data, 

2294 existing=existing_team, 

2295 permitted=resolve_team_admin_editable_fields( 

2296 _general_settings(), SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS 

2297 ), 

2298 ) 

2299 ) 

2300 

2301 await validate_router_settings_weights( 

2302 data.router_settings, 

2303 team_id=data.team_id, 

2304 prisma_client=prisma_client, 

2305 llm_router=llm_router, 

2306 ) 

2307 

2308 _existing_team_metadata: Final[object] = getattr(existing_team_row, "metadata", None) 

2309 enforce_output_token_estimates_are_admin_only( 

2310 data=data, 

2311 existing_metadata=_existing_team_metadata if isinstance(_existing_team_metadata, dict) else None, 

2312 user_api_key_dict=user_api_key_dict, 

2313 entity="team", 

2314 ) 

2315 enforce_batch_enqueued_token_limit_is_admin_only( 

2316 data=data, 

2317 existing_metadata=_existing_team_metadata if isinstance(_existing_team_metadata, dict) else None, 

2318 user_api_key_dict=user_api_key_dict, 

2319 entity="team", 

2320 ) 

2321 

2322 _check_passthrough_routes_caller_permission(data, user_api_key_dict, entity="team") 

2323 _check_disable_global_guardrails_caller_permission( 

2324 data.disable_global_guardrails, 

2325 data.metadata, # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # request models declare `metadata` as bare dict 

2326 user_api_key_dict, 

2327 entity="team", 

2328 existing_metadata=_existing_team_metadata if isinstance(_existing_team_metadata, dict) else None, # pyright: ignore[reportUnknownArgumentType] # existing_team_row.metadata is a bare dict 

2329 ) 

2330 

2331 if data.soft_budget is not None: 

2332 max_budget_to_check = data.max_budget if data.max_budget is not None else existing_team_row.max_budget 

2333 if max_budget_to_check is not None: 2333 ↛ 2334line 2333 didn't jump to line 2334 because the condition on line 2333 was never true

2334 if data.soft_budget >= max_budget_to_check: 

2335 raise HTTPException( 

2336 status_code=400, 

2337 detail={ 

2338 "error": f"soft_budget ({data.soft_budget}) must be strictly lower than max_budget ({max_budget_to_check})" 

2339 }, 

2340 ) 

2341 

2342 if data.max_budget is not None: 

2343 existing_soft_budget: Final[object] = _as_object(getattr(existing_team_row, "soft_budget", None)) 

2344 soft_budget_to_check: Final = data.soft_budget if data.soft_budget is not None else existing_soft_budget 

2345 if soft_budget_to_check is not None and isinstance(soft_budget_to_check, (int, float)): 2345 ↛ 2346line 2345 didn't jump to line 2346 because the condition on line 2345 was never true

2346 if data.max_budget <= soft_budget_to_check: 

2347 raise HTTPException( 

2348 status_code=400, 

2349 detail={ 

2350 "error": f"max_budget ({data.max_budget}) must be strictly greater than soft_budget ({soft_budget_to_check})" 

2351 }, 

2352 ) 

2353 

2354 if data.organization_id is not None and len(data.organization_id) > 0: # allow unsetting the organization_id 

2355 # If the caller is relocating the team to a different org, they 

2356 # must also be PROXY_ADMIN or an org-admin of the DESTINATION org. 

2357 # _verify_team_access above only checked the team's CURRENT org, 

2358 # so without this gate an org-admin could hand their team to any 

2359 # other org (or capture a team from another org they once 

2360 # administered into a new destination). 

2361 current_org_id: Final = getattr(existing_team_row, "organization_id", None) 

2362 if ( 2362 ↛ 2367line 2362 didn't jump to line 2367 because the condition on line 2362 was never true

2363 data.organization_id != current_org_id 

2364 and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value 

2365 ): 

2366 # Is the caller org_admin of the destination org? 

2367 caller_memberships: Final = ( 

2368 await _org_membership_db(prisma_client).find_many( 

2369 where={ 

2370 "user_id": user_api_key_dict.user_id, 

2371 "organization_id": data.organization_id, 

2372 "user_role": LitellmUserRoles.ORG_ADMIN.value, 

2373 } 

2374 ) 

2375 if user_api_key_dict.user_id 

2376 else [] 

2377 ) 

2378 if not caller_memberships: 

2379 raise HTTPException( 

2380 status_code=403, 

2381 detail={ 

2382 "error": ( 

2383 "Relocating a team to a different organization " 

2384 "requires PROXY_ADMIN or org-admin of the " 

2385 "destination org." 

2386 ) 

2387 }, 

2388 ) 

2389 

2390 await fetch_and_validate_organization( 

2391 organization_id=data.organization_id, 

2392 existing_team_row=existing_team_row, 

2393 llm_router=llm_router, 

2394 prisma_client=prisma_client, 

2395 user_api_key_dict=user_api_key_dict, 

2396 ) 

2397 elif data.organization_id is not None and len(data.organization_id) == 0: 2397 ↛ 2399line 2397 didn't jump to line 2399 because the condition on line 2397 was never true

2398 # unsetting the organization_id 

2399 data.organization_id = None 

2400 

2401 # check org team limits - if updating team that belongs to an org 

2402 org_id_to_check: Final[object] = _as_object( 

2403 data.organization_id if data.organization_id is not None else existing_team_row.organization_id 

2404 ) 

2405 if org_id_to_check is not None and isinstance(org_id_to_check, str) and prisma_client is not None: 2405 ↛ 2406line 2405 didn't jump to line 2406 because the condition on line 2405 was never true

2406 org_table: Final = await get_org_object( 

2407 org_id=org_id_to_check, 

2408 user_api_key_cache=user_api_key_cache, 

2409 prisma_client=prisma_client, 

2410 include_budget_table=True, 

2411 ) 

2412 if org_table is not None: 

2413 await _check_org_team_limits( 

2414 org_table=org_table, 

2415 data=data, 

2416 prisma_client=prisma_client, 

2417 ) 

2418 

2419 # A team admin never grows its own team's spend ceiling. Org admins grow org-scoped teams 

2420 # within the org limits _check_org_team_limits() enforced above. 

2421 max_budget_guard: Final = ( 

2422 _check_team_budget_update_authority( 

2423 data=data, 

2424 user_api_key_dict=user_api_key_dict, 

2425 existing_team_max_budget=existing_team_row.max_budget, 

2426 ) 

2427 if org_id_to_check is None or access_role == "team_admin" 

2428 else None 

2429 ) 

2430 _check_team_model_budget_update_authority( 

2431 data=data, 

2432 user_api_key_dict=user_api_key_dict, 

2433 existing_model_max_budget=existing_team_row.model_max_budget, 

2434 ) 

2435 

2436 updated_kv = data.json(exclude_unset=True) 

2437 if "model_max_budget" in updated_kv and updated_kv["model_max_budget"] is None: 2437 ↛ 2438line 2437 didn't jump to line 2438 because the condition on line 2437 was never true

2438 updated_kv["model_max_budget"] = {} 

2439 

2440 # Drop server-owned metadata keys from caller input so they can only 

2441 # be written by the same code path that creates the underlying rows. 

2442 if isinstance(updated_kv.get("metadata"), dict): 

2443 TeamMemberBudgetHandler.strip_system_managed_metadata_keys(updated_kv["metadata"]) 

2444 

2445 if "metadata" in updated_kv: 

2446 stored_metadata: Final[Mapping[str, JsonValue] | None] = ( 

2447 { # mutable-ok: the validator payload's isinstance guard requires a plain dict 

2448 key: value 

2449 for key, value in existing_team_row.metadata.items() 

2450 if key not in TeamMemberBudgetHandler.SYSTEM_MANAGED_METADATA_KEYS 

2451 } 

2452 if isinstance(existing_team_row.metadata, dict) 

2453 else None 

2454 ) 

2455 await validate_team_metadata_if_configured( 

2456 operation="update", 

2457 metadata=updated_kv.get("metadata"), 

2458 existing_metadata=stored_metadata, 

2459 team_id=data.team_id, 

2460 team_alias=data.team_alias if data.team_alias is not None else existing_team_row.team_alias, 

2461 user_api_key_dict=user_api_key_dict, 

2462 ) 

2463 

2464 # Check budget_duration and budget_reset_at 

2465 _set_budget_reset_at(data, updated_kv) 

2466 

2467 _team_member_fields_in_request: Final = { 

2468 field 

2469 for field in [ 

2470 "team_member_budget", 

2471 "team_member_rpm_limit", 

2472 "team_member_tpm_limit", 

2473 "team_member_budget_duration", 

2474 ] 

2475 if field in updated_kv 

2476 } 

2477 

2478 _writes_metadata_backed_field: Final = any( 

2479 field in updated_kv 

2480 for field in ( 

2481 *LiteLLM_ManagementEndpoint_MetadataFields, 

2482 *LiteLLM_ManagementEndpoint_MetadataFields_Premium, 

2483 ) 

2484 ) 

2485 if isinstance(existing_team_row.metadata, dict): 2485 ↛ 2498line 2485 didn't jump to line 2498 because the condition on line 2485 was always true

2486 if "metadata" not in updated_kv and (_team_member_fields_in_request or _writes_metadata_backed_field): 

2487 updated_kv["metadata"] = copy.deepcopy(existing_team_row.metadata) 

2488 elif isinstance(updated_kv.get("metadata"), dict): 

2489 updated_kv["metadata"] = { 

2490 **updated_kv["metadata"], 

2491 **{ 

2492 key: existing_team_row.metadata[key] 

2493 for key in TeamMemberBudgetHandler.SYSTEM_MANAGED_METADATA_KEYS 

2494 if key in existing_team_row.metadata 

2495 }, 

2496 } 

2497 

2498 if _team_member_fields_in_request and TeamMemberBudgetHandler.should_create_budget( 

2499 team_member_budget=data.team_member_budget, 

2500 team_member_rpm_limit=data.team_member_rpm_limit, 

2501 team_member_tpm_limit=data.team_member_tpm_limit, 

2502 team_member_budget_duration=data.team_member_budget_duration, 

2503 ): 

2504 updated_kv = await TeamMemberBudgetHandler.upsert_team_member_budget_table( 

2505 team_table=existing_team_row, 

2506 user_api_key_dict=user_api_key_dict, 

2507 updated_kv=updated_kv, 

2508 team_member_budget=data.team_member_budget, 

2509 team_member_rpm_limit=data.team_member_rpm_limit, 

2510 team_member_tpm_limit=data.team_member_tpm_limit, 

2511 team_member_budget_duration=data.team_member_budget_duration, 

2512 explicitly_set_fields=_team_member_fields_in_request, 

2513 ) 

2514 # Backfill team_memberships for members who joined before the 

2515 # budget was configured — they won't have a membership row yet. 

2516 _backfill_budget_id: Final = (updated_kv.get("metadata") or {}).get("team_member_budget_id") 

2517 if _backfill_budget_id and existing_team_row.members_with_roles: 2517 ↛ 2540line 2517 didn't jump to line 2540 because the condition on line 2517 was always true

2518 await TeamMemberBudgetHandler.backfill_team_member_budget_entries( 

2519 team_id=data.team_id, 

2520 members_with_roles=existing_team_row.members_with_roles, 

2521 team_member_budget_id=_backfill_budget_id, 

2522 prisma_client=prisma_client, 

2523 ) 

2524 await _evict_created_membership_caches( 

2525 user_ids=_member_user_ids(existing_team_row.members_with_roles), 

2526 team_id=data.team_id, 

2527 user_api_key_cache=user_api_key_cache, 

2528 ) 

2529 elif _team_member_fields_in_request: 

2530 updated_kv = await TeamMemberBudgetHandler.clear_team_member_budget_fields( 

2531 team_table=existing_team_row, 

2532 user_api_key_dict=user_api_key_dict, 

2533 updated_kv=updated_kv, 

2534 explicitly_set_fields=_team_member_fields_in_request, 

2535 ) 

2536 else: 

2537 TeamMemberBudgetHandler._clean_team_member_fields(updated_kv) 

2538 

2539 # Check object permission 

2540 if data.object_permission is not None: 

2541 await enforce_all_proxy_mcp_servers_grant_is_admin_only( 

2542 requested_mcp_servers=data.object_permission.mcp_servers, 

2543 existing_object_permission_id=existing_team_row.object_permission_id, 

2544 is_proxy_admin=user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN, 

2545 prisma_client=prisma_client, 

2546 ) 

2547 updated_kv = await handle_update_object_permission( 

2548 data_json=updated_kv, 

2549 existing_team_row=existing_team_row, 

2550 ) 

2551 

2552 # update team metadata fields 

2553 _update_metadata_fields(updated_kv=updated_kv) 

2554 

2555 if updated_kv.get("metadata") is not None: 

2556 updated_kv["metadata"] = encrypt_callback_vars(updated_kv["metadata"]) 

2557 

2558 if "model_aliases" in updated_kv: 

2559 updated_kv.pop("model_aliases") 

2560 _model_id: Final = await _update_model_table( 

2561 data=data, 

2562 model_id=existing_team_row.model_id, 

2563 prisma_client=prisma_client, 

2564 user_api_key_dict=user_api_key_dict, 

2565 litellm_proxy_admin_name=litellm_proxy_admin_name, 

2566 ) 

2567 if _model_id is not None: 2567 ↛ 2568line 2567 didn't jump to line 2568 because the condition on line 2567 was never true

2568 updated_kv["model_id"] = _model_id 

2569 

2570 # Serialize router_settings to JSON if present (matching key update pattern) 

2571 if "router_settings" in updated_kv and updated_kv["router_settings"] is not None: 

2572 updated_kv["router_settings"] = safe_dumps(updated_kv["router_settings"]) 

2573 

2574 updated_kv = prisma_client.jsonify_team_object(db_data=updated_kv) 

2575 team_update_data: Final[Mapping[str, object]] = updated_kv 

2576 team_row: Final = await _write_team_update(prisma_client, data.team_id, team_update_data, max_budget_guard) 

2577 

2578 if team_row is None or team_row.team_id is None: 2578 ↛ 2579line 2578 didn't jump to line 2579 because the condition on line 2578 was never true

2579 raise HTTPException( 

2580 status_code=400, 

2581 detail={"error": f"Team doesn't exist. Got={team_row}"}, 

2582 ) 

2583 

2584 verbose_proxy_logger.info("Successfully updated team - %s, info", team_row.team_id) 

2585 await sync_team_access_group_membership(prisma_client=prisma_client, team_id=team_row.team_id) 

2586 await _refresh_cached_team( 

2587 team_row=team_row, 

2588 user_api_key_cache=user_api_key_cache, 

2589 proxy_logging_obj=proxy_logging_obj, 

2590 ) 

2591 

2592 if is_audit_logging_enabled(): 2592 ↛ 2593line 2592 didn't jump to line 2593 because the condition on line 2592 was never true

2593 await _create_team_update_audit_log( 

2594 existing_team_row=existing_team_row, 

2595 updated_kv=updated_kv, 

2596 team_id=data.team_id, 

2597 litellm_changed_by=litellm_changed_by, 

2598 user_api_key_dict=user_api_key_dict, 

2599 litellm_proxy_admin_name=litellm_proxy_admin_name, 

2600 ) 

2601 

2602 return {"team_id": team_row.team_id, "data": team_row} 

2603 except Exception as e: 

2604 raise handle_exception_on_proxy(e) 

2605 

2606 

2607@router.patch( 

2608 "/team/{team_id}", 

2609 tags=["team management"], 

2610 dependencies=[Depends(user_api_key_auth)], 

2611 response_model=LiteLLM_TeamTable, 

2612) 

2613async def patch_team( 

2614 team_id: str, 

2615 data: PatchTeamRequest, 

2616 http_request: Request, 

2617 user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], 

2618 litellm_changed_by: Annotated[ 

2619 str | None, 

2620 Header( 

2621 description="The litellm-changed-by header enables tracking of actions performed by authorized users on behalf of other users, providing an audit trail for accountability", 

2622 ), 

2623 ] = None, 

2624): 

2625 """ 

2626 Partially update a team using RFC 7386 JSON Merge Patch semantics. 

2627 

2628 `team_id` is taken from the path; a `team_id` in the body is accepted only when it 

2629 matches. `metadata` is merged with the team's stored metadata rather than replacing 

2630 it: an omitted key is preserved, `key: null` deletes it, and any other value 

2631 overwrites (recursing into nested objects). Every other field behaves exactly like 

2632 `POST /team/update` (omitted preserves, a value overwrites). Returns the full 

2633 updated team. 

2634 

2635 ``` 

2636 curl --location --request PATCH 'http://0.0.0.0:4000/team/8d916b1c-510d-4894-a334-1c16a93344f5' \ 

2637 --header 'Authorization: Bearer sk-1234' \ 

2638 --header 'Content-Type: application/json' \ 

2639 --data-raw '{ 

2640 "metadata": {"cost_center": "1234", "deprecated_key": null} 

2641 }' 

2642 ``` 

2643 """ 

2644 from litellm.proxy.proxy_server import prisma_client 

2645 

2646 try: 

2647 if prisma_client is None: 2647 ↛ 2648line 2647 didn't jump to line 2648 because the condition on line 2647 was never true

2648 raise HTTPException( 

2649 status_code=500, 

2650 detail={"error": CommonProxyErrors.db_not_connected_error.value}, 

2651 ) 

2652 

2653 if data.team_id is not None and data.team_id != team_id: 

2654 raise HTTPException( 

2655 status_code=400, 

2656 detail={"error": f"team_id in body ({data.team_id}) does not match team_id in path ({team_id})"}, 

2657 ) 

2658 

2659 patch_fields: Final = data.model_dump(exclude_unset=True, exclude={"team_id"}) 

2660 

2661 if "metadata" in patch_fields: 

2662 existing_team_row: Final = await _team_db(prisma_client).find_unique(where={"team_id": team_id}) 

2663 if existing_team_row is None: 

2664 raise HTTPException( 

2665 status_code=404, 

2666 detail={"error": f"Team not found, passed team_id={team_id}"}, 

2667 ) 

2668 existing_metadata = existing_team_row.metadata if isinstance(existing_team_row.metadata, dict) else {} 

2669 patch_fields["metadata"] = apply_json_merge_patch(existing_metadata, patch_fields["metadata"]) 

2670 

2671 update_request: Final = UpdateTeamRequest.model_validate({"team_id": team_id, **patch_fields}) 

2672 

2673 result: Final = await update_team( 

2674 data=update_request, 

2675 http_request=http_request, 

2676 user_api_key_dict=user_api_key_dict, 

2677 litellm_changed_by=litellm_changed_by, 

2678 ) 

2679 return result["data"] 

2680 except Exception as e: # noqa: BLE001 # normalize every failure to the proxy exception contract 

2681 raise handle_exception_on_proxy(e) 

2682 

2683 

2684def _set_budget_reset_at(data: UpdateTeamRequest, updated_kv: dict) -> None: 

2685 """Set budget_reset_at in updated_kv if budget_duration is provided.""" 

2686 if data.budget_duration is not None: 2686 ↛ 2687line 2686 didn't jump to line 2687 because the condition on line 2686 was never true

2687 from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time 

2688 

2689 reset_at: Final = get_budget_reset_time(budget_duration=data.budget_duration) 

2690 updated_kv["budget_reset_at"] = reset_at 

2691 elif "budget_duration" in updated_kv and updated_kv["budget_duration"] is None: 2691 ↛ 2692line 2691 didn't jump to line 2692 because the condition on line 2691 was never true

2692 updated_kv["budget_reset_at"] = None 

2693 

2694 if data.budget_limits is not None and len(data.budget_limits) > 0: 

2695 from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time 

2696 

2697 initialized_windows: Final = [] 

2698 for window in data.budget_limits: 

2699 w = window if isinstance(window, dict) else window.model_dump() 

2700 w["reset_at"] = get_budget_reset_time(budget_duration=w["budget_duration"]).isoformat() 

2701 initialized_windows.append(w) 

2702 updated_kv["budget_limits"] = json.dumps(initialized_windows) 

2703 

2704 

2705async def handle_update_object_permission(data_json: dict, existing_team_row: _ObjectPermissionRow) -> dict: 

2706 """ 

2707 Handle the update of object permission for a team. 

2708 

2709 - IF there's no object_permission_id, then create a new entry in LiteLLM_ObjectPermissionTable 

2710 - IF there's an object_permission_id, then update the entry in LiteLLM_ObjectPermissionTable 

2711 """ 

2712 from litellm.proxy.proxy_server import prisma_client 

2713 

2714 # Use the common helper to handle the object permission update 

2715 object_permission_id: Final = await handle_update_object_permission_common( 

2716 data_json=data_json, 

2717 existing_object_permission_id=existing_team_row.object_permission_id, 

2718 prisma_client=prisma_client, 

2719 ) 

2720 

2721 # Add the object_permission_id to data_json if one was created/updated 

2722 if object_permission_id is not None: 

2723 data_json["object_permission_id"] = object_permission_id 

2724 verbose_proxy_logger.debug("updated object_permission_id: %s", object_permission_id) 

2725 

2726 return data_json 

2727 

2728 

2729def _check_team_member_admin_add( 

2730 member: Member | list[Member], 

2731 premium_user: bool, 

2732): 

2733 if isinstance(member, Member) and member.role == "admin": 

2734 if premium_user is not True: 2734 ↛ exitline 2734 didn't return from function '_check_team_member_admin_add' because the condition on line 2734 was always true

2735 raise ValueError(f"Assigning team admins is a premium feature. {CommonProxyErrors.not_premium_user.value}") 

2736 elif isinstance(member, list): 

2737 for m in member: 

2738 if m.role == "admin": 

2739 if premium_user is not True: 2739 ↛ 2737line 2739 didn't jump to line 2737 because the condition on line 2739 was always true

2740 raise ValueError( 

2741 f"Assigning team admins is a premium feature. Got={m}. {CommonProxyErrors.not_premium_user.value}. " 

2742 ) 

2743 

2744 

2745def team_call_validation_checks( 

2746 prisma_client: PrismaClient | None, 

2747 data: TeamMemberAddRequest, 

2748 premium_user: bool, 

2749): 

2750 if prisma_client is None: 2750 ↛ 2751line 2750 didn't jump to line 2751 because the condition on line 2750 was never true

2751 raise HTTPException(status_code=500, detail={"error": "No db connected"}) 

2752 

2753 if data.team_id is None: 2753 ↛ 2754line 2753 didn't jump to line 2754 because the condition on line 2753 was never true

2754 raise HTTPException(status_code=400, detail={"error": "No team id passed in"}) 

2755 

2756 if data.member is None: 2756 ↛ 2757line 2756 didn't jump to line 2757 because the condition on line 2756 was never true

2757 raise HTTPException(status_code=400, detail={"error": "No member/members passed in"}) 

2758 

2759 try: 

2760 _check_team_member_admin_add( 

2761 member=data.member, 

2762 premium_user=premium_user, 

2763 ) 

2764 except Exception as e: 

2765 raise HTTPException(status_code=400, detail={"error": str(e)}) 

2766 

2767 

2768def team_member_add_duplication_check( 

2769 data: TeamMemberAddRequest, 

2770 existing_team_row: LiteLLM_TeamTable, 

2771): 

2772 """ 

2773 Check if a member already exists in the team. 

2774 This check is done BEFORE we create/fetch the user, so it only prevents 

2775 obvious duplicates where both user_id and user_email match exactly. 

2776 """ 

2777 

2778 invalid_team_members: Final = [] 

2779 

2780 def _check_member_duplication(member: Member): 

2781 if member.user_id is not None: 2781 ↛ 2787line 2781 didn't jump to line 2787 because the condition on line 2781 was always true

2782 for existing_member in existing_team_row.members_with_roles: 

2783 if existing_member.user_id == member.user_id: 

2784 invalid_team_members.append(member) 

2785 

2786 # Check by user_email if provided 

2787 if member.user_email is not None: 

2788 for existing_member in existing_team_row.members_with_roles: 

2789 if existing_member.user_email == member.user_email: 2789 ↛ 2790line 2789 didn't jump to line 2790 because the condition on line 2789 was never true

2790 invalid_team_members.append(member) 

2791 

2792 # First, populate the invalid_team_members list by checking for duplicates 

2793 if isinstance(data.member, Member): 2793 ↛ 2794line 2793 didn't jump to line 2794 because the condition on line 2793 was never true

2794 _check_member_duplication(data.member) 

2795 elif isinstance(data.member, list): 2795 ↛ 2800line 2795 didn't jump to line 2800 because the condition on line 2795 was always true

2796 for m in data.member: 

2797 _check_member_duplication(m) 

2798 

2799 # Then check the populated list and raise exceptions if needed 

2800 if isinstance(data.member, list) and len(invalid_team_members) == len(data.member): 

2801 raise ProxyException( 

2802 message=f"All users are already in team. Existing members={existing_team_row.members_with_roles}", 

2803 type=ProxyErrorTypes.team_member_already_in_team, 

2804 param="member", 

2805 code="400", 

2806 ) 

2807 elif isinstance(data.member, Member) and len(invalid_team_members) == 1: 2807 ↛ 2808line 2807 didn't jump to line 2808 because the condition on line 2807 was never true

2808 raise ProxyException( 

2809 message=f"User already in team. Member: user_id={data.member.user_id}, user_email={data.member.user_email}. Existing members={existing_team_row.members_with_roles}", 

2810 type=ProxyErrorTypes.team_member_already_in_team, 

2811 param="member", 

2812 code="400", 

2813 ) 

2814 elif len(invalid_team_members) > 0: 2814 ↛ exitline 2814 didn't return from function 'team_member_add_duplication_check' because the condition on line 2814 was always true

2815 verbose_proxy_logger.info( 

2816 "Some users are already in team. Existing members=%s. Duplicate members=%s", 

2817 existing_team_row.members_with_roles, 

2818 invalid_team_members, 

2819 ) 

2820 

2821 

2822async def _validate_team_member_add_permissions( 

2823 user_api_key_dict: UserAPIKeyAuth, 

2824 complete_team_data: LiteLLM_TeamTable, 

2825 data: TeamMemberAddRequest, 

2826) -> None: 

2827 """Validate if user has permission to add members to the team. 

2828 

2829 Standard users can self-join an *available team*, but the bypass 

2830 must not be allowed to escalate them to ``role=admin`` or to add 

2831 other users into the team. When access is granted via the 

2832 available-team bypass we therefore enforce that every member in 

2833 the request matches the caller's own ``user_id`` and is being 

2834 added with ``role="user"``. 

2835 """ 

2836 if getattr(user_api_key_dict, "user_role", None) == LitellmUserRoles.PROXY_ADMIN.value: 2836 ↛ 2838line 2836 didn't jump to line 2838 because the condition on line 2836 was always true

2837 return 

2838 if _is_user_team_admin(user_api_key_dict=user_api_key_dict, team_obj=complete_team_data): 

2839 return 

2840 if await _is_user_org_admin_for_team(user_api_key_dict=user_api_key_dict, team_obj=complete_team_data): 

2841 return 

2842 

2843 if not _is_available_team( 

2844 team_id=complete_team_data.team_id, 

2845 user_api_key_dict=user_api_key_dict, 

2846 ): 

2847 raise HTTPException( 

2848 status_code=403, 

2849 detail={ 

2850 "error": "Call not allowed. User not proxy admin OR team admin. route={}, team_id={}".format( 

2851 "/team/member_add", 

2852 complete_team_data.team_id, 

2853 ) 

2854 }, 

2855 ) 

2856 

2857 # Available-team self-join grants only the ability to join; per-member 

2858 # budget and model controls stay admin-only. Reject them here so a 

2859 # self-joining non-admin cannot set their own cap, reset window, or model 

2860 # scope via the bypass. 

2861 if data.max_budget_in_team is not None or data.budget_duration is not None or data.allowed_models is not None: 

2862 raise HTTPException( 

2863 status_code=403, 

2864 detail={ 

2865 "error": ( 

2866 "Available-team self-join cannot set per-member budget or " 

2867 "model controls (max_budget_in_team, budget_duration, " 

2868 "allowed_models); these are admin-only." 

2869 ) 

2870 }, 

2871 ) 

2872 

2873 # Available-team self-join: caller may add only themselves, only as a 

2874 # standard user. Enforce that here so the bypass cannot be used as a 

2875 # privilege-escalation or cross-user-injection primitive. 

2876 members: Final = data.member if isinstance(data.member, list) else [data.member] 

2877 caller_user_id: Final = getattr(user_api_key_dict, "user_id", None) 

2878 for member in members: 

2879 if getattr(member, "role", "user") != "user": 

2880 raise HTTPException( 

2881 status_code=403, 

2882 detail={ 

2883 "error": ( 

2884 "Available-team self-join cannot assign 'admin' role. " 

2885 "Only proxy/team/org admins can add admins to a team." 

2886 ) 

2887 }, 

2888 ) 

2889 member_user_id = getattr(member, "user_id", None) 

2890 if not caller_user_id or not member_user_id or member_user_id != caller_user_id: 

2891 raise HTTPException( 

2892 status_code=403, 

2893 detail={ 

2894 "error": ( 

2895 "Available-team self-join can only add the caller " 

2896 "(user_id must match the authenticated user's user_id)." 

2897 ) 

2898 }, 

2899 ) 

2900 

2901 

2902async def _process_team_members( 

2903 data: TeamMemberAddRequest, 

2904 complete_team_data: LiteLLM_TeamTable, 

2905 prisma_client: PrismaClient, 

2906 user_api_key_dict: UserAPIKeyAuth, 

2907 litellm_proxy_admin_name: str, 

2908 tx: MemberWriteTx | None = None, 

2909) -> tuple[list[LiteLLM_UserTable], list[LiteLLM_TeamMembership]]: 

2910 """Process and add new team members. 

2911 

2912 ``tx`` is the caller's open transaction, when it has one, so the member writes run on the 

2913 connection it already holds instead of checking out a second one. 

2914 """ 

2915 updated_users: Final[list[LiteLLM_UserTable]] = [] 

2916 updated_team_memberships: Final[list[LiteLLM_TeamMembership]] = [] 

2917 

2918 default_team_budget_id: Final = ( 

2919 complete_team_data.metadata.get("team_member_budget_id") if complete_team_data.metadata is not None else None 

2920 ) 

2921 

2922 # Resolve allowed_models: explicit request value, or fall back to team's default_team_member_models 

2923 member_allowed_models = data.allowed_models 

2924 team_default_member_models: Final = getattr(complete_team_data, "default_team_member_models", None) 

2925 if member_allowed_models is None and team_default_member_models: 2925 ↛ 2926line 2925 didn't jump to line 2926 because the condition on line 2925 was never true

2926 member_allowed_models = team_default_member_models 

2927 

2928 requested_members: Final[Sequence[Member]] = ( 

2929 (data.member,) if isinstance(data.member, Member) else tuple(data.member) 

2930 ) 

2931 for m in requested_members: 

2932 if _member_already_in_team(m, complete_team_data): 

2933 continue 

2934 try: 

2935 updated_user, updated_tm = await add_new_member( 

2936 new_member=m, 

2937 max_budget_in_team=data.max_budget_in_team, 

2938 prisma_client=prisma_client, 

2939 user_api_key_dict=user_api_key_dict, 

2940 litellm_proxy_admin_name=litellm_proxy_admin_name, 

2941 team_id=data.team_id, 

2942 default_team_budget_id=default_team_budget_id, 

2943 allowed_models=member_allowed_models, 

2944 budget_duration=data.budget_duration, 

2945 tx=tx, 

2946 ) 

2947 except Exception as e: 

2948 raise HTTPException( 

2949 status_code=500, 

2950 detail={"error": f"Unable to add user - {m}, to team - {data.team_id}, for reason - {e}"}, 

2951 ) 

2952 updated_users.append(updated_user) 

2953 if updated_tm is not None: 2953 ↛ 2931line 2953 didn't jump to line 2931 because the condition on line 2953 was always true

2954 updated_team_memberships.append(updated_tm) 

2955 

2956 return updated_users, updated_team_memberships 

2957 

2958 

2959def _resolve_member_identity(member: Member, updated_users: Sequence[LiteLLM_UserTable]) -> Member: 

2960 """Return ``member`` with whichever of ``user_id`` / ``user_email`` the caller left out filled in. 

2961 

2962 The roster entry is a snapshot, so whatever is missing here is missing for good. 

2963 Resolution runs both ways off the user rows the add just touched: added by email 

2964 -> stamp the user_id, added by user_id -> stamp the email. A value the caller 

2965 supplied is never overwritten. 

2966 """ 

2967 resolved_user_id: Final = member.user_id or next( 

2968 ( 

2969 user.user_id 

2970 for user in updated_users 

2971 if member.user_email is not None and user.user_email == member.user_email 

2972 ), 

2973 None, 

2974 ) 

2975 resolved_user_email: Final = member.user_email or next( 

2976 ( 

2977 user.user_email 

2978 for user in updated_users 

2979 if resolved_user_id is not None and user.user_id == resolved_user_id and user.user_email is not None 

2980 ), 

2981 None, 

2982 ) 

2983 return member.model_copy( 

2984 update={ # mutable-ok: pydantic update payload 

2985 "user_id": resolved_user_id, 

2986 "user_email": resolved_user_email, 

2987 } 

2988 ) 

2989 

2990 

2991def _member_already_in_team(member: Member, complete_team_data: LiteLLM_TeamTable) -> bool: 

2992 return any( 

2993 (member.user_id is not None and existing_member.user_id == member.user_id) 

2994 or (member.user_email is not None and existing_member.user_email == member.user_email) 

2995 for existing_member in complete_team_data.members_with_roles 

2996 ) 

2997 

2998 

2999async def _update_team_members_list( 

3000 data: TeamMemberAddRequest, 

3001 complete_team_data: LiteLLM_TeamTable, 

3002 updated_users: list[LiteLLM_UserTable], 

3003) -> None: 

3004 """Update the team's members_with_roles list.""" 

3005 requested_members: Final[Sequence[Member]] = ( 

3006 (data.member,) if isinstance(data.member, Member) else tuple(data.member) 

3007 ) 

3008 resolved_members: Final = tuple(_resolve_member_identity(m, updated_users) for m in requested_members) 

3009 

3010 # extend() consumes the generator as it appends, so a member already added by this 

3011 # same call is seen by the next _member_already_in_team check - the batch dedupes 

3012 # against itself exactly as the append-one-at-a-time loop this replaced did. 

3013 complete_team_data.members_with_roles.extend( 

3014 m for m in resolved_members if not _member_already_in_team(m, complete_team_data) 

3015 ) 

3016 

3017 

3018async def _add_team_members_to_team( 

3019 data: TeamMemberAddRequest, 

3020 complete_team_data: LiteLLM_TeamTable, 

3021 prisma_client: PrismaClient, 

3022 user_api_key_dict: UserAPIKeyAuth, 

3023 litellm_proxy_admin_name: str, 

3024) -> tuple["prisma_models.LiteLLM_TeamTable", list[LiteLLM_UserTable], list[LiteLLM_TeamMembership]]: 

3025 """Add team members to the team, under the team's advisory lock. 

3026 

3027 The lock (``TEAM_ADVISORY_LOCK_SQL``, keyed on the team id) is taken first, and the 

3028 team is re-read under it before any write, so a delete that already committed is 

3029 visible here before this call writes anything: the user and membership writes only 

3030 happen once the re-read proves the team is still live. /team/delete takes the same 

3031 lock around its own sweep-and-delete, so the two can never interleave; whichever 

3032 acquires the lock first runs to completion before the other's re-read can proceed. 

3033 

3034 The user and membership writes run on this transaction too, not on a second 

3035 connection from the pool: a lock waiter that needs a connection it hasn't got yet is 

3036 a waiter that can deadlock the pool, since enough concurrent adds for one team would 

3037 hold every connection waiting on the lock while the holder waits for a free one. 

3038 """ 

3039 gone_detail: Final[_ErrorDetail] = {"error": f"Team={data.team_id} was deleted while this member add was running"} 

3040 async with prisma_client.tx() as tx: 

3041 await tx.query_raw(TEAM_ADVISORY_LOCK_SQL, data.team_id) 

3042 

3043 locked_members: Final = await TeamRepository(prisma_client).get_members_with_roles_locked(tx, data.team_id) 

3044 if locked_members is None: 3044 ↛ 3045line 3044 didn't jump to line 3045 because the condition on line 3044 was never true

3045 raise HTTPException(status_code=404, detail=gone_detail) 

3046 complete_team_data.members_with_roles = locked_members 

3047 

3048 updated_users, updated_team_memberships = await _process_team_members( 

3049 data=data, 

3050 complete_team_data=complete_team_data, 

3051 prisma_client=prisma_client, 

3052 user_api_key_dict=user_api_key_dict, 

3053 litellm_proxy_admin_name=litellm_proxy_admin_name, 

3054 tx=tx, 

3055 ) 

3056 

3057 await _update_team_members_list( 

3058 data=data, 

3059 complete_team_data=complete_team_data, 

3060 updated_users=updated_users, 

3061 ) 

3062 

3063 _db_team_members: Final = [m.model_dump() for m in complete_team_data.members_with_roles] 

3064 updated_team: Final = await _team_tx_db(tx).update( 

3065 where={"team_id": data.team_id}, 

3066 data={"members_with_roles": json.dumps(_db_team_members)}, 

3067 ) 

3068 if updated_team is None: 3068 ↛ 3069line 3068 didn't jump to line 3069 because the condition on line 3068 was never true

3069 raise HTTPException(status_code=404, detail=gone_detail) 

3070 

3071 return updated_team, updated_users, updated_team_memberships 

3072 

3073 

3074async def _update_team_member_role( 

3075 tx: "Prisma", 

3076 prisma_client: PrismaClient, 

3077 team_id: str, 

3078 user_id: str, 

3079 role: Literal["admin", "user"], 

3080 user_email: str | None, 

3081) -> tuple[tuple[Member, ...], tuple[Member, ...]]: 

3082 """Rewrite one member's role from the roster read under the team lock; returns (before, after).""" 

3083 await tx.query_raw(TEAM_ADVISORY_LOCK_SQL, team_id) 

3084 

3085 locked_members: Final = await TeamRepository(prisma_client).get_members_with_roles_locked(tx, team_id) 

3086 if locked_members is None: 3086 ↛ 3087line 3086 didn't jump to line 3087 because the condition on line 3086 was never true

3087 raise HTTPException(status_code=404, detail={"error": f"Team id={team_id} does not exist in db"}) 

3088 

3089 before: Final = tuple(locked_members) 

3090 if all(member.user_id != user_id for member in before): 3090 ↛ 3093line 3090 didn't jump to line 3093 because the condition on line 3090 was always true

3091 raise HTTPException(status_code=404, detail={"error": f"User {user_id} is not a member of team {team_id}"}) 

3092 

3093 after: Final = tuple( 

3094 Member(user_id=member.user_id, role=role, user_email=user_email or member.user_email) 

3095 if member.user_id == user_id 

3096 else member 

3097 for member in before 

3098 ) 

3099 await _team_tx_db(tx).update( 

3100 where={"team_id": team_id}, 

3101 data={"members_with_roles": json.dumps([m.model_dump() for m in after])}, 

3102 ) 

3103 return before, after 

3104 

3105 

3106def _emit_team_members_metric(team: LiteLLM_TeamTable) -> None: 

3107 """Update the Prometheus team members gauge after a membership change. 

3108 

3109 No-ops when the Prometheus callback is not registered, and never lets a 

3110 metric failure break the team add/delete request. 

3111 """ 

3112 prometheus_logger: Final = PrometheusLogger.get_instance() 

3113 if prometheus_logger is None: 3113 ↛ 3115line 3113 didn't jump to line 3115 because the condition on line 3113 was always true

3114 return 

3115 try: 

3116 prometheus_logger.set_team_members_metric(team) 

3117 except Exception as e: 

3118 verbose_proxy_logger.debug("Prometheus: failed to emit team members metric: %s", str(e)) 

3119 

3120 

3121async def _resolve_existing_member_user_ids( 

3122 members: Sequence[Member], 

3123 prisma_client: PrismaClient, 

3124) -> frozenset[str]: 

3125 """Return the caller-supplied user_ids that already have a user row. 

3126 

3127 Resolved with a single query so the number of members in the request does 

3128 not translate into that many concurrent connections. 

3129 """ 

3130 requested_user_ids: Final = frozenset(member.user_id for member in members if member.user_id is not None) 

3131 if not requested_user_ids: 3131 ↛ 3132line 3131 didn't jump to line 3132 because the condition on line 3131 was never true

3132 return frozenset() 

3133 

3134 found: Final = await _user_id_rows_db(UserRepository(prisma_client)).find_many( 

3135 where={ # mutable-ok: Prisma query filters are dict-shaped 

3136 "user_id": { # mutable-ok: Prisma query filters are dict-shaped 

3137 "in": sorted(requested_user_ids) 

3138 } 

3139 } 

3140 ) 

3141 return frozenset(user.user_id for user in found or () if user.user_id is not None) 

3142 

3143 

3144def _pre_existing_user_ids( 

3145 members: Sequence[Member], 

3146 caller_supplied_user_ids: frozenset[str], 

3147 existing_user_ids: frozenset[str], 

3148) -> frozenset[str]: 

3149 """Return the user_ids that already had a user row before this request. 

3150 

3151 Combines the caller-supplied ids that resolved to a user with the ids 

3152 ``_validate_and_populate_member_user_info`` filled in, which it only does 

3153 from a matched user row. Deriving it that way keeps this in step with the 

3154 email matching that resolution performs, rather than repeating it here. 

3155 """ 

3156 populated_user_ids: Final = frozenset( 

3157 member.user_id 

3158 for member in members 

3159 if member.user_id is not None and member.user_id not in caller_supplied_user_ids 

3160 ) 

3161 return existing_user_ids | populated_user_ids 

3162 

3163 

3164_MAX_REPORTED_UNKNOWN_USER_IDS: Final = 10 

3165 

3166 

3167def _validate_member_user_id_provisioning( 

3168 members: Sequence[Member], 

3169 existing_user_ids: frozenset[str], 

3170 user_api_key_dict: UserAPIKeyAuth, 

3171) -> None: 

3172 """Restrict adding a caller-chosen user_id that has no user row yet to proxy admins. 

3173 

3174 Team and org admins keep the ability to add users that already exist and to 

3175 invite new ones by user_email, where the user_id is allocated server-side. 

3176 """ 

3177 if user_api_key_dict.user_role in ( 3177 ↛ 3183line 3177 didn't jump to line 3183 because the condition on line 3177 was always true

3178 LitellmUserRoles.PROXY_ADMIN, 

3179 LitellmUserRoles.PROXY_ADMIN.value, 

3180 ): 

3181 return 

3182 

3183 unknown_user_ids: Final = tuple( 

3184 member.user_id for member in members if member.user_id is not None and member.user_id not in existing_user_ids 

3185 ) 

3186 if not unknown_user_ids: 

3187 return 

3188 

3189 listed: Final = ", ".join(unknown_user_ids[:_MAX_REPORTED_UNKNOWN_USER_IDS]) 

3190 remaining: Final = len(unknown_user_ids) - _MAX_REPORTED_UNKNOWN_USER_IDS 

3191 raise HTTPException( 

3192 status_code=403, 

3193 detail={ # mutable-ok: HTTPException detail must be a plain mapping to keep this route's {"error": ...} response shape 

3194 "error": ( 

3195 "Only proxy admins can add a user_id that does not exist yet: {}{}. " 

3196 "Add the member by user_email to invite a new user, or ask a proxy admin " 

3197 "to create the user first.".format(listed, f" and {remaining} more" if remaining > 0 else "") 

3198 ) 

3199 }, 

3200 ) 

3201 

3202 

3203def _members_audit_value(team_alias: str | None, members: Sequence[Member]) -> str: 

3204 """Serialize a team's member list for an audit-log value. 

3205 

3206 The audit-log columns hold a JSON object, so the member list is nested 

3207 under a key rather than serialized as a top-level array. 

3208 """ 

3209 return safe_dumps( 

3210 { # mutable-ok: the audit-log JSON column rejects a top-level array, so this value must be an object 

3211 "team_alias": team_alias, 

3212 "members_with_roles": tuple(member.model_dump() for member in members), 

3213 } 

3214 ) 

3215 

3216 

3217def _schedule_team_membership_audit_log( 

3218 team_id: str, 

3219 team_alias: str | None, 

3220 before_members: Sequence[Member], 

3221 after_members: Sequence[Member], 

3222 user_api_key_dict: UserAPIKeyAuth, 

3223 litellm_proxy_admin_name: str, 

3224) -> None: 

3225 from litellm.proxy.management_helpers.audit_logs import ( 

3226 create_object_audit_log, 

3227 is_audit_logging_enabled, 

3228 ) 

3229 

3230 if not is_audit_logging_enabled() or tuple(before_members) == tuple(after_members): 

3231 return 

3232 

3233 asyncio.create_task( 

3234 create_object_audit_log( 

3235 object_id=team_id, 

3236 action="updated", 

3237 litellm_changed_by=None, 

3238 user_api_key_dict=user_api_key_dict, 

3239 litellm_proxy_admin_name=litellm_proxy_admin_name, 

3240 table_name=LitellmTableNames.TEAM_TABLE_NAME, 

3241 before_value=_members_audit_value(team_alias, before_members), 

3242 after_value=_members_audit_value(team_alias, after_members), 

3243 ) 

3244 ) 

3245 

3246 

3247def _schedule_team_member_add_audit_logs( 

3248 team_id: str, 

3249 team_alias: str | None, 

3250 updated_users: Sequence[LiteLLM_UserTable], 

3251 existing_user_ids: frozenset[str], 

3252 before_members: Sequence[Member], 

3253 after_members: Sequence[Member], 

3254 user_api_key_dict: UserAPIKeyAuth, 

3255 litellm_proxy_admin_name: str, 

3256) -> None: 

3257 """Record the membership change, and any user row it created, in the audit log.""" 

3258 from litellm.proxy.management_helpers.audit_logs import ( 

3259 create_object_audit_log, 

3260 is_audit_logging_enabled, 

3261 ) 

3262 

3263 if not is_audit_logging_enabled(): 3263 ↛ 3266line 3263 didn't jump to line 3266 because the condition on line 3263 was always true

3264 return 

3265 

3266 for user in updated_users: 

3267 if user.user_id in existing_user_ids: 

3268 continue 

3269 asyncio.create_task( 

3270 create_object_audit_log( 

3271 object_id=user.user_id, 

3272 action="created", 

3273 litellm_changed_by=None, 

3274 user_api_key_dict=user_api_key_dict, 

3275 litellm_proxy_admin_name=litellm_proxy_admin_name, 

3276 table_name=LitellmTableNames.USER_TABLE_NAME, 

3277 before_value=None, 

3278 after_value=safe_dumps(user.model_dump(exclude_none=True)), 

3279 ) 

3280 ) 

3281 

3282 _schedule_team_membership_audit_log( 

3283 team_id=team_id, 

3284 team_alias=team_alias, 

3285 before_members=before_members, 

3286 after_members=after_members, 

3287 user_api_key_dict=user_api_key_dict, 

3288 litellm_proxy_admin_name=litellm_proxy_admin_name, 

3289 ) 

3290 

3291 

3292async def _validate_and_populate_member_user_info( 

3293 member: Member, 

3294 prisma_client: PrismaClient, 

3295) -> Member: 

3296 """ 

3297 Validate and populate user_email/user_id for a member. 

3298 

3299 Logic: 

3300 1. If both user_email and user_id are provided, verify they belong to the same user (use user_email as source of truth) 

3301 2. If only user_email is provided, populate user_id from DB 

3302 3. If only user_id is provided, populate user_email from DB (if user exists) 

3303 4. If only user_id is provided and doesn't exist, allow it to pass with user_email as None (will be upserted later) 

3304 5. If user_email and user_id mismatch, throw error 

3305 

3306 Returns a Member with user_email and user_id populated (user_email may be None if only user_id provided and user doesn't exist). 

3307 """ 

3308 if member.user_email is None and member.user_id is None: 3308 ↛ 3309line 3308 didn't jump to line 3309 because the condition on line 3308 was never true

3309 raise HTTPException( 

3310 status_code=400, 

3311 detail={"error": "Either user_id or user_email must be provided"}, 

3312 ) 

3313 

3314 # Case 1: Both user_email and user_id provided - verify they match 

3315 if member.user_email is not None and member.user_id is not None: 

3316 # Use user_email as source of truth 

3317 # Check for multiple users with same email first 

3318 users_by_email = await prisma_client.get_data( 

3319 key_val={"user_email": member.user_email}, 

3320 table_name="user", 

3321 query_type="find_all", 

3322 ) 

3323 

3324 if users_by_email is None or (isinstance(users_by_email, list) and len(users_by_email) == 0): 

3325 # User doesn't exist yet - this is fine, will be created later 

3326 return member 

3327 

3328 if isinstance(users_by_email, list) and len(users_by_email) > 1: 3328 ↛ 3329line 3328 didn't jump to line 3329 because the condition on line 3328 was never true

3329 raise HTTPException( 

3330 status_code=400, 

3331 detail={ 

3332 "error": f"Multiple users found with email '{member.user_email}'. Please use 'user_id' instead." 

3333 }, 

3334 ) 

3335 

3336 # Get the single user 

3337 user_by_email = users_by_email[0] 

3338 

3339 # Verify the user_id matches 

3340 if user_by_email.user_id != member.user_id: 3340 ↛ 3341line 3340 didn't jump to line 3341 because the condition on line 3340 was never true

3341 raise HTTPException( 

3342 status_code=400, 

3343 detail={ 

3344 "error": f"user_email '{member.user_email}' and user_id '{member.user_id}' do not belong to the same user." 

3345 }, 

3346 ) 

3347 

3348 # Both match, return as is 

3349 return member 

3350 

3351 # Case 2: Only user_email provided - populate user_id from DB 

3352 if member.user_email is not None and member.user_id is None: 3352 ↛ 3353line 3352 didn't jump to line 3353 because the condition on line 3352 was never true

3353 user_by_email = await _user_db(prisma_client).find_first( 

3354 where={"user_email": {"equals": member.user_email, "mode": "insensitive"}} 

3355 ) 

3356 

3357 if user_by_email is None: 

3358 # User doesn't exist yet - this is fine, will be created later 

3359 return member 

3360 

3361 # Check for multiple users with same email 

3362 users_by_email = await prisma_client.get_data( 

3363 key_val={"user_email": member.user_email}, 

3364 table_name="user", 

3365 query_type="find_all", 

3366 ) 

3367 

3368 if users_by_email and isinstance(users_by_email, list) and len(users_by_email) > 1: 

3369 raise HTTPException( 

3370 status_code=400, 

3371 detail={ 

3372 "error": f"Multiple users found with email '{member.user_email}'. Please use 'user_id' instead." 

3373 }, 

3374 ) 

3375 

3376 # Populate user_id 

3377 member.user_id = user_by_email.user_id 

3378 return member 

3379 

3380 # Case 3: Only user_id provided - populate user_email from DB if user exists 

3381 if member.user_id is not None and member.user_email is None: 3381 ↛ 3393line 3381 didn't jump to line 3393 because the condition on line 3381 was always true

3382 user_by_id: Final = await _user_db(prisma_client).find_unique(where={"user_id": member.user_id}) 

3383 

3384 if user_by_id is None: 

3385 # User doesn't exist yet - allow it to pass with user_email as None 

3386 # Will be upserted later with just user_id and null email 

3387 return member 

3388 

3389 # Populate user_email 

3390 member.user_email = user_by_id.user_email 

3391 return member 

3392 

3393 return member 

3394 

3395 

3396@router.post( 

3397 "/team/member_add", 

3398 tags=["team management"], 

3399 dependencies=[Depends(user_api_key_auth)], 

3400 response_model=TeamAddMemberResponse, 

3401) 

3402@management_endpoint_wrapper 

3403async def team_member_add( 

3404 data: TeamMemberAddRequest, 

3405 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), 

3406): 

3407 """ 

3408 Add new members (either via user_email or user_id) to a team 

3409 

3410 If user doesn't exist, new user row will also be added to User Table 

3411 

3412 Only proxy_admin or admin of team, allowed to access this endpoint. 

3413 ``` 

3414 

3415 curl -X POST 'http://0.0.0.0:4000/team/member_add' \ 

3416 -H 'Authorization: Bearer sk-1234' \ 

3417 -H 'Content-Type: application/json' \ 

3418 -d '{"team_id": "45e3e396-ee08-4a61-a88e-16b3ce7e0849", "member": {"role": "user", "user_id": "krrish247652@berri.ai"}}' 

3419 

3420 ``` 

3421 """ 

3422 from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import evict_and_broadcast 

3423 from litellm.proxy.proxy_server import ( 

3424 litellm_proxy_admin_name, 

3425 premium_user, 

3426 prisma_client, 

3427 proxy_logging_obj, 

3428 user_api_key_cache, 

3429 ) 

3430 

3431 try: 

3432 team_call_validation_checks( 

3433 prisma_client=prisma_client, 

3434 data=data, 

3435 premium_user=premium_user, 

3436 ) 

3437 except HTTPException as e: 

3438 raise e 

3439 

3440 validate_budget_duration(data.budget_duration) 

3441 

3442 prisma_client = cast(PrismaClient, prisma_client) 

3443 

3444 existing_team_row: Final = await get_team_object( 

3445 team_id=data.team_id, 

3446 prisma_client=prisma_client, 

3447 user_api_key_cache=user_api_key_cache, 

3448 parent_otel_span=None, 

3449 proxy_logging_obj=proxy_logging_obj, 

3450 check_cache_only=False, 

3451 check_db_only=True, 

3452 ) 

3453 if existing_team_row is None: 3453 ↛ 3454line 3453 didn't jump to line 3454 because the condition on line 3453 was never true

3454 raise HTTPException( 

3455 status_code=404, 

3456 detail={"error": f"Team not found for team_id={getattr(data, 'team_id', None)}"}, 

3457 ) 

3458 

3459 complete_team_data: Final = LiteLLM_TeamTable.model_validate(existing_team_row.model_dump()) 

3460 

3461 team_member_add_duplication_check( 

3462 data=data, 

3463 existing_team_row=complete_team_data, 

3464 ) 

3465 

3466 # Validate permissions 

3467 await _validate_team_member_add_permissions( 

3468 user_api_key_dict=user_api_key_dict, 

3469 complete_team_data=complete_team_data, 

3470 data=data, 

3471 ) 

3472 

3473 requested_members: Final = tuple(data.member) if isinstance(data.member, list) else (data.member,) 

3474 caller_supplied_user_ids = frozenset(member.user_id for member in requested_members if member.user_id is not None) 

3475 existing_user_ids: Final = await _resolve_existing_member_user_ids( 

3476 members=requested_members, 

3477 prisma_client=prisma_client, 

3478 ) 

3479 _validate_member_user_id_provisioning( 

3480 members=requested_members, 

3481 existing_user_ids=existing_user_ids, 

3482 user_api_key_dict=user_api_key_dict, 

3483 ) 

3484 members_before_add: Final = tuple(complete_team_data.members_with_roles) 

3485 

3486 # Validate and populate user_email/user_id for members before processing 

3487 if isinstance(data.member, Member): 3487 ↛ 3488line 3487 didn't jump to line 3488 because the condition on line 3487 was never true

3488 await _validate_and_populate_member_user_info( 

3489 member=data.member, 

3490 prisma_client=prisma_client, 

3491 ) 

3492 elif isinstance(data.member, list): 3492 ↛ 3499line 3492 didn't jump to line 3499 because the condition on line 3492 was always true

3493 for m in data.member: 

3494 await _validate_and_populate_member_user_info( 

3495 member=m, 

3496 prisma_client=prisma_client, 

3497 ) 

3498 

3499 pre_existing_user_ids: Final = _pre_existing_user_ids( 

3500 members=requested_members, 

3501 caller_supplied_user_ids=caller_supplied_user_ids, 

3502 existing_user_ids=existing_user_ids, 

3503 ) 

3504 

3505 ( 

3506 updated_team, 

3507 updated_users, 

3508 updated_team_memberships, 

3509 ) = await _add_team_members_to_team( 

3510 data=data, 

3511 complete_team_data=complete_team_data, 

3512 prisma_client=prisma_client, 

3513 user_api_key_dict=user_api_key_dict, 

3514 litellm_proxy_admin_name=litellm_proxy_admin_name, 

3515 ) 

3516 

3517 await evict_and_broadcast( 

3518 cache_keys=tuple(sorted(user.user_id for user in updated_users)), 

3519 user_api_key_cache=user_api_key_cache, 

3520 ) 

3521 await _evict_created_membership_caches( 

3522 user_ids=(tm.user_id for tm in updated_team_memberships), 

3523 team_id=data.team_id, 

3524 user_api_key_cache=user_api_key_cache, 

3525 ) 

3526 

3527 _emit_team_members_metric(complete_team_data) 

3528 

3529 _schedule_team_member_add_audit_logs( 

3530 team_id=data.team_id, 

3531 team_alias=complete_team_data.team_alias, 

3532 updated_users=updated_users, 

3533 existing_user_ids=pre_existing_user_ids, 

3534 before_members=members_before_add, 

3535 after_members=tuple(complete_team_data.members_with_roles), 

3536 user_api_key_dict=user_api_key_dict, 

3537 litellm_proxy_admin_name=litellm_proxy_admin_name, 

3538 ) 

3539 

3540 return TeamAddMemberResponse.model_validate( 

3541 { 

3542 **updated_team.model_dump(), 

3543 "updated_users": updated_users, 

3544 "updated_team_memberships": updated_team_memberships, 

3545 } 

3546 ) 

3547 

3548 

3549def _is_member_addressed_by(member: Member, data: TeamMemberDeleteRequest) -> bool: 

3550 return (data.user_id is not None and member.user_id is not None and data.user_id == member.user_id) or ( 

3551 data.user_email is not None and member.user_email is not None and data.user_email == member.user_email 

3552 ) 

3553 

3554 

3555def _cleanup_members_with_roles( 

3556 existing_team_row: LiteLLM_TeamTable, 

3557 data: TeamMemberDeleteRequest, 

3558) -> tuple[tuple[Member, ...], list[Member]]: 

3559 """Split a team's members_with_roles into the entries the request addresses and the ones that stay. 

3560 

3561 The addressed entries are returned rather than a bare found/not-found flag because they carry the 

3562 user_id the request may not have supplied, and every cleanup that keys off the user rather than 

3563 off the roster has to run against that id. 

3564 """ 

3565 removed_team_members: Final = tuple( 

3566 m for m in existing_team_row.members_with_roles if _is_member_addressed_by(m, data) 

3567 ) 

3568 new_team_members: Final = [m for m in existing_team_row.members_with_roles if not _is_member_addressed_by(m, data)] 

3569 return removed_team_members, new_team_members 

3570 

3571 

3572@router.post( 

3573 "/team/member_delete", 

3574 tags=["team management"], 

3575 dependencies=[Depends(user_api_key_auth)], 

3576) 

3577@management_endpoint_wrapper 

3578async def team_member_delete( 

3579 data: TeamMemberDeleteRequest, 

3580 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), 

3581): 

3582 """ 

3583 [BETA] 

3584 

3585 delete members (either via user_email or user_id) from a team 

3586 

3587 If user doesn't exist, an exception will be raised 

3588 ``` 

3589 curl -X POST 'http://0.0.0.0:8000/team/member_delete' \ 

3590 

3591 -H 'Authorization: Bearer sk-1234' \ 

3592 

3593 -H 'Content-Type: application/json' \ 

3594 

3595 -d '{ 

3596 "team_id": "45e3e396-ee08-4a61-a88e-16b3ce7e0849", 

3597 "user_id": "krrish247652@berri.ai" 

3598 }' 

3599 ``` 

3600 """ 

3601 from litellm.proxy.proxy_server import litellm_proxy_admin_name 

3602 

3603 existing_team_row, before_members, after_members = await _team_member_delete( 

3604 data=data, user_api_key_dict=user_api_key_dict 

3605 ) 

3606 

3607 _schedule_team_membership_audit_log( 

3608 team_id=existing_team_row.team_id, 

3609 team_alias=existing_team_row.team_alias, 

3610 before_members=before_members, 

3611 after_members=after_members, 

3612 user_api_key_dict=user_api_key_dict, 

3613 litellm_proxy_admin_name=litellm_proxy_admin_name, 

3614 ) 

3615 

3616 return existing_team_row 

3617 

3618 

3619async def _team_member_delete( 

3620 data: TeamMemberDeleteRequest, 

3621 user_api_key_dict: UserAPIKeyAuth, 

3622) -> tuple[LiteLLM_TeamTable, tuple[Member, ...], tuple[Member, ...]]: 

3623 from litellm.proxy.proxy_server import ( 

3624 prisma_client, 

3625 proxy_logging_obj, 

3626 user_api_key_cache, 

3627 ) 

3628 

3629 if prisma_client is None: 3629 ↛ 3630line 3629 didn't jump to line 3630 because the condition on line 3629 was never true

3630 raise HTTPException(status_code=500, detail={"error": "No db connected"}) 

3631 

3632 if data.team_id is None: 3632 ↛ 3633line 3632 didn't jump to line 3633 because the condition on line 3632 was never true

3633 raise HTTPException(status_code=400, detail={"error": "No team id passed in"}) 

3634 

3635 if data.user_id is None and data.user_email is None: 3635 ↛ 3636line 3635 didn't jump to line 3636 because the condition on line 3635 was never true

3636 raise HTTPException( 

3637 status_code=400, 

3638 detail={"error": "Either user_id or user_email needs to be passed in"}, 

3639 ) 

3640 

3641 _existing_team_row: Final = await _team_db(prisma_client).find_unique(where={"team_id": data.team_id}) 

3642 

3643 if _existing_team_row is None: 

3644 raise HTTPException( 

3645 status_code=400, 

3646 detail={"error": f"Team id={data.team_id} does not exist in db"}, 

3647 ) 

3648 existing_team_row: Final = LiteLLM_TeamTable.model_validate(_existing_team_row.model_dump()) 

3649 

3650 ## CHECK IF USER IS PROXY ADMIN OR TEAM ADMIN OR ORG ADMIN 

3651 

3652 if ( 3652 ↛ 3657line 3652 didn't jump to line 3657 because the condition on line 3652 was never true

3653 user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value 

3654 and not _is_user_team_admin(user_api_key_dict=user_api_key_dict, team_obj=existing_team_row) 

3655 and not await _is_user_org_admin_for_team(user_api_key_dict=user_api_key_dict, team_obj=existing_team_row) 

3656 ): 

3657 raise HTTPException( 

3658 status_code=403, 

3659 detail={ 

3660 "error": "Call not allowed. User not proxy admin OR team admin. route={}, team_id={}".format( 

3661 "/team/member_delete", existing_team_row.team_id 

3662 ) 

3663 }, 

3664 ) 

3665 

3666 ## DELETE MEMBER FROM TEAM 

3667 # Everything from here on runs under the team's advisory lock, the same one 

3668 # /team/member_add and /team/delete take: without it, this endpoint's own row-level 

3669 # update lock used to be the only thing serializing it against a concurrent member_add, 

3670 # and only by accident (their SELECT ... FOR UPDATE contended for the same row lock this 

3671 # UPDATE takes). Now that member_add reads under the advisory lock instead, this has to 

3672 # take it too, and re-read the roster under it rather than off the snapshot validated 

3673 # above, or a member_add that commits in between can have its addition silently 

3674 # overwritten by this delete computing from stale data. 

3675 async with prisma_client.tx() as tx: 

3676 await tx.query_raw(TEAM_ADVISORY_LOCK_SQL, data.team_id) 

3677 

3678 fresh_members: Final = await TeamRepository(prisma_client).get_members_with_roles_locked(tx, data.team_id) 

3679 if fresh_members is None: 3679 ↛ 3680line 3679 didn't jump to line 3680 because the condition on line 3679 was never true

3680 raise HTTPException( 

3681 status_code=400, 

3682 detail={"error": f"Team id={data.team_id} does not exist in db"}, 

3683 ) 

3684 

3685 removed_team_members, new_team_members = _cleanup_members_with_roles( 

3686 existing_team_row=LiteLLM_TeamTable(team_id=data.team_id, members_with_roles=fresh_members), 

3687 data=data, 

3688 ) 

3689 

3690 existing_team_row.members_with_roles = new_team_members 

3691 

3692 _db_new_team_members: Final[list[dict]] = [m.model_dump() for m in new_team_members] 

3693 

3694 ## DELETE TEAM ID from USER ROW, IF EXISTS ## 

3695 # get user row 

3696 removed_user_ids: Final = frozenset(m.user_id for m in removed_team_members if m.user_id is not None) 

3697 addressed_user_ids: Final = ( 

3698 removed_user_ids if removed_team_members else frozenset((data.user_id,) if data.user_id is not None else ()) 

3699 ) 

3700 key_val: Final[Mapping[str, object]] = ( 

3701 {"user_id": {"in": sorted(addressed_user_ids)}} if addressed_user_ids else {"user_email": data.user_email} 

3702 ) 

3703 member_tx: Final[_MemberDeleteTx] = tx 

3704 existing_user_rows: Final = await member_tx.litellm_usertable.find_many(where=key_val) 

3705 

3706 # A user row can outlive its roster entry, and until the team is off user.teams the user 

3707 # still sees it and still fails key creation against it, so removal has to clear it too 

3708 stale_user_rows: Final = tuple(user for user in existing_user_rows if data.team_id in user.teams) 

3709 

3710 # Also clean up any existing team membership rows for this user and team. An email can 

3711 # match several user rows, so with no roster entry to name the member, only the rows 

3712 # actually carrying the team are the ones this request is allowed to touch 

3713 cleanup_user_rows: Final = existing_user_rows if removed_team_members else stale_user_rows 

3714 user_ids_to_delete: Final = addressed_user_ids.union(user.user_id for user in cleanup_user_rows if user.user_id) 

3715 

3716 if not removed_team_members and not stale_user_rows: 3716 ↛ 3722line 3716 didn't jump to line 3722 because the condition on line 3716 was always true

3717 raise HTTPException(status_code=400, detail={"error": "User not found in team"}) 

3718 

3719 ## DELETE KEYS CREATED BY USER FOR THIS TEAM 

3720 # Fetch keys before deletion so their audit records can be persisted alongside the delete. 

3721 # An empty user_ids_to_delete still resolves cleanly: prisma's "in": [] matches no rows. 

3722 keys_to_delete: Final = await member_tx.litellm_verificationtoken.find_many( 

3723 where={ 

3724 "user_id": {"in": sorted(user_ids_to_delete)}, 

3725 "team_id": data.team_id, 

3726 } 

3727 ) 

3728 jwt_mapping_cache_keys: Final = await get_jwt_key_mapping_cache_keys_for_tokens( 

3729 hashed_tokens=tuple(key.token for key in keys_to_delete), 

3730 prisma_client=prisma_client, 

3731 ) 

3732 

3733 if removed_team_members: 

3734 await _team_tx_db(tx).update( 

3735 where={"team_id": data.team_id}, 

3736 data={"members_with_roles": json.dumps(_db_new_team_members)}, 

3737 ) 

3738 

3739 for existing_user in stale_user_rows: 

3740 await tx.litellm_usertable.update( 

3741 where={"user_id": existing_user.user_id}, 

3742 data={"teams": {"set": [team for team in existing_user.teams if team != data.team_id]}}, 

3743 ) 

3744 

3745 for _uid in sorted(user_ids_to_delete): 

3746 await tx.litellm_teammembership.delete_many(where={"team_id": data.team_id, "user_id": _uid}) 

3747 

3748 if user_ids_to_delete: 

3749 if keys_to_delete: 

3750 from litellm.proxy.management_endpoints.key_management_endpoints import ( 

3751 _persist_deleted_verification_tokens, 

3752 ) 

3753 

3754 await _persist_deleted_verification_tokens( 

3755 keys=keys_to_delete, 

3756 prisma_client=prisma_client, 

3757 user_api_key_dict=user_api_key_dict, 

3758 litellm_changed_by=None, 

3759 tx=tx, 

3760 ) 

3761 

3762 await tx.litellm_verificationtoken.delete_many( 

3763 where={ 

3764 "user_id": {"in": sorted(user_ids_to_delete)}, 

3765 "team_id": data.team_id, 

3766 } 

3767 ) 

3768 

3769 if keys_to_delete: 

3770 KeyManagementEventHooks.create_key_deleted_audit_logs( 

3771 keys_being_deleted=keys_to_delete, 

3772 user_api_key_dict=user_api_key_dict, 

3773 litellm_changed_by=None, 

3774 ) 

3775 

3776 await delete_cache_team_object( 

3777 team_id=data.team_id, 

3778 team_alias=existing_team_row.team_alias, 

3779 user_api_key_cache=user_api_key_cache, 

3780 proxy_logging_obj=proxy_logging_obj, 

3781 ) 

3782 await delete_cache_key_objects( 

3783 hashed_tokens=tuple(key.token for key in keys_to_delete), 

3784 user_api_key_cache=user_api_key_cache, 

3785 proxy_logging_obj=proxy_logging_obj, 

3786 ) 

3787 await evict_and_broadcast(cache_keys=jwt_mapping_cache_keys, user_api_key_cache=user_api_key_cache) 

3788 await evict_and_broadcast(cache_keys=tuple(sorted(user_ids_to_delete)), user_api_key_cache=user_api_key_cache) 

3789 for user_id in sorted(user_ids_to_delete): 

3790 await invalidate_team_member_spend_state( 

3791 user_id=user_id, 

3792 team_id=data.team_id, 

3793 user_api_key_cache=user_api_key_cache, 

3794 ) 

3795 

3796 _emit_team_members_metric(existing_team_row) 

3797 

3798 return existing_team_row, tuple(fresh_members), tuple(new_team_members) 

3799 

3800 

3801@router.post( 

3802 "/team/member_update", 

3803 tags=["team management"], 

3804 dependencies=[Depends(user_api_key_auth)], 

3805 response_model=TeamMemberUpdateResponse, 

3806) 

3807@management_endpoint_wrapper 

3808async def team_member_update( 

3809 data: TeamMemberUpdateRequest, 

3810 http_request: Request, 

3811 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), 

3812): 

3813 """ 

3814 [BETA] 

3815 

3816 Update team member budgets and team member role 

3817 """ 

3818 from litellm.proxy.proxy_server import ( 

3819 litellm_proxy_admin_name, 

3820 premium_user, 

3821 prisma_client, 

3822 user_api_key_cache, 

3823 ) 

3824 

3825 if prisma_client is None: 3825 ↛ 3826line 3825 didn't jump to line 3826 because the condition on line 3825 was never true

3826 raise HTTPException(status_code=500, detail={"error": "No db connected"}) 

3827 

3828 if data.team_id is None: 3828 ↛ 3829line 3828 didn't jump to line 3829 because the condition on line 3828 was never true

3829 raise HTTPException(status_code=400, detail={"error": "No team id passed in"}) 

3830 

3831 if data.role == "admin" and not premium_user: 

3832 # exactly the same text your proxy throws for add: 

3833 raise HTTPException( 

3834 status_code=400, 

3835 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", 

3836 ) 

3837 if data.user_id is None and data.user_email is None: 3837 ↛ 3838line 3837 didn't jump to line 3838 because the condition on line 3837 was never true

3838 raise HTTPException( 

3839 status_code=400, 

3840 detail={"error": "Either user_id or user_email needs to be passed in"}, 

3841 ) 

3842 

3843 validate_budget_duration(data.budget_duration) 

3844 

3845 _existing_team_row: Final = await _team_db(prisma_client).find_unique(where={"team_id": data.team_id}) 

3846 

3847 if _existing_team_row is None: 

3848 raise HTTPException( 

3849 status_code=400, 

3850 detail={"error": f"Team id={data.team_id} does not exist in db"}, 

3851 ) 

3852 existing_team_row: Final = LiteLLM_TeamTable.model_validate(_existing_team_row.model_dump()) 

3853 

3854 ## CHECK IF USER IS PROXY ADMIN OR TEAM ADMIN OR ORG ADMIN 

3855 

3856 if ( 3856 ↛ 3861line 3856 didn't jump to line 3861 because the condition on line 3856 was never true

3857 user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value 

3858 and not _is_user_team_admin(user_api_key_dict=user_api_key_dict, team_obj=existing_team_row) 

3859 and not await _is_user_org_admin_for_team(user_api_key_dict=user_api_key_dict, team_obj=existing_team_row) 

3860 ): 

3861 raise HTTPException( 

3862 status_code=403, 

3863 detail={ 

3864 "error": "Call not allowed. User not proxy admin OR team admin. route={}, team_id={}".format( 

3865 "/team/member_delete", existing_team_row.team_id 

3866 ) 

3867 }, 

3868 ) 

3869 

3870 returned_team_info: Final[TeamInfoResponseObject] = await team_info( 

3871 http_request=http_request, 

3872 team_id=data.team_id, 

3873 key_limit=None, 

3874 user_api_key_dict=user_api_key_dict, 

3875 ) 

3876 

3877 team_table: Final = returned_team_info["team_info"] 

3878 

3879 ## get user id 

3880 received_user_id: str | None = None 

3881 if data.user_id is not None: 3881 ↛ 3883line 3881 didn't jump to line 3883 because the condition on line 3881 was always true

3882 received_user_id = data.user_id 

3883 elif data.user_email is not None: 

3884 for member in returned_team_info["team_info"].members_with_roles: 

3885 if member.user_email is not None and member.user_email == data.user_email: 

3886 received_user_id = member.user_id 

3887 break 

3888 

3889 if received_user_id is None: 3889 ↛ 3890line 3889 didn't jump to line 3890 because the condition on line 3889 was never true

3890 raise HTTPException( 

3891 status_code=400, 

3892 detail={"error": f"User id doesn't exist in team table. Data={data}"}, 

3893 ) 

3894 ## find the relevant team membership 

3895 identified_budget_id: str | None = None 

3896 for tm in returned_team_info["team_memberships"]: 

3897 if tm.user_id == received_user_id: 

3898 identified_budget_id = tm.budget_id 

3899 break 

3900 

3901 # If this membership still points at the team's shared default member 

3902 # budget, _upsert_budget_and_membership will clone-on-write so that the 

3903 # update only touches this user (not every member sharing the default). 

3904 team_default_budget_id: str | None = None 

3905 if team_table.metadata is not None: 3905 ↛ 3911line 3905 didn't jump to line 3911 because the condition on line 3905 was always true

3906 raw_default_budget_id: Final = team_table.metadata.get("team_member_budget_id") 

3907 if isinstance(raw_default_budget_id, str): 3907 ↛ 3908line 3907 didn't jump to line 3908 because the condition on line 3907 was never true

3908 team_default_budget_id = raw_default_budget_id 

3909 

3910 ### upsert new budget 

3911 budget_patch: Final = member_budget_patch(data) 

3912 async with prisma_client.tx() as tx: 

3913 role_change: Final = ( 

3914 await _update_team_member_role( 

3915 tx=tx, 

3916 prisma_client=prisma_client, 

3917 team_id=data.team_id, 

3918 user_id=received_user_id, 

3919 role=data.role, 

3920 user_email=data.user_email, 

3921 ) 

3922 if data.role is not None 

3923 else None 

3924 ) 

3925 await _upsert_budget_and_membership( 

3926 tx=tx, 

3927 team_id=data.team_id, 

3928 user_id=received_user_id, 

3929 existing_budget_id=identified_budget_id, 

3930 user_api_key_dict=user_api_key_dict, 

3931 budget_patch=budget_patch, 

3932 team_default_budget_id=team_default_budget_id, 

3933 ) 

3934 if budget_patch: 

3935 await invalidate_team_member_spend_state( 

3936 user_id=received_user_id, 

3937 team_id=data.team_id, 

3938 user_api_key_cache=user_api_key_cache, 

3939 ) 

3940 

3941 if role_change is not None: 3941 ↛ 3942line 3941 didn't jump to line 3942 because the condition on line 3941 was never true

3942 members_before_role_update, team_members = role_change 

3943 team_table.members_with_roles = list(team_members) 

3944 _schedule_team_membership_audit_log( 

3945 team_id=data.team_id, 

3946 team_alias=team_table.team_alias, 

3947 before_members=members_before_role_update, 

3948 after_members=team_members, 

3949 user_api_key_dict=user_api_key_dict, 

3950 litellm_proxy_admin_name=litellm_proxy_admin_name, 

3951 ) 

3952 

3953 return TeamMemberUpdateResponse( 

3954 team_id=data.team_id, 

3955 user_id=received_user_id, 

3956 user_email=data.user_email, 

3957 max_budget_in_team=data.max_budget_in_team, 

3958 tpm_limit=data.tpm_limit, 

3959 rpm_limit=data.rpm_limit, 

3960 budget_duration=data.budget_duration, 

3961 allowed_models=data.allowed_models, 

3962 temp_budget_increase=data.temp_budget_increase, 

3963 temp_budget_expiry=data.temp_budget_expiry, 

3964 ) 

3965 

3966 

3967def _check_not_resetting_own_spend(user_id: str, user_api_key_dict: UserAPIKeyAuth) -> None: 

3968 """ 

3969 _verify_team_access authorizes a team admin (or org admin) over their own 

3970 team, with no check that the target user_id differs from the caller. Left 

3971 unchecked, that admin could target their own LiteLLM_TeamMembership row and 

3972 repeatedly reset it to 0 right before it crosses their per-member cap, 

3973 consuming the shared team budget without the configured limit ever binding. 

3974 Only a proxy admin may reset an admin's own spend. 

3975 """ 

3976 if user_id == user_api_key_dict.user_id and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: 3976 ↛ 3977line 3976 didn't jump to line 3977 because the condition on line 3976 was never true

3977 _raise_reset_spend_error(status.HTTP_403_FORBIDDEN, "Cannot reset your own spend. Ask a proxy admin.") 

3978 

3979 

3980def _raise_reset_spend_error(status_code: int, message: str) -> NoReturn: 

3981 detail: Final = {"error": message} # mutable-ok: HTTPException.detail takes a dict 

3982 raise HTTPException(status_code=status_code, detail=detail) 

3983 

3984 

3985def _validate_team_member_reset_spend_value( 

3986 reset_to: object, 

3987 membership: LiteLLM_TeamMembership, 

3988) -> float: 

3989 if not isinstance(reset_to, (int, float)): 3989 ↛ 3990line 3989 didn't jump to line 3990 because the condition on line 3989 was never true

3990 _raise_reset_spend_error(status.HTTP_400_BAD_REQUEST, "reset_to must be a float") 

3991 

3992 reset_to_float: Final = float(reset_to) 

3993 if not math.isfinite(reset_to_float) or reset_to_float < 0: 

3994 _raise_reset_spend_error(status.HTTP_400_BAD_REQUEST, "reset_to must be a finite number >= 0") 

3995 

3996 current_spend: Final = membership.spend or 0.0 

3997 if reset_to_float > current_spend: 

3998 _raise_reset_spend_error( 

3999 status.HTTP_400_BAD_REQUEST, 

4000 f"reset_to ({reset_to_float}) must be <= current spend ({current_spend})", 

4001 ) 

4002 

4003 max_budget: Final = membership.litellm_budget_table.max_budget if membership.litellm_budget_table else None 

4004 if max_budget is not None and reset_to_float > max_budget: 4004 ↛ 4005line 4004 didn't jump to line 4005 because the condition on line 4004 was never true

4005 _raise_reset_spend_error( 

4006 status.HTTP_400_BAD_REQUEST, 

4007 f"reset_to ({reset_to_float}) must be <= budget ({max_budget})", 

4008 ) 

4009 

4010 return reset_to_float 

4011 

4012 

4013@router.post( 

4014 "/team/{team_id}/member/{user_id}/reset_spend", 

4015 tags=["team management"], # mutable-ok: FastAPI's `tags` param is typed as list[str], not Sequence 

4016 dependencies=(Depends(user_api_key_auth),), 

4017) 

4018@management_endpoint_wrapper 

4019async def reset_team_member_spend_fn( 

4020 team_id: str, 

4021 user_id: str, 

4022 data: ResetSpendRequest, 

4023 user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], 

4024): 

4025 """ 

4026 Reset a team member's tracked spend against their per-member budget. 

4027 

4028 A member's spend is tracked separately from both their own personal 

4029 budget and the team's own budget (LiteLLM_TeamMembership.spend), so 

4030 neither /user/update nor /team/update can clear it: this is the only 

4031 endpoint that does. The cross-pod spend counter and cached membership 

4032 reads are invalidated so the reset takes effect on the member's next 

4033 request rather than waiting on the membership cache's TTL. 

4034 """ 

4035 from litellm.proxy.proxy_server import prisma_client, proxy_logging_obj, user_api_key_cache 

4036 

4037 if prisma_client is None: 4037 ↛ 4038line 4037 didn't jump to line 4038 because the condition on line 4037 was never true

4038 _raise_reset_spend_error(status.HTTP_500_INTERNAL_SERVER_ERROR, "DB not connected. prisma_client is None") 

4039 

4040 team_obj: Final = await get_team_object( 

4041 team_id=team_id, 

4042 prisma_client=prisma_client, 

4043 user_api_key_cache=user_api_key_cache, 

4044 parent_otel_span=None, 

4045 proxy_logging_obj=proxy_logging_obj, 

4046 check_db_only=True, 

4047 ) 

4048 await _verify_team_access(team_obj=team_obj, user_api_key_dict=user_api_key_dict) 

4049 _check_not_resetting_own_spend(user_id=user_id, user_api_key_dict=user_api_key_dict) 

4050 

4051 membership_where: Final = { # mutable-ok: prisma client requires a plain dict where= argument 

4052 "user_id_team_id": {"user_id": user_id, "team_id": team_id} # mutable-ok: same prisma where= argument 

4053 } 

4054 _membership_row: Final = await _team_membership_db(prisma_client).find_unique( 

4055 where=membership_where, 

4056 include={"litellm_budget_table": True}, # mutable-ok: prisma client requires a plain dict include= argument 

4057 ) 

4058 if _membership_row is None: 4058 ↛ 4059line 4058 didn't jump to line 4059 because the condition on line 4058 was never true

4059 _raise_reset_spend_error(status.HTTP_404_NOT_FOUND, f"User {user_id} is not a member of team {team_id}.") 

4060 membership: Final = LiteLLM_TeamMembership.model_validate(_membership_row.model_dump()) 

4061 

4062 current_spend: Final = membership.spend or 0.0 

4063 reset_to: Final = _validate_team_member_reset_spend_value(data.reset_to, membership) 

4064 

4065 await _team_membership_db(prisma_client).update( 

4066 where=membership_where, 

4067 data={"spend": reset_to}, # mutable-ok: prisma client requires a plain dict data= argument 

4068 ) 

4069 

4070 await invalidate_team_member_spend_state( 

4071 user_id=user_id, 

4072 team_id=team_id, 

4073 user_api_key_cache=user_api_key_cache, 

4074 new_spend=reset_to, 

4075 ) 

4076 

4077 return { # mutable-ok: matches this router's established untyped-response-dict convention 

4078 "team_id": team_id, 

4079 "user_id": user_id, 

4080 "spend": reset_to, 

4081 "previous_spend": current_spend, 

4082 "max_budget": membership.litellm_budget_table.max_budget if membership.litellm_budget_table else None, 

4083 } 

4084 

4085 

4086class _TeamMetadataView(BaseModel): 

4087 metadata: Mapping[str, object] | None = None 

4088 

4089 

4090def _team_default_budget_id(team: LiteLLM_TeamTable) -> str | None: 

4091 view: Final = _TeamMetadataView.model_validate(team, from_attributes=True) 

4092 raw: Final = view.metadata.get("team_member_budget_id") if view.metadata is not None else None 

4093 return raw if isinstance(raw, str) else None 

4094 

4095 

4096async def _existing_team_default_budget_id(team: LiteLLM_TeamTable, prisma_client: PrismaClient) -> str | None: 

4097 budget_id: Final = _team_default_budget_id(team) 

4098 if budget_id is None: 4098 ↛ 4100line 4098 didn't jump to line 4100 because the condition on line 4098 was always true

4099 return None 

4100 row: Final = await _budget_db(prisma_client).find_unique( 

4101 where={"budget_id": budget_id}, # mutable-ok: prisma client requires a plain dict where= argument 

4102 ) 

4103 return budget_id if row is not None else None 

4104 

4105 

4106def _member_budget_source(budget_id: str | None, team_default_budget_id: str | None) -> TeamMemberBudgetSource: 

4107 if budget_id is not None and budget_id != team_default_budget_id: 

4108 return "custom" 

4109 return "team_default" if team_default_budget_id is not None else "none" 

4110 

4111 

4112@router.post( 

4113 "/team/{team_id}/member/{user_id}/reset_budget", 

4114 tags=["team management"], # mutable-ok: FastAPI's `tags` param is typed as list[str], not Sequence 

4115 dependencies=(Depends(user_api_key_auth),), 

4116 response_model=TeamMemberResetBudgetResponse, 

4117) 

4118@management_endpoint_wrapper 

4119async def reset_team_member_budget_fn( 

4120 team_id: str, 

4121 user_id: str, 

4122 user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], 

4123) -> TeamMemberResetBudgetResponse: 

4124 """ 

4125 Put a team member back on the team's shared default member budget (`team_member_budget`). 

4126 

4127 Drops the member's own budget row link so team-wide changes made through /team/update 

4128 reach them again. Leaves the member with no budget when the team has no default. Spend is untouched. 

4129 """ 

4130 from litellm.proxy.proxy_server import prisma_client, proxy_logging_obj, user_api_key_cache 

4131 

4132 if prisma_client is None: 4132 ↛ 4133line 4132 didn't jump to line 4133 because the condition on line 4132 was never true

4133 _raise_reset_spend_error(status.HTTP_500_INTERNAL_SERVER_ERROR, "DB not connected. prisma_client is None") 

4134 

4135 team_obj: Final = await get_team_object( 

4136 team_id=team_id, 

4137 prisma_client=prisma_client, 

4138 user_api_key_cache=user_api_key_cache, 

4139 parent_otel_span=None, 

4140 proxy_logging_obj=proxy_logging_obj, 

4141 check_db_only=True, 

4142 ) 

4143 await _verify_team_access(team_obj=team_obj, user_api_key_dict=user_api_key_dict) 

4144 

4145 membership_where: Final = { # mutable-ok: prisma client requires a plain dict where= argument 

4146 "user_id_team_id": {"user_id": user_id, "team_id": team_id} # mutable-ok: same prisma where= argument 

4147 } 

4148 membership_row: Final = await _team_membership_db(prisma_client).find_unique(where=membership_where) 

4149 if membership_row is None: 

4150 _raise_reset_spend_error(status.HTTP_404_NOT_FOUND, f"User {user_id} is not a member of team {team_id}.") 

4151 

4152 team_default_budget_id: Final = await _existing_team_default_budget_id(team_obj, prisma_client) 

4153 budget_link: Final = ( 

4154 {"connect": {"budget_id": team_default_budget_id}} 

4155 if team_default_budget_id is not None 

4156 else {"disconnect": True} # mutable-ok: same prisma data= argument 

4157 ) 

4158 await _team_membership_db(prisma_client).update( 

4159 where=membership_where, 

4160 data={"litellm_budget_table": budget_link}, # mutable-ok: prisma client requires a plain dict data= argument 

4161 ) 

4162 await invalidate_team_member_spend_state( 

4163 user_id=user_id, 

4164 team_id=team_id, 

4165 user_api_key_cache=user_api_key_cache, 

4166 ) 

4167 

4168 return TeamMemberResetBudgetResponse( 

4169 team_id=team_id, 

4170 user_id=user_id, 

4171 budget_id=team_default_budget_id, 

4172 previous_budget_id=membership_row.budget_id, 

4173 budget_source=_member_budget_source(team_default_budget_id, team_default_budget_id), 

4174 ) 

4175 

4176 

4177def _create_results_from_response( 

4178 members: list[Member], 

4179 response: TeamAddMemberResponse, 

4180) -> list[TeamMemberAddResult]: 

4181 """ 

4182 Convert TeamAddMemberResponse into individual TeamMemberAddResult objects 

4183 """ 

4184 results: Final[list[TeamMemberAddResult]] = [] 

4185 

4186 for member in members: 

4187 # Find corresponding updated user 

4188 updated_user = None 

4189 for user in response.updated_users: 

4190 if (member.user_id and user.user_id == member.user_id) or ( 

4191 member.user_email and user.user_email == member.user_email 

4192 ): 

4193 updated_user = user.model_dump() 

4194 break 

4195 

4196 # Find corresponding updated team membership 

4197 updated_team_membership = None 

4198 for tm in response.updated_team_memberships: 

4199 if member.user_id and tm.user_id == member.user_id: 

4200 updated_team_membership = tm.model_dump() 

4201 break 

4202 

4203 results.append( 

4204 TeamMemberAddResult( 

4205 user_id=member.user_id, 

4206 user_email=member.user_email, 

4207 success=True, 

4208 updated_user=updated_user, 

4209 updated_team_membership=updated_team_membership, 

4210 ) 

4211 ) 

4212 

4213 return results 

4214 

4215 

4216@router.post( 

4217 "/team/bulk_member_add", 

4218 tags=["team management"], 

4219 dependencies=[Depends(user_api_key_auth)], 

4220 response_model=BulkTeamMemberAddResponse, 

4221) 

4222@management_endpoint_wrapper 

4223async def bulk_team_member_add( 

4224 data: BulkTeamMemberAddRequest, 

4225 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), 

4226): 

4227 """ 

4228 Bulk add multiple members to a team at once. 

4229  

4230 This endpoint reuses the same logic as /team/member_add but provides a bulk-friendly response format. 

4231  

4232 Parameters: 

4233 - team_id: str - The ID of the team to add members to 

4234 - members: List[Member] - List of members to add to the team 

4235 - all_users: Optional[bool] - Flag to add all users on Proxy to the team 

4236 - max_budget_in_team: Optional[float] - Maximum budget allocated to each user within the team 

4237  

4238 Returns: 

4239 - results: List of individual member addition results 

4240 - total_requested: Total number of members requested for addition 

4241 - successful_additions: Number of successful additions  

4242 - failed_additions: Number of failed additions 

4243 - updated_team: The updated team object 

4244  

4245 Example request: 

4246 ```bash 

4247 curl --location 'http://0.0.0.0:4000/team/bulk_member_add' \ 

4248 --header 'Authorization: Bearer sk-1234' \ 

4249 --header 'Content-Type: application/json' \ 

4250 --data '{ 

4251 "team_id": "team-1234", 

4252 "members": [ 

4253 { 

4254 "user_id": "user1", 

4255 "role": "user" 

4256 }, 

4257 { 

4258 "user_email": "user2@example.com", 

4259 "role": "admin" 

4260 } 

4261 ], 

4262 "max_budget_in_team": 100.0 

4263 }' 

4264 ``` 

4265 """ 

4266 from litellm.proxy._types import CommonProxyErrors 

4267 from litellm.proxy.proxy_server import prisma_client 

4268 

4269 if prisma_client is None: 4269 ↛ 4270line 4269 didn't jump to line 4270 because the condition on line 4269 was never true

4270 raise HTTPException( 

4271 status_code=500, 

4272 detail={"error": CommonProxyErrors.db_not_connected_error.value}, 

4273 ) 

4274 

4275 if data.all_users: 

4276 # `all_users=True` pulls every user in the database into this team, 

4277 # regardless of org. Any team admin could use it to capture every 

4278 # user across every org into a team they control. Restrict to 

4279 # PROXY_ADMIN. 

4280 if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value: 4280 ↛ 4281line 4280 didn't jump to line 4281 because the condition on line 4280 was never true

4281 raise HTTPException( 

4282 status_code=403, 

4283 detail={ 

4284 "error": ( 

4285 "`all_users=true` is restricted to PROXY_ADMIN. " 

4286 "Org/team admins must specify explicit member lists." 

4287 ) 

4288 }, 

4289 ) 

4290 # get all users from the database 

4291 all_users_in_db: Final = await _user_db(prisma_client).find_many(order={"created_at": "desc"}) 

4292 data.members = [ 

4293 Member( 

4294 user_id=user.user_id, 

4295 user_email=user.user_email, 

4296 role="user", 

4297 ) 

4298 for user in all_users_in_db 

4299 ] 

4300 

4301 if not data.members: 

4302 raise HTTPException( 

4303 status_code=400, 

4304 detail={"error": "At least one member is required"}, 

4305 ) 

4306 

4307 # Limit batch size to prevent overwhelming the system 

4308 MAX_BATCH_SIZE: Final = 500 

4309 if len(data.members) > MAX_BATCH_SIZE: 4309 ↛ 4310line 4309 didn't jump to line 4310 because the condition on line 4309 was never true

4310 raise HTTPException( 

4311 status_code=400, 

4312 detail={"error": f"Maximum {MAX_BATCH_SIZE} members can be added at once"}, 

4313 ) 

4314 

4315 try: 

4316 # Reuse the existing team_member_add logic directly 

4317 response: Final = await team_member_add( 

4318 data=TeamMemberAddRequest( 

4319 team_id=data.team_id, 

4320 member=data.members, # Pass the entire list 

4321 max_budget_in_team=data.max_budget_in_team, 

4322 ), 

4323 user_api_key_dict=user_api_key_dict, 

4324 ) 

4325 

4326 # Convert to bulk response format 

4327 results = _create_results_from_response(data.members, response) 

4328 

4329 return BulkTeamMemberAddResponse( 

4330 team_id=data.team_id, 

4331 results=results, 

4332 total_requested=len(data.members), 

4333 successful_additions=len(results), # All succeeded if we got here 

4334 failed_additions=0, 

4335 updated_team=response.model_dump(), 

4336 ) 

4337 

4338 except Exception as e: 

4339 # If the entire operation fails, mark all members as failed 

4340 verbose_proxy_logger.exception(e) 

4341 error_message: Final = str(e) 

4342 results = [ 

4343 TeamMemberAddResult( 

4344 user_id=member.user_id, 

4345 user_email=member.user_email, 

4346 success=False, 

4347 error=error_message, 

4348 ) 

4349 for member in data.members 

4350 ] 

4351 

4352 return BulkTeamMemberAddResponse( 

4353 team_id=data.team_id, 

4354 results=results, 

4355 total_requested=len(data.members), 

4356 successful_additions=0, 

4357 failed_additions=len(data.members), 

4358 updated_team=None, 

4359 ) 

4360 

4361 

4362@router.post("/team/delete", tags=["team management"], dependencies=[Depends(user_api_key_auth)]) 

4363@management_endpoint_wrapper 

4364async def delete_team( 

4365 data: DeleteTeamRequest, 

4366 http_request: Request, 

4367 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), 

4368 litellm_changed_by: str | None = Header( 

4369 None, 

4370 description="The litellm-changed-by header enables tracking of actions performed by authorized users on behalf of other users, providing an audit trail for accountability", 

4371 ), 

4372): 

4373 """ 

4374 delete team and associated team keys 

4375 

4376 Parameters: 

4377 - team_ids: List[str] - Required. List of team IDs to delete. Example: ["team-1234", "team-5678"] 

4378 

4379 ``` 

4380 curl --location 'http://0.0.0.0:4000/team/delete' \ 

4381 --header 'Authorization: Bearer sk-1234' \ 

4382 --header 'Content-Type: application/json' \ 

4383 --data-raw '{ 

4384 "team_ids": ["8d916b1c-510d-4894-a334-1c16a93344f5"] 

4385 }' 

4386 ``` 

4387 """ 

4388 from litellm.proxy.management_helpers.audit_logs import ( 

4389 get_audit_log_changed_by, 

4390 is_audit_logging_enabled, 

4391 ) 

4392 from litellm.proxy.proxy_server import ( 

4393 create_audit_log_for_update, 

4394 litellm_proxy_admin_name, 

4395 prisma_client, 

4396 proxy_logging_obj, 

4397 user_api_key_cache, 

4398 ) 

4399 

4400 if prisma_client is None: 4400 ↛ 4401line 4400 didn't jump to line 4401 because the condition on line 4400 was never true

4401 raise HTTPException(status_code=500, detail={"error": "No db connected"}) 

4402 

4403 if data.team_ids is None: 4403 ↛ 4404line 4403 didn't jump to line 4404 because the condition on line 4403 was never true

4404 raise HTTPException(status_code=400, detail={"error": "No team id passed in"}) 

4405 

4406 # check that all teams passed exist 

4407 team_rows: Final[list[LiteLLM_TeamTable]] = [] 

4408 for team_id in data.team_ids: 

4409 try: 

4410 team_row_base: BaseModel | None = await _team_db(prisma_client).find_unique(where={"team_id": team_id}) 

4411 if team_row_base is None: 4411 ↛ 4418line 4411 didn't jump to line 4418 because the condition on line 4411 was always true

4412 raise Exception 

4413 except Exception: 

4414 raise HTTPException( 

4415 status_code=404, 

4416 detail={"error": f"Team not found, passed team_id={team_id}"}, 

4417 ) 

4418 team_row_pydantic = LiteLLM_TeamTable.model_validate(team_row_base.model_dump()) 

4419 

4420 # Verify caller has access to manage this team 

4421 await _verify_team_access( 

4422 team_obj=team_row_pydantic, 

4423 user_api_key_dict=user_api_key_dict, 

4424 ) 

4425 

4426 team_rows.append(team_row_pydantic) 

4427 

4428 await _persist_deleted_team_records( 

4429 teams=team_rows, 

4430 prisma_client=prisma_client, 

4431 user_api_key_dict=user_api_key_dict, 

4432 litellm_changed_by=litellm_changed_by, 

4433 ) 

4434 

4435 # we do this after the first for loop, since first for loop is for validation. we only want this inserted after validation passes 

4436 if is_audit_logging_enabled(): 4436 ↛ 4438line 4436 didn't jump to line 4438 because the condition on line 4436 was never true

4437 # make an audit log for each team deleted 

4438 for team_id in data.team_ids: 

4439 team_row: LiteLLM_TeamTable | None = await prisma_client.get_data( 

4440 team_id=team_id, table_name="team", query_type="find_unique" 

4441 ) 

4442 

4443 if team_row is None: 

4444 continue 

4445 

4446 _team_row = team_row.json(exclude_none=True) 

4447 

4448 asyncio.create_task( 

4449 create_audit_log_for_update( 

4450 request_data=LiteLLM_AuditLogs( 

4451 id=str(uuid.uuid4()), 

4452 updated_at=datetime.now(timezone.utc), 

4453 changed_by=get_audit_log_changed_by( 

4454 litellm_changed_by=litellm_changed_by, 

4455 user_api_key_dict=user_api_key_dict, 

4456 litellm_proxy_admin_name=litellm_proxy_admin_name, 

4457 ), 

4458 changed_by_api_key=user_api_key_dict.api_key, 

4459 table_name=LitellmTableNames.TEAM_TABLE_NAME, 

4460 object_id=team_id, 

4461 action="deleted", 

4462 updated_values="{}", 

4463 before_value=_team_row, 

4464 ) 

4465 ) 

4466 ) 

4467 

4468 # End of Audit logging 

4469 

4470 ## DELETE ASSOCIATED KEYS 

4471 # Fetch keys before deletion to persist them 

4472 from litellm.proxy.management_endpoints.key_management_endpoints import ( 

4473 _persist_deleted_verification_tokens, 

4474 ) 

4475 

4476 keys_to_delete: Final = await _tokens_db(prisma_client).find_many(where={"team_id": {"in": data.team_ids}}) 

4477 jwt_mapping_cache_keys: Final = await get_jwt_key_mapping_cache_keys_for_tokens( 

4478 hashed_tokens=tuple(key.token for key in keys_to_delete), 

4479 prisma_client=prisma_client, 

4480 ) 

4481 

4482 if keys_to_delete: 4482 ↛ 4483line 4482 didn't jump to line 4483 because the condition on line 4482 was never true

4483 await _persist_deleted_verification_tokens( 

4484 keys=keys_to_delete, 

4485 prisma_client=prisma_client, 

4486 user_api_key_dict=user_api_key_dict, 

4487 litellm_changed_by=litellm_changed_by, 

4488 ) 

4489 

4490 await prisma_client.delete_data(team_id_list=data.team_ids, table_name="key") 

4491 

4492 if keys_to_delete: 4492 ↛ 4493line 4492 didn't jump to line 4493 because the condition on line 4492 was never true

4493 KeyManagementEventHooks.create_key_deleted_audit_logs( 

4494 keys_being_deleted=keys_to_delete, 

4495 user_api_key_dict=user_api_key_dict, 

4496 litellm_changed_by=litellm_changed_by, 

4497 ) 

4498 

4499 await _invalidate_deleted_key_cache( 

4500 keys=keys_to_delete, 

4501 user_api_key_cache=user_api_key_cache, 

4502 proxy_logging_obj=proxy_logging_obj, 

4503 ) 

4504 await evict_and_broadcast(cache_keys=jwt_mapping_cache_keys, user_api_key_cache=user_api_key_cache) 

4505 

4506 ## DELETE ASSOCIATED BYOK MODELS 

4507 # Runs before the team rows are deleted so a mid-flight failure never leaves 

4508 # the team gone with its models orphaned. 

4509 from litellm.proxy.management_endpoints.model_management_endpoints import ( 

4510 delete_team_models, 

4511 ) 

4512 from litellm.proxy.proxy_server import llm_router 

4513 

4514 await delete_team_models( 

4515 team_ids=data.team_ids, 

4516 prisma_client=prisma_client, 

4517 llm_router=llm_router, 

4518 ) 

4519 

4520 # ## DELETE TEAM MEMBERSHIPS 

4521 for team_row in team_rows: 4521 ↛ 4523line 4521 didn't jump to line 4523 because the loop on line 4521 never started

4522 ### get all team members 

4523 team_members = team_row.members_with_roles 

4524 ### call team_member_delete for each team member 

4525 tasks = [] 

4526 for team_member in team_members: 

4527 tasks.append( 

4528 _team_member_delete( 

4529 data=TeamMemberDeleteRequest( 

4530 team_id=team_row.team_id, 

4531 user_id=team_member.user_id, 

4532 user_email=team_member.user_email, 

4533 ), 

4534 user_api_key_dict=user_api_key_dict, 

4535 ) 

4536 ) 

4537 await asyncio.gather(*tasks) 

4538 

4539 await _sweep_deleted_team_references(team_ids=data.team_ids, prisma_client=prisma_client) 

4540 

4541 ## DELETE TEAMS 

4542 # Both the delete and the reconcile sweep run under every team's advisory lock 

4543 # (TEAM_ADVISORY_LOCK_SQL, the same one /team/member_add takes before its own writes), 

4544 # sorted so two overlapping batch deletes always request their locks in the same order. 

4545 # A member_add mid-flight for one of these teams either finishes its write and releases 

4546 # the lock before this transaction starts, in which case this sweep reaches what it wrote, 

4547 # or is still waiting on the lock, in which case its own re-read happens after this commits 

4548 # and sees the row gone before it writes anything. 

4549 delete_filter: Final[_TeamIdInFilter] = {"team_id": {"in": data.team_ids}} 

4550 async with prisma_client.tx() as tx: 

4551 for team_id in sorted(data.team_ids): 4551 ↛ 4552line 4551 didn't jump to line 4552 because the loop on line 4551 never started

4552 await tx.query_raw(TEAM_ADVISORY_LOCK_SQL, team_id) 

4553 await tx.litellm_teamtable.delete_many(where=delete_filter) 

4554 await _sweep_deleted_team_references_tx(team_ids=data.team_ids, tx=tx) 

4555 

4556 deleted_teams: Final[_DeletedTeamsResult] = {"deleted_teams": data.team_ids} 

4557 

4558 # Evict AFTER the rows are gone. Both writers of these keys (`_cache_team_object` and 

4559 # `get_team_object_by_alias`) hydrate from the db, so evicting first leaves a window where a 

4560 # concurrent auth lookup re-caches the still-present team and the delete looks like it never 

4561 # invalidated anything. Nothing fallible runs between the delete and this, or a failure there 

4562 # would strand the deleted team in cache. 

4563 await _invalidate_deleted_team_cache( 

4564 teams=team_rows, 

4565 user_api_key_cache=user_api_key_cache, 

4566 proxy_logging_obj=proxy_logging_obj, 

4567 ) 

4568 

4569 for deleted_team in team_rows: 4569 ↛ 4570line 4569 didn't jump to line 4570 because the loop on line 4569 never started

4570 await sync_team_access_group_membership(prisma_client=prisma_client, team_id=deleted_team.team_id) 

4571 

4572 return deleted_teams 

4573 

4574 

4575async def _sweep_deleted_team_references(team_ids: Sequence[str], prisma_client: PrismaClient) -> None: 

4576 """ 

4577 Strip the deleted team ids from every user row and team-membership row that still references them. 

4578 

4579 The per-member `team_member_delete` pass above only reaches users listed in the team's 

4580 `members_with_roles`, so a user row that outlived its roster entry is invisible to it and keeps 

4581 surfacing the team on `/user/info` after the team is gone. 

4582 

4583 #36839 closed the route that created that drift, by resolving member removal off the roster 

4584 entry's `user_id` rather than the identifier the caller happened to pass. It does not backfill 

4585 rows that already drifted, which is the state this was reported against, so the sweep still has 

4586 to run on delete. 

4587 

4588 `array_remove` rather than read-filter-write: rewriting the whole array from a snapshot read 

4589 outside a transaction drops any team a concurrent `/team/member_add` appended in between. 

4590 """ 

4591 for team_id in team_ids: 4591 ↛ 4592line 4591 didn't jump to line 4592 because the loop on line 4591 never started

4592 _ = await prisma_client.db.execute_raw(_STRIP_DELETED_TEAM_FROM_USERS_SQL, team_id) 

4593 

4594 _ = await _team_membership_db(prisma_client).delete_many(where=_TeamIdInFilter(team_id={"in": tuple(team_ids)})) 

4595 

4596 

4597async def _sweep_deleted_team_references_tx(team_ids: Sequence[str], tx: _TeamDeleteTx) -> None: 

4598 """Same sweep as `_sweep_deleted_team_references`, run on the transaction that holds 

4599 every id's advisory lock and deletes the team rows, so it commits or rolls back with them.""" 

4600 for team_id in team_ids: 4600 ↛ 4601line 4600 didn't jump to line 4601 because the loop on line 4600 never started

4601 _ = await tx.execute_raw(_STRIP_DELETED_TEAM_FROM_USERS_SQL, team_id) 

4602 

4603 membership_filter: Final[_TeamIdInFilter] = {"team_id": {"in": tuple(team_ids)}} 

4604 _ = await tx.litellm_teammembership.delete_many(where=membership_filter) 

4605 

4606 

4607async def _invalidate_deleted_key_cache( 

4608 keys: "Sequence[prisma_models.LiteLLM_VerificationToken]", 

4609 user_api_key_cache: UserApiKeyCache, 

4610 proxy_logging_obj: ProxyLogging, 

4611) -> None: 

4612 """ 

4613 Evict the auth cache entry for every key deleted along with the team. 

4614 

4615 `/key/delete` evicts as it goes, but the bulk delete above writes straight to the db. Auth 

4616 resolves a cached key object without re-reading the team, so a key belonging to a deleted team 

4617 keeps buying access until its TTL expires. 

4618 """ 

4619 await delete_cache_key_objects( 

4620 hashed_tokens=tuple(key.token for key in keys), 

4621 user_api_key_cache=user_api_key_cache, 

4622 proxy_logging_obj=proxy_logging_obj, 

4623 ) 

4624 

4625 

4626async def _invalidate_deleted_team_cache( 

4627 teams: Sequence[LiteLLM_TeamTable], 

4628 user_api_key_cache: UserApiKeyCache, 

4629 proxy_logging_obj: ProxyLogging, 

4630) -> None: 

4631 _ = await asyncio.gather( 

4632 *( 

4633 delete_cache_team_object( 

4634 team_id=team.team_id, 

4635 team_alias=team.team_alias, 

4636 user_api_key_cache=user_api_key_cache, 

4637 proxy_logging_obj=proxy_logging_obj, 

4638 ) 

4639 for team in teams 

4640 ) 

4641 ) 

4642 

4643 

4644def _transform_teams_to_deleted_records( 

4645 teams: list[LiteLLM_TeamTable], 

4646 user_api_key_dict: UserAPIKeyAuth, 

4647 litellm_changed_by: str | None = None, 

4648) -> list[dict[str, object]]: 

4649 """Transform teams into deleted team records ready for persistence.""" 

4650 if not teams: 4650 ↛ 4653line 4650 didn't jump to line 4653 because the condition on line 4650 was always true

4651 return [] 

4652 

4653 deleted_at: Final = datetime.now(timezone.utc) 

4654 records: Final = [] 

4655 for team in teams: 

4656 team_payload = team.model_dump() 

4657 deleted_record = LiteLLM_DeletedTeamTable.model_validate( 

4658 { 

4659 **team_payload, 

4660 "deleted_at": deleted_at, 

4661 "deleted_by": user_api_key_dict.user_id, 

4662 "deleted_by_api_key": user_api_key_dict.api_key, 

4663 "litellm_changed_by": litellm_changed_by, 

4664 } 

4665 ) 

4666 record = deleted_record.model_dump() 

4667 

4668 for json_field in [ 

4669 "members_with_roles", 

4670 "metadata", 

4671 "model_spend", 

4672 "model_max_budget", 

4673 "router_settings", 

4674 ]: 

4675 if json_field in record and record[json_field] is not None: 

4676 record[json_field] = json.dumps(record[json_field]) 

4677 

4678 for rel_key in ( 

4679 "litellm_model_table", 

4680 "object_permission", 

4681 "id", 

4682 "budget_limits", # not in LiteLLM_DeletedTeamTable schema 

4683 "default_team_member_models", # not in LiteLLM_DeletedTeamTable schema 

4684 ): 

4685 record.pop(rel_key, None) 

4686 

4687 records.append(record) 

4688 

4689 return records 

4690 

4691 

4692async def _save_deleted_team_records( 

4693 records: list[dict[str, object]], 

4694 prisma_client: PrismaClient, 

4695) -> None: 

4696 """Save deleted team records to the database.""" 

4697 if not records: 4697 ↛ 4699line 4697 didn't jump to line 4699 because the condition on line 4697 was always true

4698 return 

4699 await _deleted_team_db(prisma_client).create_many(data=records) 

4700 

4701 

4702async def _persist_deleted_team_records( 

4703 teams: list[LiteLLM_TeamTable], 

4704 prisma_client: PrismaClient, 

4705 user_api_key_dict: UserAPIKeyAuth, 

4706 litellm_changed_by: str | None = None, 

4707) -> None: 

4708 """Persist deleted team records by transforming and saving them.""" 

4709 records: Final = _transform_teams_to_deleted_records( 

4710 teams=teams, 

4711 user_api_key_dict=user_api_key_dict, 

4712 litellm_changed_by=litellm_changed_by, 

4713 ) 

4714 await _save_deleted_team_records( 

4715 records=records, 

4716 prisma_client=prisma_client, 

4717 ) 

4718 

4719 

4720async def validate_membership(user_api_key_dict: UserAPIKeyAuth, team_table: LiteLLM_TeamTable): 

4721 if ( 4721 ↛ 4727line 4721 didn't jump to line 4727 because the condition on line 4721 was always true

4722 user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value 

4723 or user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value 

4724 ): 

4725 return 

4726 

4727 if user_api_key_dict.team_id == team_table.team_id: # allow team keys to check their info 

4728 return 

4729 

4730 # Handle case where user_id is None (e.g., team key accessing different team) 

4731 if user_api_key_dict.user_id is None: 

4732 if user_api_key_dict.team_id is not None: 

4733 raise HTTPException( 

4734 status_code=403, 

4735 detail={ 

4736 "error": f"Team key for team={user_api_key_dict.team_id} not authorized to access this team={team_table.team_id}" 

4737 }, 

4738 ) 

4739 else: 

4740 raise HTTPException( 

4741 status_code=403, 

4742 detail={ 

4743 "error": f"API key not authorized to access this team={team_table.team_id}. No user_id or team_id associated with this key." 

4744 }, 

4745 ) 

4746 

4747 # Check direct team membership 

4748 if user_api_key_dict.user_id in [m.user_id for m in team_table.members_with_roles]: 

4749 return 

4750 

4751 # Check if user is an org admin for the team's organization 

4752 if await _is_user_org_admin_for_team(user_api_key_dict=user_api_key_dict, team_obj=team_table): 

4753 return 

4754 

4755 raise HTTPException( 

4756 status_code=403, 

4757 detail={"error": f"User={user_api_key_dict.user_id} not authorized to access this team={team_table.team_id}"}, 

4758 ) 

4759 

4760 

4761async def _add_team_member_budget_table( 

4762 team_member_budget_id: str, 

4763 prisma_client: PrismaClient, 

4764 team_info_response_object: TeamInfoResponseObjectTeamTable, 

4765) -> TeamInfoResponseObjectTeamTable: 

4766 try: 

4767 team_budget: Final = await _budget_db(prisma_client).find_unique(where={"budget_id": team_member_budget_id}) 

4768 return team_info_response_object.model_copy(update={"team_member_budget_table": team_budget}) 

4769 except Exception: 

4770 verbose_proxy_logger.info( 

4771 "Team member budget table not found, passed team_member_budget_id=%s", team_member_budget_id 

4772 ) 

4773 

4774 return team_info_response_object 

4775 

4776 

4777async def _hydrate_member_user_details( 

4778 prisma_client: PrismaClient, 

4779 members: Sequence[Member], 

4780) -> tuple[TeamInfoMember, ...]: 

4781 """Attach ``user_alias`` and fill in a missing ``user_email`` from ``LiteLLM_UserTable`` in one query.""" 

4782 user_ids: Final = frozenset(m.user_id for m in members if m.user_id is not None) 

4783 user_rows: Final[Sequence[prisma_models.LiteLLM_UserTable]] = ( 

4784 await _user_db(prisma_client).find_many( 

4785 where={ # mutable-ok: Prisma query filters are dict-shaped 

4786 "user_id": { # mutable-ok: Prisma query filters are dict-shaped 

4787 "in": sorted(user_ids) 

4788 } 

4789 } 

4790 ) 

4791 if user_ids 

4792 else () 

4793 ) 

4794 user_by_id: Final = MappingProxyType({u.user_id: u for u in user_rows}) 

4795 

4796 def hydrate(m: Member) -> TeamInfoMember: 

4797 user_row: Final = user_by_id.get(m.user_id) if m.user_id is not None else None 

4798 return TeamInfoMember( 

4799 role=m.role, 

4800 user_id=m.user_id, 

4801 user_email=m.user_email or (user_row.user_email if user_row is not None else None), 

4802 user_alias=user_row.user_alias if user_row is not None else None, 

4803 ) 

4804 

4805 return tuple(hydrate(m) for m in members) 

4806 

4807 

4808class _OrganizationModelsRow(BaseModel): 

4809 models: list[str] = [] # mutable-ok: pydantic field default 

4810 

4811 

4812class _TeamRowWithOrganization(BaseModel): 

4813 litellm_organization_table: _OrganizationModelsRow | None = None 

4814 

4815 

4816def _parent_organization_models(team_row: BaseModel) -> list[str] | None: 

4817 """Return the parent org's model allow-list, or None when the team has no org.""" 

4818 organization: Final = _TeamRowWithOrganization.model_validate(team_row.model_dump()).litellm_organization_table 

4819 return organization.models if organization is not None else None 

4820 

4821 

4822async def _resolve_team_access_group_resources( 

4823 _team_info: TeamInfoResponseObjectTeamTable, 

4824) -> TeamInfoResponseObjectTeamTable: 

4825 """Return a copy of the team info with access_group_models / mcp_server_ids / 

4826 agent_ids / details resolved from its access groups.""" 

4827 if not _team_info.access_group_ids: 

4828 return _team_info 

4829 ag_lookup: Final = await _batch_resolve_access_group_resources(_team_info.access_group_ids) 

4830 resolved_groups: Final = tuple( 

4831 ag_lookup[ag_id] for ag_id in dict.fromkeys(_team_info.access_group_ids) if ag_id in ag_lookup 

4832 ) 

4833 return _team_info.model_copy( 

4834 update={ 

4835 "access_group_models": list({m for group in resolved_groups for m in (group.access_model_names or [])}), 

4836 "access_group_mcp_server_ids": list( 

4837 {s for group in resolved_groups for s in (group.access_mcp_server_ids or [])} 

4838 ), 

4839 "access_group_agent_ids": list({a for group in resolved_groups for a in (group.access_agent_ids or [])}), 

4840 "access_group_details": tuple( 

4841 TeamAccessGroupModelGrant( 

4842 access_group_id=group.access_group_id, 

4843 access_group_name=group.access_group_name, 

4844 models=tuple(group.access_model_names or ()), 

4845 mcp_server_ids=tuple(group.access_mcp_server_ids or ()), 

4846 agent_ids=tuple(group.access_agent_ids or ()), 

4847 ) 

4848 for group in resolved_groups 

4849 ), 

4850 } 

4851 ) 

4852 

4853 

4854@router.get("/team/info", tags=["team management"], dependencies=[Depends(user_api_key_auth)]) 

4855@management_endpoint_wrapper 

4856async def team_info( 

4857 http_request: Request, 

4858 team_id: str = fastapi.Query(default=None, description="Team ID in the request parameters"), 

4859 key_limit: int | None = fastapi.Query(default=None, description="Limit the number of keys returned", gt=0), 

4860 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), 

4861): 

4862 """ 

4863 get info on team + related keys 

4864 

4865 Parameters: 

4866 - team_id: str - Required. The unique identifier of the team to get info on. 

4867 

4868 ``` 

4869 curl --location 'http://localhost:4000/team/info?team_id=your_team_id_here' \ 

4870 --header 'Authorization: Bearer your_api_key_here' 

4871 ``` 

4872 """ 

4873 from litellm.proxy._types import TeamInfoResponseObjectTeamTable 

4874 from litellm.proxy.proxy_server import model_max_budget_limiter, prisma_client 

4875 

4876 try: 

4877 if prisma_client is None: 4877 ↛ 4878line 4877 didn't jump to line 4878 because the condition on line 4877 was never true

4878 raise HTTPException( 

4879 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 

4880 detail={ 

4881 "error": "Database not connected. Connect a database to your proxy - https://docs.litellm.ai/docs/simple_proxy#managing-auth---virtual-keys" 

4882 }, 

4883 ) 

4884 if team_id is None: 

4885 raise HTTPException( 

4886 status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, 

4887 detail={"message": "Malformed request. No team id passed in."}, 

4888 ) 

4889 

4890 try: 

4891 team_info: BaseModel | None = await _team_db(prisma_client).find_unique( 

4892 where={"team_id": team_id}, 

4893 include={ 

4894 "litellm_model_table": True, 

4895 "object_permission": True, 

4896 "litellm_organization_table": True, 

4897 }, 

4898 ) 

4899 if team_info is None: 

4900 raise Exception 

4901 except Exception: 

4902 raise HTTPException( 

4903 status_code=status.HTTP_404_NOT_FOUND, 

4904 detail={"message": f"Team not found, passed team id: {team_id}."}, 

4905 ) 

4906 team_table: Final = LiteLLM_TeamTable.model_validate(team_info.model_dump()) 

4907 await validate_membership(user_api_key_dict=user_api_key_dict, team_table=team_table) 

4908 access_role: Final = await _resolve_team_access(team_obj=team_table, user_api_key_dict=user_api_key_dict) 

4909 organization_models: Final[list[str] | None] = ( 

4910 _parent_organization_models(team_info) if access_role is not None else None 

4911 ) 

4912 

4913 ## GET ALL KEYS ## 

4914 keys = await prisma_client.get_data( 

4915 team_id=team_id, 

4916 table_name="key", 

4917 query_type="find_all", 

4918 expires=datetime.now(), 

4919 limit=key_limit, 

4920 ) 

4921 

4922 if keys is None: 4922 ↛ 4923line 4922 didn't jump to line 4923 because the condition on line 4922 was never true

4923 keys = [] 

4924 

4925 if team_info is None: 4925 ↛ 4927line 4925 didn't jump to line 4927 because the condition on line 4925 was never true

4926 ## make sure we still return a total spend ## 

4927 spend = 0 

4928 for k in keys: 

4929 spend += getattr(k, "spend", 0) 

4930 team_info = {"spend": spend} 

4931 

4932 ## REMOVE HASHED TOKEN INFO before returning ## 

4933 for key in keys: 4933 ↛ 4934line 4933 didn't jump to line 4934 because the loop on line 4933 never started

4934 try: 

4935 key = key.model_dump() 

4936 except Exception: 

4937 # if using pydantic v1 

4938 key = key.dict() 

4939 key.pop("token", None) 

4940 

4941 ## GET ALL MEMBERSHIPS ## 

4942 returned_tm: Final = await get_all_team_memberships(prisma_client, [team_id], user_id=None) 

4943 

4944 if isinstance(team_info, dict): 4944 ↛ 4945line 4944 didn't jump to line 4945 because the condition on line 4944 was never true

4945 _team_info = TeamInfoResponseObjectTeamTable.model_validate(team_info) 

4946 elif isinstance(team_info, BaseModel): 4946 ↛ 4949line 4946 didn't jump to line 4949 because the condition on line 4946 was always true

4947 _team_info = TeamInfoResponseObjectTeamTable.model_validate(team_info.model_dump()) 

4948 else: 

4949 _team_info = TeamInfoResponseObjectTeamTable() 

4950 

4951 ## GET TEAM BUDGET (if exists) ## 

4952 team_member_budget_id: Final = _team_default_budget_id(_team_info) 

4953 if team_member_budget_id is not None: 4953 ↛ 4954line 4953 didn't jump to line 4954 because the condition on line 4953 was never true

4954 _team_info = await _add_team_member_budget_table( 

4955 team_member_budget_id=team_member_budget_id, 

4956 prisma_client=prisma_client, 

4957 team_info_response_object=_team_info, 

4958 ) 

4959 active_default_budget_id: Final = ( 

4960 team_member_budget_id if _team_info.team_member_budget_table is not None else None 

4961 ) 

4962 

4963 # Resolve resources inherited from access groups 

4964 resolved_team_info: Final = await _resolve_team_access_group_resources(_team_info) 

4965 

4966 hydrated_members: Final = await _hydrate_member_user_details( 

4967 prisma_client=prisma_client, 

4968 members=resolved_team_info.members_with_roles, 

4969 ) 

4970 hydrated_team_info: Final = resolved_team_info.model_copy( 

4971 update={ # mutable-ok: pydantic update payload 

4972 "members_with_roles": hydrated_members, 

4973 "organization_models": organization_models, 

4974 "model_max_budget_usage": await build_model_max_budget_usage( 

4975 entity_type=Litellm_EntityType.TEAM, 

4976 entity_id=team_id, 

4977 model_max_budget=resolved_team_info.model_max_budget, 

4978 cache=model_max_budget_limiter.dual_cache, 

4979 ), 

4980 "caller_edit_access": _caller_edit_access(access_role, _general_settings()), 

4981 } 

4982 ) 

4983 

4984 response_object: Final = TeamInfoResponseObject( 

4985 team_id=team_id, 

4986 team_info=hydrated_team_info, 

4987 keys=keys, 

4988 team_memberships=tuple( 

4989 TeamInfoMembership.model_validate( 

4990 MappingProxyType( 

4991 { 

4992 **tm.model_dump(), 

4993 "budget_source": _member_budget_source(tm.budget_id, active_default_budget_id), 

4994 } 

4995 ) 

4996 ) 

4997 for tm in returned_tm 

4998 ), 

4999 ) 

5000 return response_object 

5001 

5002 except Exception as e: 

5003 verbose_proxy_logger.error( 

5004 "litellm.proxy.management_endpoints.team_endpoints.py::team_info - Exception occurred - %s\n%s", 

5005 e, 

5006 traceback.format_exc(), 

5007 ) 

5008 if isinstance(e, HTTPException): 

5009 raise ProxyException( 

5010 message=getattr(e, "detail", f"Authentication Error({e})"), 

5011 type=ProxyErrorTypes.auth_error, 

5012 param=getattr(e, "param", "None"), 

5013 code=getattr(e, "status_code", status.HTTP_400_BAD_REQUEST), 

5014 ) 

5015 elif isinstance(e, ProxyException): 5015 ↛ 5016line 5015 didn't jump to line 5016 because the condition on line 5015 was never true

5016 raise e 

5017 raise ProxyException( 

5018 message="Authentication Error, " + str(e), 

5019 type=ProxyErrorTypes.auth_error, 

5020 param=getattr(e, "param", "None"), 

5021 code=status.HTTP_400_BAD_REQUEST, 

5022 ) 

5023 

5024 

5025@router.get( 

5026 "/team/{team_id}/members/me", 

5027 tags=["team management"], 

5028 dependencies=[Depends(user_api_key_auth)], 

5029 response_model=TeamMemberInfoResponse, 

5030) 

5031@management_endpoint_wrapper 

5032async def team_member_me( 

5033 http_request: Request, 

5034 team_id: str, 

5035 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), 

5036): 

5037 """ 

5038 Get the caller's own team-membership row for the given team. 

5039 

5040 Used by internal users to view their own spend, budget, budget reset 

5041 date, rate limits, and role within a team — without exposing other 

5042 members' data. The caller is resolved from their API key; the path 

5043 `/members/me` always refers to that caller. 

5044 

5045 Returns 404 if the caller is not a member of the team. 

5046 

5047 ``` 

5048 curl --location 'http://localhost:4000/team/your_team_id/members/me' \ 

5049 --header 'Authorization: Bearer your_api_key_here' 

5050 ``` 

5051 """ 

5052 from litellm.proxy.proxy_server import prisma_client, user_api_key_cache 

5053 

5054 if prisma_client is None: 5054 ↛ 5055line 5054 didn't jump to line 5055 because the condition on line 5054 was never true

5055 raise HTTPException( 

5056 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 

5057 detail={ 

5058 "error": "Database not connected. Connect a database to your proxy - https://docs.litellm.ai/docs/simple_proxy#managing-auth---virtual-keys" 

5059 }, 

5060 ) 

5061 

5062 caller_user_id: Final = user_api_key_dict.user_id 

5063 if caller_user_id is None: 5063 ↛ 5065line 5063 didn't jump to line 5065 because the condition on line 5063 was never true

5064 # Team keys / service-account keys without a user_id can't resolve "me". 

5065 raise HTTPException( 

5066 status_code=status.HTTP_400_BAD_REQUEST, 

5067 detail={"error": "API key has no associated user_id; cannot resolve 'me' for team membership."}, 

5068 ) 

5069 

5070 team_table: Final = await get_team_object( 

5071 team_id=team_id, 

5072 prisma_client=prisma_client, 

5073 user_api_key_cache=user_api_key_cache, 

5074 ) 

5075 

5076 caller_user_email: Final = user_api_key_dict.user_email 

5077 member_role: str | None = None 

5078 for m in team_table.members_with_roles: 5078 ↛ 5087line 5078 didn't jump to line 5087 because the loop on line 5078 didn't complete

5079 # Match by user_id when present, else fall back to email — members 

5080 # added by email may have user_id=None on the stored entry. 

5081 if (m.user_id is not None and m.user_id == caller_user_id) or ( 5081 ↛ 5078line 5081 didn't jump to line 5078 because the condition on line 5081 was always true

5082 m.user_email is not None and caller_user_email is not None and m.user_email == caller_user_email 

5083 ): 

5084 member_role = m.role 

5085 break 

5086 

5087 if member_role is None: 5087 ↛ 5091line 5087 didn't jump to line 5091 because the condition on line 5087 was never true

5088 # Caller is not a member of this team. Even proxy admins get 404 here — 

5089 # they can use /team/info to view all members; "me" only resolves for 

5090 # actual members of the team. 

5091 raise HTTPException( 

5092 status_code=status.HTTP_404_NOT_FOUND, 

5093 detail={"error": f"User user_id={caller_user_id} is not a member of team_id={team_id}."}, 

5094 ) 

5095 

5096 membership: Final = await get_team_membership( 

5097 user_id=caller_user_id, 

5098 team_id=team_id, 

5099 prisma_client=prisma_client, 

5100 user_api_key_cache=user_api_key_cache, 

5101 ) 

5102 

5103 user_row: Final = await get_user_object( 

5104 user_id=caller_user_id, 

5105 prisma_client=prisma_client, 

5106 user_api_key_cache=user_api_key_cache, 

5107 user_id_upsert=False, 

5108 ) 

5109 user_email: Final = getattr(user_row, "user_email", None) if user_row is not None else None 

5110 

5111 if membership is None: 5111 ↛ 5114line 5111 didn't jump to line 5114 because the condition on line 5111 was never true

5112 # Member is in members_with_roles but has no membership row yet 

5113 # (no per-member budget/limits configured). Return defaults. 

5114 return TeamMemberInfoResponse( 

5115 user_id=caller_user_id, 

5116 team_id=team_id, 

5117 team_alias=team_table.team_alias, 

5118 role=member_role, 

5119 user_email=user_email, 

5120 spend=0.0, 

5121 total_spend=0.0, 

5122 budget_id=None, 

5123 litellm_budget_table=None, 

5124 ) 

5125 

5126 return TeamMemberInfoResponse( 

5127 user_id=caller_user_id, 

5128 team_id=team_id, 

5129 team_alias=team_table.team_alias, 

5130 role=member_role, 

5131 user_email=user_email, 

5132 spend=membership.spend, 

5133 total_spend=membership.total_spend, 

5134 budget_id=membership.budget_id, 

5135 litellm_budget_table=membership.litellm_budget_table, 

5136 ) 

5137 

5138 

5139@router.post("/team/block", tags=["team management"], dependencies=[Depends(user_api_key_auth)]) 

5140@management_endpoint_wrapper 

5141async def block_team( 

5142 data: BlockTeamRequest, 

5143 http_request: Request, 

5144 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), 

5145): 

5146 """ 

5147 Blocks all calls from keys with this team id. 

5148 

5149 Parameters: 

5150 - team_id: str - Required. The unique identifier of the team to block. 

5151 

5152 Example: 

5153 ``` 

5154 curl --location 'http://0.0.0.0:4000/team/block' \ 

5155 --header 'Authorization: Bearer sk-1234' \ 

5156 --header 'Content-Type: application/json' \ 

5157 --data '{ 

5158 "team_id": "team-1234" 

5159 }' 

5160 ``` 

5161 

5162 Returns: 

5163 - The updated team record with blocked=True 

5164 

5165 

5166 

5167 """ 

5168 from litellm.proxy.proxy_server import prisma_client 

5169 

5170 if prisma_client is None: 5170 ↛ 5171line 5170 didn't jump to line 5171 because the condition on line 5170 was never true

5171 raise Exception("No DB Connected.") 

5172 

5173 existing_team: Final = await _team_db(prisma_client).find_unique(where={"team_id": data.team_id}) 

5174 if existing_team is None: 

5175 raise HTTPException( 

5176 status_code=404, 

5177 detail={"error": f"Team not found, passed team_id={data.team_id}"}, 

5178 ) 

5179 

5180 # Verify caller has access to manage this team 

5181 await _verify_team_access( 

5182 team_obj=LiteLLM_TeamTable.model_validate(existing_team.model_dump()), 

5183 user_api_key_dict=user_api_key_dict, 

5184 ) 

5185 

5186 record: Final = await _team_db(prisma_client).update( 

5187 where={"team_id": data.team_id}, 

5188 data={"blocked": True}, 

5189 ) 

5190 

5191 return record 

5192 

5193 

5194@router.post("/team/unblock", tags=["team management"], dependencies=[Depends(user_api_key_auth)]) 

5195@management_endpoint_wrapper 

5196async def unblock_team( 

5197 data: BlockTeamRequest, 

5198 http_request: Request, 

5199 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), 

5200): 

5201 """ 

5202 Unblocks a previously blocked team, re-enabling calls from keys with this team id. 

5203 

5204 Parameters: 

5205 - team_id: str - Required. The unique identifier of the team to unblock. 

5206 

5207 Example: 

5208 ``` 

5209 curl --location 'http://0.0.0.0:4000/team/unblock' \ 

5210 --header 'Authorization: Bearer sk-1234' \ 

5211 --header 'Content-Type: application/json' \ 

5212 --data '{ 

5213 "team_id": "team-1234" 

5214 }' 

5215 ``` 

5216 """ 

5217 from litellm.proxy.proxy_server import prisma_client 

5218 

5219 if prisma_client is None: 5219 ↛ 5220line 5219 didn't jump to line 5220 because the condition on line 5219 was never true

5220 raise Exception("No DB Connected.") 

5221 

5222 existing_team: Final = await _team_db(prisma_client).find_unique(where={"team_id": data.team_id}) 

5223 if existing_team is None: 

5224 raise HTTPException( 

5225 status_code=404, 

5226 detail={"error": f"Team not found, passed team_id={data.team_id}"}, 

5227 ) 

5228 

5229 # Verify caller has access to manage this team 

5230 await _verify_team_access( 

5231 team_obj=LiteLLM_TeamTable.model_validate(existing_team.model_dump()), 

5232 user_api_key_dict=user_api_key_dict, 

5233 ) 

5234 

5235 record: Final = await _team_db(prisma_client).update( 

5236 where={"team_id": data.team_id}, 

5237 data={"blocked": False}, 

5238 ) 

5239 

5240 return record 

5241 

5242 

5243@router.get( 

5244 "/team/metadata_schema", 

5245 tags=["team management"], # mutable-ok: fastapi's decorator signature types tags as a list 

5246 dependencies=(Depends(user_api_key_auth),), 

5247 response_model=TeamMetadataSchemaResponse, 

5248) 

5249async def get_team_metadata_schema(): 

5250 """ 

5251 Get the team metadata fields declared in ``general_settings.team_metadata_schema``. 

5252 

5253 The UI uses this to prepopulate the team metadata form with the declared 

5254 keys. Returns an empty ``fields`` list when no schema is configured. This 

5255 schema is advisory; server-side enforcement stays with 

5256 ``custom_team_metadata_validate``. 

5257 """ 

5258 return TeamMetadataSchemaResponse(fields=TEAM_METADATA_SCHEMA_REGISTRY.get()) 

5259 

5260 

5261@router.get("/team/available") 

5262async def list_available_teams( 

5263 http_request: Request, 

5264 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), 

5265 response_model=list[LiteLLM_TeamTable], 

5266): 

5267 from litellm.proxy.proxy_server import prisma_client 

5268 

5269 if prisma_client is None: 5269 ↛ 5270line 5269 didn't jump to line 5270 because the condition on line 5269 was never true

5270 raise HTTPException( 

5271 status_code=400, 

5272 detail={"error": CommonProxyErrors.db_not_connected_error.value}, 

5273 ) 

5274 

5275 available_teams = cast( 

5276 list[str] | None, 

5277 ( 

5278 litellm.default_internal_user_params.get("available_teams") 

5279 if litellm.default_internal_user_params is not None 

5280 else None 

5281 ), 

5282 ) 

5283 if available_teams is None: 5283 ↛ 5287line 5283 didn't jump to line 5287 because the condition on line 5283 was always true

5284 return [] 

5285 

5286 # filter out teams that the user is already a member of 

5287 user_info: Final = await _user_db(prisma_client).find_unique(where={"user_id": user_api_key_dict.user_id}) 

5288 if user_info is None: 

5289 raise HTTPException( 

5290 status_code=404, 

5291 detail={"error": "User not found"}, 

5292 ) 

5293 user_info_correct_type: Final = LiteLLM_UserTable.model_validate(user_info.model_dump()) 

5294 

5295 available_teams = [team for team in available_teams if team not in user_info_correct_type.teams] 

5296 

5297 available_teams_db: Final = await _team_db(prisma_client).find_many(where={"team_id": {"in": available_teams}}) 

5298 

5299 available_teams_correct_type = [LiteLLM_TeamTable.model_validate(team.model_dump()) for team in available_teams_db] 

5300 

5301 return available_teams_correct_type 

5302 

5303 

5304async def _get_org_admin_org_ids( 

5305 user_id: str, 

5306 prisma_client: PrismaClient, 

5307 user_api_key_cache: UserApiKeyCache, 

5308 proxy_logging_obj: ProxyLogging, 

5309) -> list[str] | None: 

5310 """ 

5311 Return the list of organization IDs where the user is an org admin. 

5312 Returns None if the user is not an org admin of any organization or if 

5313 the user cannot be found. 

5314 """ 

5315 try: 

5316 caller_user: Final = await get_user_object( 

5317 user_id=user_id, 

5318 prisma_client=prisma_client, 

5319 user_api_key_cache=user_api_key_cache, 

5320 user_id_upsert=False, 

5321 proxy_logging_obj=proxy_logging_obj, 

5322 ) 

5323 except ValueError: 

5324 # get_user_object raises ValueError when the user doesn't exist 

5325 return None 

5326 

5327 if caller_user is None: 

5328 return None 

5329 

5330 org_ids: Final = [ 

5331 m.organization_id 

5332 for m in (caller_user.organization_memberships or []) 

5333 if m.user_role == LitellmUserRoles.ORG_ADMIN.value and m.organization_id is not None 

5334 ] 

5335 return org_ids if org_ids else None 

5336 

5337 

5338async def _get_user_team_ids_from_db( 

5339 user_id: str, 

5340 prisma_client: PrismaClient, 

5341 user_api_key_cache: UserApiKeyCache, 

5342 proxy_logging_obj: ProxyLogging, 

5343) -> tuple[str, ...]: 

5344 try: 

5345 user: Final = await get_user_object( 

5346 user_id=user_id, 

5347 prisma_client=prisma_client, 

5348 user_api_key_cache=user_api_key_cache, 

5349 user_id_upsert=False, 

5350 proxy_logging_obj=proxy_logging_obj, 

5351 check_db_only=True, 

5352 ) 

5353 except UserNotFoundError: 

5354 return () 

5355 return tuple(user.teams or ()) if user is not None else () 

5356 

5357 

5358async def _build_team_list_where_conditions( 

5359 prisma_client: PrismaClient, 

5360 team_id: str | None, 

5361 team_alias: str | None, 

5362 organization_id: str | None, 

5363 user_id: str | None, 

5364 use_deleted_table: bool, 

5365 search: str | None = None, 

5366 search_team_id_match: TeamIdSearchMatch = "exact", 

5367 org_admin_org_ids: list[str] | None = None, 

5368 own_team_ids: tuple[str, ...] = (), 

5369 user_api_key_cache: UserApiKeyCache | None = None, 

5370 proxy_logging_obj: ProxyLogging | None = None, 

5371) -> dict[str, object] | None: 

5372 """ 

5373 Build where conditions for team list query. 

5374 

5375 An org admin listing their own teams sees the union of the teams in the 

5376 orgs they administer and `own_team_ids`, the teams they are a member of. 

5377 

5378 Returns None when the query is guaranteed to yield no results (e.g. user 

5379 has no team memberships), allowing the caller to skip the DB round-trip. 

5380 """ 

5381 where_conditions: Final[dict[str, object]] = {} 

5382 

5383 if team_id: 

5384 where_conditions["team_id"] = team_id 

5385 

5386 if team_alias: 

5387 where_conditions["team_alias"] = { 

5388 "contains": team_alias, 

5389 "mode": "insensitive", # Case-insensitive search 

5390 } 

5391 

5392 if search: 

5393 where_conditions["OR"] = [ 

5394 ({"team_id": {"startsWith": search}} if search_team_id_match == "prefix" else {"team_id": search}), 

5395 {"team_alias": {"contains": search, "mode": "insensitive"}}, 

5396 ] 

5397 

5398 if organization_id: 5398 ↛ 5399line 5398 didn't jump to line 5399 because the condition on line 5398 was never true

5399 where_conditions["organization_id"] = organization_id 

5400 elif org_admin_org_ids is not None and own_team_ids: 5400 ↛ 5401line 5400 didn't jump to line 5401 because the condition on line 5400 was never true

5401 org_or_membership_scope: Final[prisma_types.LiteLLM_TeamTableWhereInput] = { 

5402 "OR": [{"organization_id": {"in": org_admin_org_ids}}, {"team_id": {"in": list(own_team_ids)}}] 

5403 } 

5404 where_conditions["AND"] = [org_or_membership_scope] 

5405 elif org_admin_org_ids is not None: 5405 ↛ 5407line 5405 didn't jump to line 5407 because the condition on line 5405 was never true

5406 # Org admin: always scope to their orgs, even when filtering by user_id. 

5407 where_conditions["organization_id"] = {"in": org_admin_org_ids} 

5408 

5409 if user_id: 

5410 try: 

5411 user_object_correct_type: Final = await get_user_object( 

5412 user_id=user_id, 

5413 prisma_client=prisma_client, 

5414 user_api_key_cache=user_api_key_cache, 

5415 user_id_upsert=False, 

5416 proxy_logging_obj=proxy_logging_obj, 

5417 ) 

5418 except ValueError: 

5419 raise HTTPException( 

5420 status_code=404, 

5421 detail={"error": f"User not found, passed user_id={user_id}"}, 

5422 ) 

5423 if user_object_correct_type is None: 5423 ↛ 5424line 5423 didn't jump to line 5424 because the condition on line 5423 was never true

5424 raise HTTPException( 

5425 status_code=404, 

5426 detail={"error": f"User not found, passed user_id={user_id}"}, 

5427 ) 

5428 user_team_ids: Final = user_object_correct_type.teams or [] 

5429 

5430 if use_deleted_table: 5430 ↛ 5431line 5430 didn't jump to line 5431 because the condition on line 5430 was never true

5431 where_conditions["members"] = {"has": user_id} 

5432 else: 

5433 # When user_id is provided, filter by that user's direct team 

5434 # memberships. For org admins the access control gate in 

5435 # list_team_v2 already verified the caller's authority — the 

5436 # filter logic is the same as for regular users. 

5437 if not user_team_ids: 5437 ↛ 5438line 5437 didn't jump to line 5438 because the condition on line 5437 was never true

5438 return None # no memberships — skip the DB query 

5439 elif team_id is not None: 5439 ↛ 5447line 5439 didn't jump to line 5447 because the condition on line 5439 was always true

5440 # team_id exact-match already in where_conditions; verify membership 

5441 if team_id not in user_team_ids: 

5442 raise HTTPException( 

5443 status_code=404, 

5444 detail={"error": f"User is not a member of team_id={team_id}"}, 

5445 ) 

5446 else: 

5447 where_conditions["team_id"] = {"in": user_team_ids} 

5448 

5449 return where_conditions 

5450 

5451 

5452async def _batch_resolve_access_group_resources( 

5453 all_access_group_ids: list[str], 

5454) -> "dict[str, prisma_models.LiteLLM_AccessGroupTable]": 

5455 """ 

5456 Batch-fetch access groups in a single DB query and return them keyed by 

5457 access_group_id. Missing/invalid groups are silently omitted. 

5458 """ 

5459 from litellm.proxy.proxy_server import prisma_client as _prisma_client 

5460 

5461 if not all_access_group_ids or _prisma_client is None: 5461 ↛ 5462line 5461 didn't jump to line 5462 because the condition on line 5461 was never true

5462 return {} 

5463 

5464 unique_ids: Final = tuple(frozenset(all_access_group_ids)) 

5465 rows: Final = await _access_group_db(_prisma_client).find_many( 

5466 where={"access_group_id": {"in": unique_ids}}, 

5467 ) 

5468 return {row.access_group_id: row for row in rows} 

5469 

5470 

5471def _convert_teams_to_response_models( 

5472 teams: Sequence, 

5473 use_deleted_table: bool, 

5474 keys_count_by_team: dict[str, int] | None = None, 

5475) -> list[TeamListItem | LiteLLM_TeamTable | LiteLLM_DeletedTeamTable]: 

5476 """Convert raw Prisma team rows to response models.""" 

5477 team_list: Final[list[TeamListItem | LiteLLM_TeamTable | LiteLLM_DeletedTeamTable]] = [] 

5478 counts: Final = keys_count_by_team or {} 

5479 for team in teams: 

5480 try: 

5481 team_dict = team.model_dump() 

5482 except Exception: 

5483 team_dict = team.dict() 

5484 

5485 if use_deleted_table: 5485 ↛ 5486line 5485 didn't jump to line 5486 because the condition on line 5485 was never true

5486 team_list.append(LiteLLM_DeletedTeamTable.model_validate(team_dict)) 

5487 else: 

5488 members_with_roles = team_dict.get("members_with_roles") 

5489 if not isinstance(members_with_roles, list): 5489 ↛ 5490line 5489 didn't jump to line 5490 because the condition on line 5489 was never true

5490 members_with_roles = [] 

5491 team_dict["members_with_roles"] = members_with_roles 

5492 members_count = len(members_with_roles) 

5493 keys_count = counts.get(team_dict.get("team_id") or "", 0) 

5494 team_list.append( 

5495 TeamListItem( 

5496 **team_dict, 

5497 members_count=members_count, 

5498 keys_count=keys_count, 

5499 ) 

5500 ) 

5501 return team_list 

5502 

5503 

5504async def _get_keys_count_by_team( 

5505 prisma_client: PrismaClient, 

5506 teams: Sequence[_TeamIdRow], 

5507) -> dict[str, int]: 

5508 """Aggregate virtual-key counts per team for the given page of teams. 

5509 

5510 Runs a single GROUP BY against LiteLLM_VerificationToken. The IN clause is 

5511 bounded by page_size and uses the existing @@index([team_id]), so this is 

5512 one DB round-trip per page. Returns an empty map when the page has no teams. 

5513 """ 

5514 page_team_ids: Final = [getattr(t, "team_id", None) for t in teams if getattr(t, "team_id", None)] 

5515 if not page_team_ids: 

5516 return {} 

5517 

5518 grouped: Final = cast( # cast-ok: prisma group_by returns one row per `by` key with `count=` nested under "_count" 

5519 "Sequence[_TeamIdGroupRow]", 

5520 await _tokens_db(prisma_client).group_by( 

5521 by=["team_id"], 

5522 where={"team_id": {"in": page_team_ids}}, 

5523 count={"team_id": True}, 

5524 ), 

5525 ) 

5526 return {row["team_id"]: row.get("_count", {}).get("team_id", 0) for row in grouped if row.get("team_id")} 

5527 

5528 

5529async def _enforce_list_team_v2_access( 

5530 user_api_key_dict: UserAPIKeyAuth, 

5531 user_id: str | None, 

5532 organization_id: str | None, 

5533 prisma_client: PrismaClient, 

5534 user_api_key_cache: UserApiKeyCache, 

5535 proxy_logging_obj: ProxyLogging, 

5536) -> tuple[str | None, list[str] | None, tuple[str, ...]]: 

5537 """Enforce access control for list_team_v2. 

5538 

5539 - Proxy admins and admin viewers can query any teams. 

5540 - Org admins can query teams within their organizations, plus the teams 

5541 they are a member of when listing their own teams. 

5542 - Regular users can only query their own teams. 

5543 

5544 Returns the (possibly overridden) user_id, org_admin_org_ids and, for an 

5545 org admin's own query, the caller's own team ids. 

5546 """ 

5547 is_proxy_admin: Final = _user_has_admin_view(user_api_key_dict) 

5548 caller_user_id: Final = user_api_key_dict.user_id 

5549 

5550 if is_proxy_admin: 5550 ↛ 5555line 5550 didn't jump to line 5555 because the condition on line 5550 was always true

5551 return user_id, None, () 

5552 

5553 # Always check org admin status so that even own-queries see 

5554 # the full set of organisation teams, not just direct memberships. 

5555 org_admin_org_ids: Final = ( 

5556 await _get_org_admin_org_ids( 

5557 user_id=caller_user_id, 

5558 prisma_client=prisma_client, 

5559 user_api_key_cache=user_api_key_cache, 

5560 proxy_logging_obj=proxy_logging_obj, 

5561 ) 

5562 if caller_user_id 

5563 else None 

5564 ) 

5565 

5566 if caller_user_id and org_admin_org_ids is not None: 

5567 # Org admin: validate org_id filter if provided 

5568 if organization_id and organization_id not in org_admin_org_ids: 

5569 raise HTTPException( 

5570 status_code=403, 

5571 detail={"error": "You can only view teams within your organizations."}, 

5572 ) 

5573 is_own_query: Final = user_id is None or user_id == caller_user_id 

5574 own_team_ids: Final = ( 

5575 await _get_user_team_ids_from_db( 

5576 user_id=caller_user_id, 

5577 prisma_client=prisma_client, 

5578 user_api_key_cache=user_api_key_cache, 

5579 proxy_logging_obj=proxy_logging_obj, 

5580 ) 

5581 if is_own_query 

5582 else () 

5583 ) 

5584 verbose_proxy_logger.debug( 

5585 "list_team_v2: org admin access for user=%s, org_ids=%s, user_id_filter=%s", 

5586 _sanitize_for_log(caller_user_id), 

5587 org_admin_org_ids, 

5588 _sanitize_for_log(None if is_own_query else user_id), 

5589 ) 

5590 return None if is_own_query else user_id, org_admin_org_ids, own_team_ids 

5591 

5592 # Not an org admin — fall back to standard route check 

5593 if not allowed_route_check_inside_route(user_api_key_dict=user_api_key_dict, requested_user_id=user_id): 

5594 raise HTTPException( 

5595 status_code=401, 

5596 detail={ 

5597 "error": f"Only admin users can query all teams/other teams. Your user role={user_api_key_dict.user_role}" 

5598 }, 

5599 ) 

5600 # Regular user — auto-inject caller's user_id 

5601 return user_id if user_id is not None else caller_user_id, None, () 

5602 

5603 

5604@router.get( 

5605 "/v2/team/list", 

5606 tags=["team management"], 

5607 response_model=TeamListResponse, 

5608 dependencies=[Depends(user_api_key_auth)], 

5609) 

5610@management_endpoint_wrapper 

5611async def list_team_v2( 

5612 http_request: Request, 

5613 user_id: str | None = fastapi.Query(default=None, description="Only return teams which this 'user_id' belongs to"), 

5614 organization_id: str | None = fastapi.Query( 

5615 default=None, 

5616 description="Only return teams which this 'organization_id' belongs to", 

5617 ), 

5618 team_id: str | None = fastapi.Query(default=None, description="Only return teams which this 'team_id' belongs to"), 

5619 team_alias: str | None = fastapi.Query( 

5620 default=None, 

5621 description="Only return teams which this 'team_alias' belongs to. Supports partial matching.", 

5622 ), 

5623 search: str | None = fastapi.Query( 

5624 default=None, 

5625 description="Combined search: matches teams whose 'team_id' matches the value OR whose 'team_alias' contains it (case-insensitive).", 

5626 ), 

5627 search_team_id_match: Annotated[ 

5628 TeamIdSearchMatch, 

5629 fastapi.Query( 

5630 description="How 'search' matches 'team_id': 'exact' (default) or 'prefix' for a case-sensitive prefix match." 

5631 ), 

5632 ] = "exact", 

5633 page: int = fastapi.Query(default=1, description="Page number for pagination", ge=1), 

5634 page_size: int = fastapi.Query(default=10, description="Number of teams per page", ge=1, le=100), 

5635 sort_by: str | None = fastapi.Query( 

5636 default=None, 

5637 description="Column to sort by (e.g. 'team_id', 'team_alias', 'created_at')", 

5638 ), 

5639 sort_order: str = fastapi.Query(default="asc", description="Sort order ('asc' or 'desc')"), 

5640 status: str | None = fastapi.Query(default=None, description="Filter by status (e.g. 'deleted')"), 

5641 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), 

5642): 

5643 """ 

5644 Get a paginated list of teams with filtering and sorting options. 

5645 

5646 Parameters: 

5647 user_id: Optional[str] 

5648 Only return teams which this user belongs to 

5649 organization_id: Optional[str] 

5650 Only return teams which belong to this organization 

5651 team_id: Optional[str] 

5652 Filter teams by exact team_id match 

5653 team_alias: Optional[str] 

5654 Filter teams by partial team_alias match 

5655 page: int 

5656 The page number to return 

5657 page_size: int 

5658 The number of items per page 

5659 sort_by: Optional[str] 

5660 Column to sort by (e.g. 'team_id', 'team_alias', 'created_at') 

5661 sort_order: str 

5662 Sort order ('asc' or 'desc') 

5663 status: Optional[str] 

5664 Filter by status. Currently supports "deleted" to query deleted teams. 

5665 """ 

5666 from litellm.proxy.proxy_server import ( 

5667 prisma_client, 

5668 proxy_logging_obj, 

5669 user_api_key_cache, 

5670 ) 

5671 

5672 if prisma_client is None: 5672 ↛ 5673line 5672 didn't jump to line 5673 because the condition on line 5672 was never true

5673 raise HTTPException( 

5674 status_code=500, 

5675 detail={"error": f"No db connected. prisma client={prisma_client}"}, 

5676 ) 

5677 

5678 # --- Access control --- 

5679 user_id, org_admin_org_ids, own_team_ids = await _enforce_list_team_v2_access( 

5680 user_api_key_dict=user_api_key_dict, 

5681 user_id=user_id, 

5682 organization_id=organization_id, 

5683 prisma_client=prisma_client, 

5684 user_api_key_cache=user_api_key_cache, 

5685 proxy_logging_obj=proxy_logging_obj, 

5686 ) 

5687 

5688 if status is not None and status != "deleted": 

5689 raise HTTPException( 

5690 status_code=400, 

5691 detail={"error": "Invalid status value. Currently only 'deleted' is supported."}, 

5692 ) 

5693 

5694 use_deleted_table: Final = status == "deleted" 

5695 

5696 # Calculate skip and take for pagination 

5697 skip: Final = (page - 1) * page_size 

5698 

5699 # Build where conditions based on provided parameters. 

5700 # Returns None when the query is guaranteed to yield no results. 

5701 where_conditions: Final = await _build_team_list_where_conditions( 

5702 prisma_client=prisma_client, 

5703 team_id=team_id, 

5704 team_alias=team_alias, 

5705 organization_id=organization_id, 

5706 user_id=user_id, 

5707 use_deleted_table=use_deleted_table, 

5708 search=search, 

5709 search_team_id_match=search_team_id_match, 

5710 org_admin_org_ids=org_admin_org_ids, 

5711 own_team_ids=own_team_ids, 

5712 user_api_key_cache=user_api_key_cache, 

5713 proxy_logging_obj=proxy_logging_obj, 

5714 ) 

5715 

5716 if where_conditions is None: 5716 ↛ 5717line 5716 didn't jump to line 5717 because the condition on line 5716 was never true

5717 return { 

5718 "teams": [], 

5719 "total": 0, 

5720 "page": page, 

5721 "page_size": page_size, 

5722 "total_pages": 0, 

5723 } 

5724 

5725 # Build order_by conditions 

5726 valid_sort_columns: Final = ["team_id", "team_alias", "created_at"] 

5727 order_by = None 

5728 if sort_by and sort_by in valid_sort_columns: 5728 ↛ 5729line 5728 didn't jump to line 5729 because the condition on line 5728 was never true

5729 if sort_order.lower() not in ["asc", "desc"]: 

5730 sort_order = "asc" 

5731 order_by = {sort_by: sort_order.lower()} 

5732 

5733 # Get teams with pagination 

5734 if use_deleted_table: 5734 ↛ 5736line 5734 didn't jump to line 5736 because the condition on line 5734 was never true

5735 # LiteLLM_DeletedTeamTable has no litellm_model_table relation, unlike below 

5736 teams = await _deleted_team_db(prisma_client).find_many( 

5737 where=where_conditions, 

5738 skip=skip, 

5739 take=page_size, 

5740 order=order_by if order_by else {"created_at": "desc"}, # Default sort 

5741 ) 

5742 # Get total count for pagination 

5743 total_count = await _deleted_team_db(prisma_client).count(where=where_conditions) 

5744 else: 

5745 teams = await _team_db(prisma_client).find_many( 

5746 where=where_conditions, 

5747 skip=skip, 

5748 take=page_size, 

5749 order=order_by if order_by else {"created_at": "desc"}, # Default sort 

5750 include=_INCLUDE_MODEL_TABLE, 

5751 ) 

5752 # Get total count for pagination 

5753 total_count = await _team_db(prisma_client).count(where=where_conditions) 

5754 

5755 # Calculate total pages 

5756 total_pages: Final = -(-total_count // page_size) # Ceiling division 

5757 

5758 # Aggregate virtual-key counts per team for the current page. The deleted 

5759 # table does not carry keys_count, so it is skipped. 

5760 keys_count_by_team: dict[str, int] = {} 

5761 if not use_deleted_table: 5761 ↛ 5765line 5761 didn't jump to line 5765 because the condition on line 5761 was always true

5762 keys_count_by_team = await _get_keys_count_by_team(prisma_client, teams) 

5763 

5764 # Convert Prisma models to response models with members_count and keys_count 

5765 team_list = _convert_teams_to_response_models(teams, use_deleted_table, keys_count_by_team=keys_count_by_team) 

5766 

5767 # Resolve resources inherited from access groups (single batch query) 

5768 if not use_deleted_table: 5768 ↛ 5787line 5768 didn't jump to line 5787 because the condition on line 5768 was always true

5769 team_items_with_ag: Final = [t for t in team_list if isinstance(t, TeamListItem) and t.access_group_ids] 

5770 if team_items_with_ag: 

5771 all_ag_ids: Final = [ag_id for t in team_items_with_ag for ag_id in (t.access_group_ids or [])] 

5772 ag_lookup: Final = await _batch_resolve_access_group_resources(all_ag_ids) 

5773 for team_item in team_items_with_ag: 

5774 team_groups = tuple( 

5775 ag_lookup[ag_id] for ag_id in (team_item.access_group_ids or []) if ag_id in ag_lookup 

5776 ) 

5777 team_item.access_group_models = list( 

5778 {m for group in team_groups for m in (group.access_model_names or [])} 

5779 ) 

5780 team_item.access_group_mcp_server_ids = list( 

5781 {s for group in team_groups for s in (group.access_mcp_server_ids or [])} 

5782 ) 

5783 team_item.access_group_agent_ids = list( 

5784 {a for group in team_groups for a in (group.access_agent_ids or [])} 

5785 ) 

5786 

5787 return { 

5788 "teams": team_list, 

5789 "total": total_count, 

5790 "page": page, 

5791 "page_size": page_size, 

5792 "total_pages": total_pages, 

5793 } 

5794 

5795 

5796async def _authorize_and_filter_teams( 

5797 user_api_key_dict: UserAPIKeyAuth, 

5798 user_id: str | None, 

5799 prisma_client: PrismaClient, 

5800 user_api_key_cache: UserApiKeyCache, 

5801 proxy_logging_obj: ProxyLogging, 

5802) -> list: 

5803 """ 

5804 Authorize the /team/list request and return filtered teams. 

5805 

5806 - Proxy admins: all teams (or filtered by user_id if provided). 

5807 - Org admins: teams from their orgs (scoped to user_id if provided). 

5808 - Own query (user_id matches caller): teams the user is a member of, across all orgs. 

5809 - Others: 401. 

5810 """ 

5811 is_proxy_admin: Final = _user_has_admin_view(user_api_key_dict) 

5812 is_own_query: Final = ( 

5813 user_id is not None and user_api_key_dict.user_id is not None and user_api_key_dict.user_id == user_id 

5814 ) 

5815 allowed_org_ids: list[str] | None = None 

5816 

5817 if not is_proxy_admin: 5817 ↛ 5819line 5817 didn't jump to line 5819 because the condition on line 5817 was never true

5818 # Check if user is an org admin (even for own queries, so they see org teams) 

5819 if user_api_key_dict.user_id is not None: 

5820 caller_user: Final = await get_user_object( 

5821 user_id=user_api_key_dict.user_id, 

5822 prisma_client=prisma_client, 

5823 user_api_key_cache=user_api_key_cache, 

5824 user_id_upsert=False, 

5825 proxy_logging_obj=proxy_logging_obj, 

5826 ) 

5827 if caller_user is not None: 

5828 allowed_org_ids = [ 

5829 m.organization_id 

5830 for m in (caller_user.organization_memberships or []) 

5831 if m.user_role == LitellmUserRoles.ORG_ADMIN.value and m.organization_id is not None 

5832 ] 

5833 if not allowed_org_ids: 

5834 allowed_org_ids = None 

5835 

5836 if allowed_org_ids is None and not is_own_query: 

5837 raise HTTPException( 

5838 status_code=401, 

5839 detail={ 

5840 "error": f"Only admin users can query all teams/other teams. Your user role={user_api_key_dict.user_role}" 

5841 }, 

5842 ) 

5843 

5844 if allowed_org_ids is not None and not is_own_query: 5844 ↛ 5845line 5844 didn't jump to line 5845 because the condition on line 5844 was never true

5845 org_teams: Final = await _raw_team_db(TeamRepository(prisma_client)).find_many( 

5846 where={"organization_id": {"in": allowed_org_ids}}, 

5847 include={"litellm_model_table": True}, 

5848 ) 

5849 if not user_id: 

5850 return list(org_teams) 

5851 return [ 

5852 team 

5853 for team in org_teams 

5854 if team.members_with_roles and any(m.get("user_id") == user_id for m in team.members_with_roles) 

5855 ] 

5856 

5857 response: Final = await _raw_team_db(TeamRepository(prisma_client)).find_many(include={"litellm_model_table": True}) 

5858 if not user_id: 

5859 # Proxy admin: all teams 

5860 return list(response) 

5861 

5862 # Prisma can't filter JSON arrays, so membership is filtered in Python 

5863 return [ 

5864 team 

5865 for team in response 

5866 if team.members_with_roles and any(m.get("user_id") == user_id for m in team.members_with_roles) 

5867 ] 

5868 

5869 

5870@router.get("/team/list", tags=["team management"], dependencies=[Depends(user_api_key_auth)]) 

5871@management_endpoint_wrapper 

5872async def list_team( 

5873 http_request: Request, 

5874 user_id: str | None = fastapi.Query(default=None, description="Only return teams which this 'user_id' belongs to"), 

5875 organization_id: str | None = None, 

5876 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), 

5877): 

5878 """ 

5879 ``` 

5880 curl --location --request GET 'http://0.0.0.0:4000/team/list' \ 

5881 --header 'Authorization: Bearer sk-1234' 

5882 ``` 

5883 

5884 Parameters: 

5885 - user_id: str - Optional. If passed will only return teams that the user_id is a member of. 

5886 - organization_id: str - Optional. If passed will only return teams that belong to the organization_id. Pass 'default_organization' to get all teams without organization_id. 

5887 """ 

5888 from litellm.proxy.proxy_server import ( 

5889 prisma_client, 

5890 proxy_logging_obj, 

5891 user_api_key_cache, 

5892 ) 

5893 

5894 if prisma_client is None: 5894 ↛ 5895line 5894 didn't jump to line 5895 because the condition on line 5894 was never true

5895 raise HTTPException( 

5896 status_code=400, 

5897 detail={"error": CommonProxyErrors.db_not_connected_error.value}, 

5898 ) 

5899 

5900 filtered_response: Final = await _authorize_and_filter_teams( 

5901 user_api_key_dict=user_api_key_dict, 

5902 user_id=user_id, 

5903 prisma_client=prisma_client, 

5904 user_api_key_cache=user_api_key_cache, 

5905 proxy_logging_obj=proxy_logging_obj, 

5906 ) 

5907 

5908 _team_ids: Final = [team.team_id for team in filtered_response] 

5909 returned_tm: Final = await get_all_team_memberships(prisma_client, _team_ids, user_id=user_id) 

5910 

5911 returned_responses: list[TeamListResponseObject] = [] 

5912 for team in filtered_response: 

5913 _team_memberships: list[LiteLLM_TeamMembership] = [] 

5914 for tm in returned_tm: 

5915 if tm.team_id == team.team_id: 

5916 _team_memberships.append(tm) 

5917 

5918 # add all keys that belong to the team 

5919 keys = _as_list(await _tokens_db(prisma_client).find_many(where={"team_id": team.team_id})) 

5920 

5921 try: 

5922 returned_responses.append( 

5923 TeamListResponseObject( 

5924 **team.model_dump(), 

5925 team_memberships=_team_memberships, 

5926 keys=keys, 

5927 ) 

5928 ) 

5929 except Exception as e: 

5930 team_exception = f"""Invalid team object for team_id: {team.team_id}. team_object={team.model_dump()}. 

5931 Error: {e} 

5932 """ 

5933 verbose_proxy_logger.exception(team_exception) 

5934 continue 

5935 # Sort the responses by team_alias 

5936 returned_responses.sort(key=lambda x: getattr(x, "team_alias", "") or "") 

5937 

5938 if organization_id is not None: 

5939 if organization_id == SpecialManagementEndpointEnums.DEFAULT_ORGANIZATION.value: 5939 ↛ 5940line 5939 didn't jump to line 5940 because the condition on line 5939 was never true

5940 returned_responses = [team for team in returned_responses if team.organization_id is None] 

5941 else: 

5942 returned_responses = [team for team in returned_responses if team.organization_id == organization_id] 

5943 

5944 return returned_responses 

5945 

5946 

5947async def get_paginated_teams( 

5948 prisma_client: PrismaClient, 

5949 page_size: int = 10, 

5950 page: int = 1, 

5951) -> tuple[list[LiteLLM_TeamTable], int]: 

5952 """ 

5953 Get paginated list of teams from team table 

5954 

5955 Parameters: 

5956 prisma_client: PrismaClient - The database client 

5957 page_size: int - Number of teams per page 

5958 page: int - Page number (1-based) 

5959 

5960 Returns: 

5961 Tuple[List[LiteLLM_TeamTable], int] - (list of teams, total count) 

5962 """ 

5963 try: 

5964 # Calculate skip for pagination 

5965 skip: Final = (page - 1) * page_size 

5966 # Get total count 

5967 total_count: Final = await _team_db(prisma_client).count() 

5968 

5969 # Get paginated teams 

5970 teams: Final = await _team_db(prisma_client).find_many( 

5971 skip=skip, 

5972 take=page_size, 

5973 order={"team_alias": "asc"}, # Sort by team_alias 

5974 ) 

5975 return teams, total_count 

5976 except Exception as e: 

5977 verbose_proxy_logger.exception("[Non-Blocking] Error getting paginated teams: %s", e) 

5978 return [], 0 

5979 

5980 

5981@router.get( 

5982 "/team/filter/ui", 

5983 tags=["team management"], 

5984 dependencies=[Depends(user_api_key_auth)], 

5985 include_in_schema=False, 

5986 responses={ 

5987 200: {"model": list[LiteLLM_TeamTable]}, 

5988 }, 

5989) 

5990async def ui_view_teams( 

5991 team_id: str | None = fastapi.Query(default=None, description="Team ID in the request parameters"), 

5992 team_alias: str | None = fastapi.Query(default=None, description="Team alias in the request parameters"), 

5993 page: int = fastapi.Query(default=1, description="Page number for pagination", ge=1), 

5994 page_size: int = fastapi.Query(default=50, description="Number of items per page", ge=1, le=100), 

5995 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), 

5996): 

5997 """ 

5998 [PROXY-ADMIN ONLY] Filter teams based on partial match of team_id or team_alias with pagination. 

5999 

6000 Args: 

6001 user_id (Optional[str]): Partial user ID to search for 

6002 user_email (Optional[str]): Partial email to search for 

6003 page (int): Page number for pagination (starts at 1) 

6004 page_size (int): Number of items per page (max 100) 

6005 user_api_key_dict (UserAPIKeyAuth): User authentication information 

6006 

6007 Returns: 

6008 List[LiteLLM_SpendLogs]: Paginated list of matching user records 

6009 """ 

6010 from litellm.proxy.proxy_server import prisma_client 

6011 

6012 if prisma_client is None: 

6013 raise HTTPException(status_code=500, detail={"error": "No db connected"}) 

6014 

6015 try: 

6016 # Calculate offset for pagination 

6017 skip: Final = (page - 1) * page_size 

6018 

6019 # Build where conditions based on provided parameters 

6020 where_conditions: Final[_TeamUiViewFilters] = {} 

6021 

6022 if team_id: 

6023 where_conditions["team_id"] = { 

6024 "contains": team_id, 

6025 "mode": "insensitive", # Case-insensitive search 

6026 } 

6027 

6028 if team_alias: 

6029 where_conditions["team_alias"] = { 

6030 "contains": team_alias, 

6031 "mode": "insensitive", # Case-insensitive search 

6032 } 

6033 

6034 # Query users with pagination and filters 

6035 teams: Final = await _team_db(prisma_client).find_many( 

6036 where=where_conditions, 

6037 skip=skip, 

6038 take=page_size, 

6039 order={"created_at": "desc"}, 

6040 ) 

6041 

6042 if not teams: 

6043 return [] 

6044 

6045 return teams 

6046 

6047 except Exception as e: 

6048 raise HTTPException(status_code=500, detail=f"Error searching teams: {e}") 

6049 

6050 

6051def add_new_models_to_team(team_obj: LiteLLM_TeamTable, new_models: list[str]) -> list[str]: 

6052 """ 

6053 Add new models to a team's allowed model list. 

6054 """ 

6055 current_models = team_obj.models 

6056 if current_models is not None and len(current_models) == 0: # implies all model access 

6057 current_models = [SpecialModelNames.all_proxy_models.value] 

6058 else: 

6059 current_models = team_obj.models 

6060 updated_models: Final = list(set(current_models + new_models)) 

6061 return updated_models 

6062 

6063 

6064@router.post( 

6065 "/team/model/add", 

6066 tags=["team management"], 

6067 dependencies=[Depends(user_api_key_auth)], 

6068) 

6069@management_endpoint_wrapper 

6070async def team_model_add( 

6071 data: TeamModelAddRequest, 

6072 http_request: Request, 

6073 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), 

6074): 

6075 """ 

6076 Add models to a team's allowed model list. Only proxy admin or team admin can add models. 

6077 

6078 Parameters: 

6079 - team_id: str - Required. The team to add models to 

6080 - models: List[str] - Required. List of models to add to the team 

6081 

6082 Example Request: 

6083 ``` 

6084 curl --location 'http://0.0.0.0:4000/team/model/add' \ 

6085 --header 'Authorization: Bearer sk-1234' \ 

6086 --header 'Content-Type: application/json' \ 

6087 --data '{ 

6088 "team_id": "team-1234", 

6089 "models": ["gpt-4", "claude-2"] 

6090 }' 

6091 ``` 

6092 """ 

6093 from litellm.proxy.proxy_server import ( 

6094 prisma_client, 

6095 proxy_logging_obj, 

6096 user_api_key_cache, 

6097 ) 

6098 

6099 if prisma_client is None: 6099 ↛ 6100line 6099 didn't jump to line 6100 because the condition on line 6099 was never true

6100 raise HTTPException(status_code=500, detail={"error": "No db connected"}) 

6101 

6102 # Get existing team 

6103 team_row: Final = await _team_db(prisma_client).find_unique(where={"team_id": data.team_id}) 

6104 

6105 if team_row is None: 

6106 raise HTTPException( 

6107 status_code=404, 

6108 detail={"error": f"Team not found, passed team_id={data.team_id}"}, 

6109 ) 

6110 

6111 team_obj: Final = LiteLLM_TeamTable.model_validate(team_row.model_dump()) 

6112 

6113 # Authorization check - only proxy admin, team admin, or org admin can add models 

6114 if ( 6114 ↛ 6119line 6114 didn't jump to line 6119 because the condition on line 6114 was never true

6115 user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value 

6116 and not _is_user_team_admin(user_api_key_dict=user_api_key_dict, team_obj=team_obj) 

6117 and not await _is_user_org_admin_for_team(user_api_key_dict=user_api_key_dict, team_obj=team_obj) 

6118 ): 

6119 raise HTTPException( 

6120 status_code=403, 

6121 detail={"error": "Only proxy admin or team admin can modify team models"}, 

6122 ) 

6123 

6124 return await append_team_models( 

6125 data=data, 

6126 prisma_client=prisma_client, 

6127 user_api_key_cache=user_api_key_cache, 

6128 proxy_logging_obj=proxy_logging_obj, 

6129 ) 

6130 

6131 

6132async def append_team_models( 

6133 *, 

6134 data: TeamModelAddRequest, 

6135 prisma_client: PrismaClient, 

6136 user_api_key_cache: UserApiKeyCache, 

6137 proxy_logging_obj: ProxyLogging, 

6138) -> "prisma_models.LiteLLM_TeamTable": 

6139 # Atomic array append with dedup at the database level so concurrent 

6140 # BYOK model creates don't overwrite each other's team.models entries. 

6141 # When the team currently has models=[] (unrestricted access), the 

6142 # CASE expression inserts the 'all-proxy-models' sentinel first. 

6143 models_to_add: Final = list(data.models) 

6144 await prisma_client.db.execute_raw( 

6145 'UPDATE "LiteLLM_TeamTable" ' 

6146 "SET models = (" 

6147 " SELECT ARRAY(SELECT DISTINCT unnest(" 

6148 " CASE WHEN cardinality(COALESCE(models, ARRAY[]::text[])) = 0 " 

6149 " THEN ARRAY['all-proxy-models']::text[] " 

6150 " ELSE models " 

6151 " END || $1::text[]" 

6152 " ))" 

6153 ") " 

6154 "WHERE team_id = $2", 

6155 models_to_add, 

6156 data.team_id, 

6157 ) 

6158 # Re-fetch via update (write-routed) instead of find_unique (read-routed) 

6159 # to avoid returning stale data from a read replica. The models column 

6160 # was already set by execute_raw above; this just retrieves the row from 

6161 # the writer and lets Prisma bump updated_at. 

6162 # `include` mirrors the relations the auth path consumes off the cached 

6163 # team object so that `_refresh_cached_team` doesn't null them out. 

6164 updated_team: Final = await _team_db(prisma_client).update( 

6165 where={"team_id": data.team_id}, 

6166 data={"updated_at": datetime.now(timezone.utc)}, 

6167 include={"litellm_model_table": True, "object_permission": True}, 

6168 ) 

6169 if updated_team is None: 6169 ↛ 6170line 6169 didn't jump to line 6170 because the condition on line 6169 was never true

6170 raise HTTPException( 

6171 status_code=404, 

6172 detail={"error": f"Team not found, passed team_id={data.team_id}"}, 

6173 ) 

6174 

6175 await _refresh_cached_team( 

6176 team_row=updated_team, 

6177 user_api_key_cache=user_api_key_cache, 

6178 proxy_logging_obj=proxy_logging_obj, 

6179 ) 

6180 

6181 return updated_team 

6182 

6183 

6184@router.post( 

6185 "/team/model/delete", 

6186 tags=["team management"], 

6187 dependencies=[Depends(user_api_key_auth)], 

6188) 

6189@management_endpoint_wrapper 

6190async def team_model_delete( 

6191 data: TeamModelDeleteRequest, 

6192 http_request: Request, 

6193 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), 

6194): 

6195 """ 

6196 Remove models from a team's allowed model list. Only proxy admin or team admin can remove models. 

6197 

6198 Parameters: 

6199 - team_id: str - Required. The team to remove models from 

6200 - models: List[str] - Required. List of models to remove from the team 

6201 

6202 Example Request: 

6203 ``` 

6204 curl --location 'http://0.0.0.0:4000/team/model/delete' \ 

6205 --header 'Authorization: Bearer sk-1234' \ 

6206 --header 'Content-Type: application/json' \ 

6207 --data '{ 

6208 "team_id": "team-1234", 

6209 "models": ["gpt-4"] 

6210 }' 

6211 ``` 

6212 """ 

6213 from litellm.proxy.proxy_server import ( 

6214 prisma_client, 

6215 proxy_logging_obj, 

6216 user_api_key_cache, 

6217 ) 

6218 

6219 if prisma_client is None: 6219 ↛ 6220line 6219 didn't jump to line 6220 because the condition on line 6219 was never true

6220 raise HTTPException(status_code=500, detail={"error": "No db connected"}) 

6221 

6222 # Get existing team 

6223 team_row: Final = await _team_db(prisma_client).find_unique(where={"team_id": data.team_id}) 

6224 

6225 if team_row is None: 

6226 raise HTTPException( 

6227 status_code=404, 

6228 detail={"error": f"Team not found, passed team_id={data.team_id}"}, 

6229 ) 

6230 

6231 team_obj: Final = LiteLLM_TeamTable.model_validate(team_row.model_dump()) 

6232 

6233 # Authorization check - only proxy admin, team admin, or org admin can remove models 

6234 if ( 6234 ↛ 6239line 6234 didn't jump to line 6239 because the condition on line 6234 was never true

6235 user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value 

6236 and not _is_user_team_admin(user_api_key_dict=user_api_key_dict, team_obj=team_obj) 

6237 and not await _is_user_org_admin_for_team(user_api_key_dict=user_api_key_dict, team_obj=team_obj) 

6238 ): 

6239 raise HTTPException( 

6240 status_code=403, 

6241 detail={"error": "Only proxy admin or team admin can modify team models"}, 

6242 ) 

6243 

6244 # Get current models list 

6245 current_models: Final[Sequence[str]] = team_obj.models or [] 

6246 

6247 # Remove specified models 

6248 updated_models: Final = [m for m in current_models if m not in data.models] 

6249 

6250 # Update team. See team_model_add for the rationale on `include`. 

6251 updated_team: Final = await _team_db(prisma_client).update( 

6252 where={"team_id": data.team_id}, 

6253 data={"models": updated_models}, 

6254 include={"litellm_model_table": True, "object_permission": True}, 

6255 ) 

6256 if updated_team is None: 6256 ↛ 6257line 6256 didn't jump to line 6257 because the condition on line 6256 was never true

6257 raise HTTPException( 

6258 status_code=404, 

6259 detail={"error": f"Team not found, passed team_id={data.team_id}"}, 

6260 ) 

6261 

6262 await _refresh_cached_team( 

6263 team_row=updated_team, 

6264 user_api_key_cache=user_api_key_cache, 

6265 proxy_logging_obj=proxy_logging_obj, 

6266 ) 

6267 

6268 return updated_team 

6269 

6270 

6271@router.get( 

6272 "/team/permissions_list", 

6273 tags=["team management"], 

6274 dependencies=[Depends(user_api_key_auth)], 

6275) 

6276@management_endpoint_wrapper 

6277async def team_member_permissions( 

6278 team_id: str = fastapi.Query(default=None, description="Team ID in the request parameters"), 

6279 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), 

6280) -> GetTeamMemberPermissionsResponse: 

6281 """ 

6282 Get the team member permissions for a team 

6283 """ 

6284 from litellm.proxy.proxy_server import ( 

6285 prisma_client, 

6286 proxy_logging_obj, 

6287 user_api_key_cache, 

6288 ) 

6289 

6290 if prisma_client is None: 6290 ↛ 6291line 6290 didn't jump to line 6291 because the condition on line 6290 was never true

6291 raise HTTPException(status_code=500, detail={"error": "No db connected"}) 

6292 

6293 ## CHECK IF USER IS PROXY ADMIN OR TEAM ADMIN OR ORG ADMIN 

6294 existing_team_row: Final = await get_team_object( 

6295 team_id=team_id, 

6296 prisma_client=prisma_client, 

6297 user_api_key_cache=user_api_key_cache, 

6298 parent_otel_span=None, 

6299 proxy_logging_obj=proxy_logging_obj, 

6300 check_cache_only=False, 

6301 check_db_only=True, 

6302 ) 

6303 

6304 complete_team_data: Final = LiteLLM_TeamTable.model_validate(existing_team_row.model_dump()) 

6305 

6306 # Admin Viewer follows the read-parity rule: see team permissions like 

6307 # a Proxy Admin would. Team / org admins keep their existing scope. 

6308 if ( 6308 ↛ 6318line 6308 didn't jump to line 6318 because the condition on line 6308 was never true

6309 hasattr(user_api_key_dict, "user_role") 

6310 and not _user_has_admin_view(user_api_key_dict) 

6311 and not _is_user_team_admin(user_api_key_dict=user_api_key_dict, team_obj=complete_team_data) 

6312 and not await _is_user_org_admin_for_team(user_api_key_dict=user_api_key_dict, team_obj=complete_team_data) 

6313 and not _is_available_team( 

6314 team_id=complete_team_data.team_id, 

6315 user_api_key_dict=user_api_key_dict, 

6316 ) 

6317 ): 

6318 raise HTTPException( 

6319 status_code=403, 

6320 detail={ 

6321 "error": "Call not allowed. User not proxy admin OR team admin. route={}, team_id={}".format( 

6322 "/team/member_add", 

6323 complete_team_data.team_id, 

6324 ) 

6325 }, 

6326 ) 

6327 

6328 if existing_team_row.team_member_permissions is None: 6328 ↛ 6329line 6328 didn't jump to line 6329 because the condition on line 6328 was never true

6329 existing_team_row.team_member_permissions = TeamMemberPermissionChecks.default_team_member_permissions() 

6330 

6331 return GetTeamMemberPermissionsResponse( 

6332 team_id=team_id, 

6333 team_member_permissions=existing_team_row.team_member_permissions, 

6334 all_available_permissions=TeamMemberPermissionChecks.get_all_available_team_member_permissions(), 

6335 ) 

6336 

6337 

6338@router.post( 

6339 "/team/permissions_update", 

6340 tags=["team management"], 

6341 dependencies=[Depends(user_api_key_auth)], 

6342) 

6343async def update_team_member_permissions( 

6344 data: UpdateTeamMemberPermissionsRequest, 

6345 http_request: Request, 

6346 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), 

6347) -> LiteLLM_TeamTable: 

6348 """ 

6349 Update the team member permissions for a team 

6350 """ 

6351 from litellm.proxy.proxy_server import ( 

6352 prisma_client, 

6353 proxy_logging_obj, 

6354 user_api_key_cache, 

6355 ) 

6356 

6357 if prisma_client is None: 6357 ↛ 6358line 6357 didn't jump to line 6358 because the condition on line 6357 was never true

6358 raise HTTPException(status_code=500, detail={"error": "No db connected"}) 

6359 

6360 ## CHECK IF USER IS PROXY ADMIN OR TEAM ADMIN OR ORG ADMIN 

6361 existing_team_row: Final = await get_team_object( 

6362 team_id=data.team_id, 

6363 prisma_client=prisma_client, 

6364 user_api_key_cache=user_api_key_cache, 

6365 parent_otel_span=None, 

6366 proxy_logging_obj=proxy_logging_obj, 

6367 check_cache_only=False, 

6368 check_db_only=True, 

6369 ) 

6370 

6371 complete_team_data: Final = LiteLLM_TeamTable.model_validate(existing_team_row.model_dump()) 

6372 

6373 # Available-team self-join must NOT grant write access to team-wide 

6374 # permission policies; only proxy/team/org admins can update them. 

6375 if ( 6375 ↛ 6381line 6375 didn't jump to line 6381 because the condition on line 6375 was never true

6376 hasattr(user_api_key_dict, "user_role") 

6377 and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value 

6378 and not _is_user_team_admin(user_api_key_dict=user_api_key_dict, team_obj=complete_team_data) 

6379 and not await _is_user_org_admin_for_team(user_api_key_dict=user_api_key_dict, team_obj=complete_team_data) 

6380 ): 

6381 raise HTTPException( 

6382 status_code=403, 

6383 detail={ 

6384 "error": "Call not allowed. User not proxy admin OR team admin. route={}, team_id={}".format( 

6385 "/team/permissions_update", 

6386 complete_team_data.team_id, 

6387 ) 

6388 }, 

6389 ) 

6390 # Update the team member permissions 

6391 updated_team: Final = await _team_db(prisma_client).update( 

6392 where={"team_id": data.team_id}, 

6393 data={"team_member_permissions": data.team_member_permissions}, 

6394 ) 

6395 if updated_team is None: 6395 ↛ 6396line 6395 didn't jump to line 6396 because the condition on line 6395 was never true

6396 raise HTTPException( 

6397 status_code=404, 

6398 detail={"error": f"Team not found, passed team_id={data.team_id}"}, 

6399 ) 

6400 

6401 return updated_team # pyright: ignore[reportReturnType] # prisma row, coerced by this route's response_model 

6402 

6403 

6404@router.post( 

6405 "/team/permissions_bulk_update", 

6406 tags=["team management"], 

6407 dependencies=[Depends(user_api_key_auth)], 

6408 response_model=BulkUpdateTeamMemberPermissionsResponse, 

6409) 

6410@management_endpoint_wrapper 

6411async def bulk_update_team_member_permissions( 

6412 data: BulkUpdateTeamMemberPermissionsRequest, 

6413 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), 

6414): 

6415 """ 

6416 Append permissions to existing teams. 

6417 

6418 Either pass team_ids to target specific teams, or set 

6419 apply_to_all_teams=True to update every team. For each team, 

6420 the provided permissions are merged with the team's existing 

6421 permissions (duplicates are skipped). 

6422 """ 

6423 from litellm.proxy.proxy_server import prisma_client 

6424 

6425 if prisma_client is None: 6425 ↛ 6426line 6425 didn't jump to line 6426 because the condition on line 6425 was never true

6426 raise HTTPException(status_code=500, detail={"error": "No db connected"}) 

6427 

6428 if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value: 6428 ↛ 6429line 6428 didn't jump to line 6429 because the condition on line 6428 was never true

6429 raise HTTPException( 

6430 status_code=403, 

6431 detail={"error": "Only proxy admins can bulk-update team permissions"}, 

6432 ) 

6433 

6434 if not data.permissions: 

6435 return { 

6436 "message": "No permissions provided", 

6437 "teams_updated": 0, 

6438 } 

6439 

6440 if not data.apply_to_all_teams and not data.team_ids: 

6441 raise HTTPException( 

6442 status_code=400, 

6443 detail={"error": "Must provide team_ids or set apply_to_all_teams=true"}, 

6444 ) 

6445 

6446 if data.apply_to_all_teams and data.team_ids: 

6447 raise HTTPException( 

6448 status_code=400, 

6449 detail={"error": "Cannot set both apply_to_all_teams=true and team_ids"}, 

6450 ) 

6451 

6452 permissions_to_add: Final = set(data.permissions) 

6453 

6454 if data.team_ids: 

6455 teams_updated = await _append_permissions_to_specific_teams(prisma_client, data.team_ids, permissions_to_add) 

6456 else: 

6457 teams_updated = await _append_permissions_to_all_teams(prisma_client, permissions_to_add) 

6458 

6459 return { 

6460 "message": "Team permissions updated successfully", 

6461 "teams_updated": teams_updated, 

6462 "permissions_appended": data.permissions, 

6463 } 

6464 

6465 

6466async def _compute_and_batch_updates( 

6467 prisma_client, teams: "Sequence[prisma_models.LiteLLM_TeamTable]", permissions_to_add: set 

6468) -> int: 

6469 """Compute merged permissions and batch-write updates. Returns count of teams updated.""" 

6470 updates: Final = [] 

6471 for team in teams: 

6472 existing = set(team.team_member_permissions or []) 

6473 if permissions_to_add <= existing: 

6474 continue 

6475 merged = sorted(existing | permissions_to_add) # normalise to alphabetical order 

6476 updates.append((team.team_id, merged)) 

6477 

6478 if updates: 

6479 batcher: Final = prisma_client.db.batch_() 

6480 for team_id, merged_perms in updates: 

6481 batcher.litellm_teamtable.update( 

6482 where={"team_id": team_id}, 

6483 data={"team_member_permissions": merged_perms}, 

6484 ) 

6485 await batcher.commit() 

6486 

6487 return len(updates) 

6488 

6489 

6490async def _append_permissions_to_specific_teams( 

6491 prisma_client: PrismaClient, team_ids: list[str], permissions_to_add: set 

6492) -> int: 

6493 """Fetch specific teams by ID and append permissions.""" 

6494 teams: Final = await _team_db(prisma_client).find_many( 

6495 where={"team_id": {"in": team_ids}}, 

6496 ) 

6497 

6498 found_ids: Final = {team.team_id for team in teams} 

6499 missing_ids: Final = set(team_ids) - found_ids 

6500 if missing_ids: 6500 ↛ 6506line 6500 didn't jump to line 6506 because the condition on line 6500 was always true

6501 raise HTTPException( 

6502 status_code=404, 

6503 detail={"error": f"Team(s) not found: {sorted(missing_ids)}"}, 

6504 ) 

6505 

6506 return await _compute_and_batch_updates(prisma_client, teams, permissions_to_add) 

6507 

6508 

6509async def _append_permissions_to_all_teams(prisma_client: PrismaClient, permissions_to_add: set) -> int: 

6510 """Paginated read + batched write across all teams.""" 

6511 teams_updated = 0 

6512 cursor = None 

6513 BATCH_SIZE: Final = 500 

6514 

6515 while True: 

6516 find_args: _TeamFindManyArgs = { 

6517 "take": BATCH_SIZE, 

6518 "order": {"team_id": "asc"}, 

6519 } 

6520 if cursor is not None: 6520 ↛ 6521line 6520 didn't jump to line 6521 because the condition on line 6520 was never true

6521 find_args["cursor"] = {"team_id": cursor} 

6522 find_args["skip"] = 1 

6523 

6524 teams = await _team_db(prisma_client).find_many(**find_args) 

6525 

6526 if not teams: 6526 ↛ 6527line 6526 didn't jump to line 6527 because the condition on line 6526 was never true

6527 break 

6528 

6529 teams_updated += await _compute_and_batch_updates(prisma_client, teams, permissions_to_add) 

6530 

6531 cursor = teams[-1].team_id 

6532 

6533 if len(teams) < BATCH_SIZE: 6533 ↛ 6515line 6533 didn't jump to line 6515 because the condition on line 6533 was always true

6534 break 

6535 

6536 return teams_updated 

6537 

6538 

6539def _daily_activity_error(*, status_code: int, message: str) -> HTTPException: 

6540 """Single construction site for the `{"error": ...}` detail shape the 

6541 /team/daily/activity endpoints have always returned.""" 

6542 return HTTPException(status_code=status_code, detail={"error": message}) # mutable-ok: FastAPI JSON detail 

6543 

6544 

6545class _TeamDailyActivityScope(NamedTuple): 

6546 team_ids: list[str] | None # mutable-ok: downstream daily-activity signatures take str | list unions 

6547 exclude_team_ids: list[str] | None # mutable-ok: downstream daily-activity signatures take str | list unions 

6548 team_alias_metadata: dict[str, dict[str, object]] # mutable-ok: entity_metadata_field shape 

6549 api_key_filter: str | list[str] | None # mutable-ok: downstream daily-activity signatures take str | list unions 

6550 

6551 

6552async def _resolve_team_daily_activity_scope( 

6553 *, 

6554 team_ids: str | None, 

6555 exclude_team_ids: str | None, 

6556 api_key: str | None, 

6557 user_api_key_dict: UserAPIKeyAuth, 

6558 prisma_client: PrismaClient, 

6559 user_api_key_cache: UserApiKeyCache, 

6560 proxy_logging_obj: ProxyLogging, 

6561) -> _TeamDailyActivityScope: 

6562 """Resolve which teams the caller may see and whether results must be 

6563 narrowed to their own API keys. Shared by the paginated and aggregated 

6564 /team/daily/activity endpoints so both enforce identical permissions.""" 

6565 # Convert comma-separated tags string to list if provided 

6566 team_ids_list = team_ids.split(",") if team_ids else None 

6567 exclude_team_ids_list: list[str] | None = None 

6568 

6569 if exclude_team_ids: 

6570 exclude_team_ids_list = exclude_team_ids.split(",") if exclude_team_ids else None 

6571 

6572 if not _user_has_admin_view(user_api_key_dict): 6572 ↛ 6573line 6572 didn't jump to line 6573 because the condition on line 6572 was never true

6573 user_info: Final = await get_user_object( 

6574 user_id=user_api_key_dict.user_id, 

6575 prisma_client=prisma_client, 

6576 user_id_upsert=False, 

6577 user_api_key_cache=user_api_key_cache, 

6578 parent_otel_span=user_api_key_dict.parent_otel_span, 

6579 proxy_logging_obj=proxy_logging_obj, 

6580 check_db_only=True, 

6581 ) 

6582 if user_info is None: 

6583 raise _daily_activity_error(status_code=404, message=f"User= {user_api_key_dict.user_id} not found") 

6584 

6585 if team_ids_list is None: 

6586 team_ids_list = user_info.teams 

6587 else: 

6588 # check if all team_ids are in user_info.teams 

6589 for team_id in team_ids_list: 

6590 if team_id not in user_info.teams: 

6591 raise _daily_activity_error( 

6592 status_code=404, 

6593 message=f"User does not belong to Team= {team_id}. Call `/user/info` to see user's teams", 

6594 ) 

6595 

6596 ## Fetch team aliases and check team admin status 

6597 where_condition: Final[_TeamIdInFilter] = {} 

6598 if team_ids_list: 

6599 where_condition["team_id"] = {"in": list(team_ids_list)} 

6600 team_aliases: Final = await _team_db(prisma_client).find_many(where=where_condition) 

6601 team_alias_metadata: Final = {t.team_id: {"team_alias": _as_object(t.team_alias)} for t in team_aliases} 

6602 

6603 # Check if user is team admin or has /team/daily/activity permission 

6604 # If not, filter by user's API keys. 

6605 # 

6606 # Earlier this loop used `any-team admin -> set has_full_team_view=True 

6607 # for the entire request`, so an admin of one team that requested 

6608 # data for several teams would see API-key-level breakdowns for all 

6609 # of them. Require full view on EVERY requested team — if the caller 

6610 # only has admin/permission for a strict subset, fall back to 

6611 # filtering the entire response by their own API keys (they can re- 

6612 # request the admin-only teams separately to get the wider view). 

6613 user_api_keys: list[str] | None = None 

6614 if not _user_has_admin_view(user_api_key_dict) and team_ids_list and team_aliases: 6614 ↛ 6615line 6614 didn't jump to line 6615 because the condition on line 6614 was never true

6615 has_full_team_view = True 

6616 for team_alias in team_aliases: 

6617 team_obj = LiteLLM_TeamTable.model_validate(team_alias.model_dump()) 

6618 is_admin = _is_user_team_admin(user_api_key_dict=user_api_key_dict, team_obj=team_obj) 

6619 has_perm = _team_member_has_permission( 

6620 user_api_key_dict=user_api_key_dict, 

6621 team_obj=team_obj, 

6622 permission="/team/daily/activity", 

6623 ) 

6624 if not (is_admin or has_perm): 

6625 has_full_team_view = False 

6626 break 

6627 

6628 # If user does not have full team view, filter by their API keys 

6629 if not has_full_team_view: 

6630 # Get all API keys for this user 

6631 user_keys: Final = await _tokens_db(prisma_client).find_many(where={"user_id": user_api_key_dict.user_id}) 

6632 user_api_keys = [key.token for key in user_keys if key.token] 

6633 # If user has no API keys, return empty result 

6634 if not user_api_keys: 

6635 user_api_keys = [""] # Use empty string to ensure no matches 

6636 

6637 # If api_key parameter is provided, use it; otherwise use user_api_keys if set 

6638 final_api_key_filter: str | list[str] | None = api_key 

6639 if final_api_key_filter is None and user_api_keys is not None: 6639 ↛ 6640line 6639 didn't jump to line 6640 because the condition on line 6639 was never true

6640 final_api_key_filter = user_api_keys 

6641 

6642 return _TeamDailyActivityScope( 

6643 team_ids=team_ids_list, 

6644 exclude_team_ids=exclude_team_ids_list, 

6645 team_alias_metadata=team_alias_metadata, 

6646 api_key_filter=final_api_key_filter, 

6647 ) 

6648 

6649 

6650@router.get( 

6651 "/team/daily/activity", 

6652 response_model=SpendAnalyticsPaginatedResponse, 

6653 tags=["team management"], 

6654) 

6655async def get_team_daily_activity( 

6656 user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], 

6657 team_ids: str | None = None, 

6658 start_date: str | None = None, 

6659 end_date: str | None = None, 

6660 model: str | None = None, 

6661 api_key: str | None = None, 

6662 page: int = 1, 

6663 page_size: int = 10, 

6664 exclude_team_ids: str | None = None, 

6665): 

6666 """ 

6667 Get daily activity for specific teams or all teams. 

6668 

6669 Args: 

6670 team_ids (Optional[str]): Comma-separated list of team IDs to filter by. If not provided, returns data for all teams. 

6671 start_date (Optional[str]): Start date for the activity period (YYYY-MM-DD). 

6672 end_date (Optional[str]): End date for the activity period (YYYY-MM-DD). 

6673 model (Optional[str]): Filter by model name. 

6674 api_key (Optional[str]): Filter by API key. 

6675 page (int): Page number for pagination. 

6676 page_size (int): Number of items per page. 

6677 exclude_team_ids (Optional[str]): Comma-separated list of team IDs to exclude. 

6678 Returns: 

6679 SpendAnalyticsPaginatedResponse: Paginated response containing daily activity data. 

6680 """ 

6681 from litellm.proxy.proxy_server import ( 

6682 prisma_client, 

6683 proxy_logging_obj, 

6684 user_api_key_cache, 

6685 ) 

6686 

6687 if prisma_client is None: 6687 ↛ 6688line 6687 didn't jump to line 6688 because the condition on line 6687 was never true

6688 raise _daily_activity_error(status_code=500, message=CommonProxyErrors.db_not_connected_error.value) 

6689 

6690 scope: Final = await _resolve_team_daily_activity_scope( 

6691 team_ids=team_ids, 

6692 exclude_team_ids=exclude_team_ids, 

6693 api_key=api_key, 

6694 user_api_key_dict=user_api_key_dict, 

6695 prisma_client=prisma_client, 

6696 user_api_key_cache=user_api_key_cache, 

6697 proxy_logging_obj=proxy_logging_obj, 

6698 ) 

6699 

6700 return await get_daily_activity( 

6701 prisma_client=prisma_client, 

6702 table_name="litellm_dailyteamspend", 

6703 entity_id_field="team_id", 

6704 entity_id=scope.team_ids, 

6705 entity_metadata_field=scope.team_alias_metadata, 

6706 exclude_entity_ids=scope.exclude_team_ids, 

6707 start_date=start_date, 

6708 end_date=end_date, 

6709 model=model, 

6710 api_key=scope.api_key_filter, 

6711 page=page, 

6712 page_size=page_size, 

6713 ) 

6714 

6715 

6716_MAX_AGGREGATED_RANGE_DAYS: Final = 400 

6717 

6718 

6719def _aggregated_date_range_error(start_date: str | None, end_date: str | None) -> str | None: 

6720 """The aggregated endpoint has no pagination to bound its work, so malformed 

6721 dates and ranges wider than the UI ever requests are rejected before querying.""" 

6722 if start_date is None or end_date is None: 

6723 return "Please provide start_date and end_date" 

6724 try: 

6725 parsed_start: Final = datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=timezone.utc) 

6726 parsed_end: Final = datetime.strptime(end_date, "%Y-%m-%d").replace(tzinfo=timezone.utc) 

6727 except ValueError: 

6728 return "start_date and end_date must be valid YYYY-MM-DD dates" 

6729 if parsed_end < parsed_start: 6729 ↛ 6730line 6729 didn't jump to line 6730 because the condition on line 6729 was never true

6730 return "end_date must be on or after start_date" 

6731 if (parsed_end - parsed_start).days > _MAX_AGGREGATED_RANGE_DAYS: 6731 ↛ 6732line 6731 didn't jump to line 6732 because the condition on line 6731 was never true

6732 return f"Date range must be at most {_MAX_AGGREGATED_RANGE_DAYS} days" 

6733 return None 

6734 

6735 

6736@router.get( 

6737 "/team/daily/activity/aggregated", 

6738 response_model=SpendAnalyticsPaginatedResponse, 

6739 tags=["team management"], 

6740) 

6741async def get_team_daily_activity_aggregated( 

6742 user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], 

6743 team_ids: str | None = None, 

6744 start_date: str | None = None, 

6745 end_date: str | None = None, 

6746 model: str | None = None, 

6747 api_key: str | None = None, 

6748 exclude_team_ids: str | None = None, 

6749 timezone: int | None = None, 

6750): 

6751 """ 

6752 Aggregated daily activity for teams without pagination, including per-team breakdown. 

6753 

6754 One SQL GROUPING SETS pass returns every day in the range regardless of row 

6755 volume, so callers never reassemble pages. Same response shape as the 

6756 paginated endpoint with page metadata pinned to a single page. 

6757 

6758 Args: 

6759 team_ids (Optional[str]): Comma-separated list of team IDs to filter by. If not provided, returns data for all teams. 

6760 start_date (Optional[str]): Start date for the activity period (YYYY-MM-DD). 

6761 end_date (Optional[str]): End date for the activity period (YYYY-MM-DD). 

6762 model (Optional[str]): Filter by model name. 

6763 api_key (Optional[str]): Filter by API key. 

6764 exclude_team_ids (Optional[str]): Comma-separated list of team IDs to exclude. 

6765 timezone (Optional[int]): Timezone offset in minutes from UTC, matching JavaScript's Date.getTimezoneOffset() convention. 

6766 Returns: 

6767 SpendAnalyticsPaginatedResponse: Response containing all daily activity data for the range. 

6768 """ 

6769 from litellm.proxy.proxy_server import ( 

6770 prisma_client, 

6771 proxy_logging_obj, 

6772 user_api_key_cache, 

6773 ) 

6774 

6775 if prisma_client is None: 6775 ↛ 6776line 6775 didn't jump to line 6776 because the condition on line 6775 was never true

6776 raise _daily_activity_error(status_code=500, message=CommonProxyErrors.db_not_connected_error.value) 

6777 

6778 range_error: Final = _aggregated_date_range_error(start_date, end_date) 

6779 if range_error is not None: 

6780 raise _daily_activity_error(status_code=400, message=range_error) 

6781 

6782 scope: Final = await _resolve_team_daily_activity_scope( 

6783 team_ids=team_ids, 

6784 exclude_team_ids=exclude_team_ids, 

6785 api_key=api_key, 

6786 user_api_key_dict=user_api_key_dict, 

6787 prisma_client=prisma_client, 

6788 user_api_key_cache=user_api_key_cache, 

6789 proxy_logging_obj=proxy_logging_obj, 

6790 ) 

6791 

6792 return await get_daily_activity_aggregated( 

6793 prisma_client=prisma_client, 

6794 table_name="litellm_dailyteamspend", 

6795 entity_id_field="team_id", 

6796 entity_id=scope.team_ids, 

6797 entity_metadata_field=scope.team_alias_metadata, 

6798 start_date=start_date, 

6799 end_date=end_date, 

6800 model=model, 

6801 api_key=scope.api_key_filter, 

6802 exclude_entity_ids=scope.exclude_team_ids, 

6803 timezone_offset_minutes=timezone, 

6804 include_entity_breakdown=True, 

6805 ) 

6806 

6807 

6808def _team_user_spend_sql(*, team_count: int, restrict_to_user: bool) -> str: 

6809 team_placeholders: Final = ", ".join(f"${i}" for i in range(3, 3 + team_count)) 

6810 user_clause: Final = f' AND sl."user" = ${3 + team_count}' if restrict_to_user else "" 

6811 return f""" 

6812 SELECT 

6813 sl.team_id, 

6814 sl."user" AS user_id, 

6815 u.user_email, 

6816 u.user_alias, 

6817 SUM(sl.spend)::float AS spend, 

6818 SUM(sl.prompt_tokens)::bigint AS prompt_tokens, 

6819 SUM(sl.completion_tokens)::bigint AS completion_tokens, 

6820 SUM(sl.total_tokens)::bigint AS total_tokens, 

6821 COUNT(*)::bigint AS api_requests, 

6822 COUNT(*) FILTER (WHERE sl.status IS DISTINCT FROM 'failure')::bigint AS successful_requests, 

6823 COUNT(*) FILTER (WHERE sl.status = 'failure')::bigint AS failed_requests 

6824 FROM "LiteLLM_SpendLogs" sl 

6825 LEFT JOIN "LiteLLM_UserTable" u ON u.user_id = sl."user" 

6826 WHERE sl."startTime" >= $1::timestamp 

6827 AND sl."startTime" < $2::timestamp + INTERVAL '1 day' 

6828 AND sl.team_id IN ({team_placeholders}){user_clause} 

6829 GROUP BY sl.team_id, sl."user", u.user_email, u.user_alias 

6830 ORDER BY spend DESC, sl.team_id, sl."user" 

6831 """ 

6832 

6833 

6834class _TeamUserSpendDbRow(TypedDict): 

6835 team_id: ReadOnly[str] 

6836 user_id: ReadOnly[str | None] 

6837 user_email: ReadOnly[str | None] 

6838 user_alias: ReadOnly[str | None] 

6839 spend: ReadOnly[float] 

6840 prompt_tokens: ReadOnly[int] 

6841 completion_tokens: ReadOnly[int] 

6842 total_tokens: ReadOnly[int] 

6843 api_requests: ReadOnly[int] 

6844 successful_requests: ReadOnly[int] 

6845 failed_requests: ReadOnly[int] 

6846 

6847 

6848@router.get( 

6849 "/team/spend/by_user", 

6850 response_model=TeamUserSpendResponse, 

6851 tags=["team management"], # mutable-ok: fastapi route tags must be a list 

6852) 

6853async def get_team_spend_by_user( 

6854 user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], 

6855 team_ids: str | None = None, 

6856 start_date: str | None = None, 

6857 end_date: str | None = None, 

6858) -> TeamUserSpendResponse: 

6859 """ 

6860 Spend per user within the given teams, attributed per request from spend logs. 

6861 

6862 Proxy admins may query any team. Team admins and members holding the 

6863 `/team/daily/activity` permission see every user of the requested teams; 

6864 other members only see their own row. 

6865 """ 

6866 from litellm.proxy.proxy_server import ( 

6867 prisma_client, 

6868 proxy_logging_obj, 

6869 user_api_key_cache, 

6870 ) 

6871 

6872 if prisma_client is None: 6872 ↛ 6873line 6872 didn't jump to line 6873 because the condition on line 6872 was never true

6873 raise _daily_activity_error(status_code=500, message=CommonProxyErrors.db_not_connected_error.value) 

6874 

6875 range_error: Final = _aggregated_date_range_error(start_date, end_date) 

6876 if range_error is not None or start_date is None or end_date is None: 

6877 raise _daily_activity_error(status_code=400, message=range_error or "Please provide start_date and end_date") 

6878 

6879 if not team_ids: 

6880 raise _daily_activity_error(status_code=400, message="Please provide team_ids") 

6881 

6882 scope: Final = await _resolve_team_daily_activity_scope( 

6883 team_ids=team_ids, 

6884 exclude_team_ids=None, 

6885 api_key=None, 

6886 user_api_key_dict=user_api_key_dict, 

6887 prisma_client=prisma_client, 

6888 user_api_key_cache=user_api_key_cache, 

6889 proxy_logging_obj=proxy_logging_obj, 

6890 ) 

6891 scoped_team_ids: Final = tuple(scope.team_ids or ()) 

6892 if not scoped_team_ids: 6892 ↛ 6893line 6892 didn't jump to line 6893 because the condition on line 6892 was never true

6893 return TeamUserSpendResponse(start_date=start_date, end_date=end_date, results=()) 

6894 

6895 own_user_only: Final = scope.api_key_filter is not None 

6896 user_param: Final = (user_api_key_dict.user_id or "",) if own_user_only else () 

6897 rows: Final[Sequence[_TeamUserSpendDbRow]] = await prisma_client.db.query_raw( 

6898 _team_user_spend_sql(team_count=len(scoped_team_ids), restrict_to_user=own_user_only), 

6899 start_date, 

6900 end_date, 

6901 *scoped_team_ids, 

6902 *user_param, 

6903 ) 

6904 results: Final = tuple( 

6905 TeamUserSpendRow( 

6906 team_id=row["team_id"], 

6907 team_alias=_team_alias_or_none(scope.team_alias_metadata.get(row["team_id"])), 

6908 user_id=row["user_id"] or "", 

6909 user_email=row["user_email"], 

6910 user_alias=row["user_alias"], 

6911 spend=row["spend"], 

6912 prompt_tokens=row["prompt_tokens"], 

6913 completion_tokens=row["completion_tokens"], 

6914 total_tokens=row["total_tokens"], 

6915 api_requests=row["api_requests"], 

6916 successful_requests=row["successful_requests"], 

6917 failed_requests=row["failed_requests"], 

6918 ) 

6919 for row in rows 

6920 ) 

6921 return TeamUserSpendResponse(start_date=start_date, end_date=end_date, results=results) 

6922 

6923 

6924def _team_alias_or_none(metadata: Mapping[str, object] | None) -> str | None: 

6925 alias: Final = metadata.get("team_alias") if metadata is not None else None 

6926 return alias if isinstance(alias, str) else None