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

507 statements  

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

1import math 

2 

3""" 

4Endpoints for /organization operations 

5 

6/organization/new 

7/organization/update 

8/organization/delete 

9/organization/member_add 

10/organization/info 

11/organization/list 

12""" 

13 

14#### ORGANIZATION MANAGEMENT #### 

15 

16from collections.abc import Mapping, Sequence 

17from typing import ( 

18 TYPE_CHECKING, 

19 Annotated, 

20 Final, 

21 Protocol, 

22 cast, # noqa: TID251 # prisma types Json columns as fields.Json but reads back plain python values 

23 overload, 

24) 

25 

26import fastapi 

27from fastapi import APIRouter, Depends, HTTPException, Request, status 

28from pydantic import TypeAdapter 

29from typing_extensions import ReadOnly, TypedDict 

30 

31from litellm._logging import verbose_proxy_logger 

32from litellm._uuid import uuid 

33from litellm.proxy._types import * 

34from litellm.proxy.auth.auth_checks import ( 

35 can_user_call_model, 

36 delete_cache_key_objects, 

37 get_jwt_key_mapping_cache_keys_for_tokens, 

38 get_user_object, 

39) 

40from litellm.proxy.auth.user_api_key_auth import user_api_key_auth 

41from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import evict_and_broadcast 

42from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time 

43from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache 

44from litellm.proxy.management_endpoints.budget_management_endpoints import ( 

45 new_budget, 

46 update_budget, 

47) 

48from litellm.proxy.management_endpoints.common_daily_activity import get_daily_activity 

49from litellm.proxy.management_endpoints.common_utils import ( 

50 _set_object_metadata_field, 

51 _user_has_admin_view, 

52 validate_budget_duration, 

53) 

54from litellm.proxy.management_helpers.object_permission_utils import ( 

55 handle_update_object_permission_common, 

56 prepare_object_permission_upsert, 

57 reject_ambiguous_mcp_tool_permission_keys, 

58) 

59from litellm.proxy.management_helpers.utils import ( 

60 get_new_internal_user_defaults, 

61 management_endpoint_wrapper, 

62) 

63from litellm.proxy.utils import PrismaClient, ProxyLogging 

64from litellm.repositories.budget_repository import BudgetRepository 

65from litellm.repositories.object_permission_repository import ObjectPermissionRepository 

66from litellm.repositories.organization_repository import OrganizationRepository 

67from litellm.repositories.table_repositories import OrganizationMembershipRepository 

68from litellm.repositories.team_repository import TeamRepository 

69from litellm.repositories.user_repository import UserRepository 

70from litellm.repositories.verification_token_repository import ( 

71 VerificationTokenRepository, 

72) 

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

74 SpendAnalyticsPaginatedResponse, 

75) 

76from litellm.utils import _update_dictionary 

77 

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

79 from types import TracebackType 

80 

81 from prisma.models import LiteLLM_BudgetTable as PrismaBudgetTable 

82 from prisma.models import ( 

83 LiteLLM_ObjectPermissionTable as PrismaObjectPermissionTable, 

84 ) 

85 from prisma.models import ( 

86 LiteLLM_OrganizationMembership as PrismaOrganizationMembership, 

87 ) 

88 from prisma.models import LiteLLM_OrganizationTable as PrismaOrganizationTable 

89 from prisma.models import LiteLLM_UserTable as PrismaUserTable 

90 from prisma.models import LiteLLM_VerificationToken as PrismaVerificationToken 

91 

92 

93async def _enterprise_license_required( 

94 _user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], 

95) -> None: 

96 from litellm.proxy.proxy_server import premium_user 

97 

98 if not premium_user: 98 ↛ exitline 98 didn't return from function '_enterprise_license_required' because the condition on line 98 was always true

99 raise HTTPException( 

100 status_code=403, 

101 detail={ 

102 "error": "Organizations are only available for LiteLLM Enterprise users. " 

103 f"{CommonProxyErrors.not_premium_user.value}" 

104 }, 

105 ) 

106 

107 

108router: Final = APIRouter(dependencies=[Depends(_enterprise_license_required)]) 

109 

110 

111class _ObjectPermissionRow(Protocol): 

112 @property 

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

114 

115 

116class _UserTableClient(Protocol): 

117 async def find_unique(self, where: Mapping[str, object]) -> "PrismaUserTable | None": ... 117 ↛ exitline 117 didn't return from function 'find_unique' because

118 

119 

120class _BudgetTableClient(Protocol): 

121 async def create(self, data: Mapping[str, object]) -> "PrismaBudgetTable": ... 121 ↛ exitline 121 didn't return from function 'create' because

122 

123 

124class _ObjectPermissionTableClient(Protocol): 

125 async def create(self, data: Mapping[str, object]) -> "PrismaObjectPermissionTable": ... 125 ↛ exitline 125 didn't return from function 'create' because

126 

127 

128class _OrganizationTableClient(Protocol): 

129 async def create( 129 ↛ exitline 129 didn't return from function 'create' because

130 self, data: Mapping[str, object], include: Mapping[str, object] | None = None 

131 ) -> "PrismaOrganizationTable": ... 

132 

133 async def find_unique( 133 ↛ exitline 133 didn't return from function 'find_unique' because

134 self, where: Mapping[str, object], include: Mapping[str, object] | None = None 

135 ) -> "PrismaOrganizationTable | None": ... 

136 

137 async def find_many( 137 ↛ exitline 137 didn't return from function 'find_many' because

138 self, 

139 where: Mapping[str, object] | None = None, 

140 include: Mapping[str, object] | None = None, 

141 ) -> "Sequence[PrismaOrganizationTable]": ... 

142 

143 async def update( 143 ↛ exitline 143 didn't return from function 'update' because

144 self, 

145 where: Mapping[str, object], 

146 data: Mapping[str, object], 

147 include: Mapping[str, object] | None = None, 

148 ) -> "PrismaOrganizationTable": ... 

149 

150 async def delete( 150 ↛ exitline 150 didn't return from function 'delete' because

151 self, where: Mapping[str, object], include: Mapping[str, object] | None = None 

152 ) -> "PrismaOrganizationTable | None": ... 

153 

154 

155class _OrganizationMembershipTableClient(Protocol): 

156 async def create(self, data: Mapping[str, object]) -> "PrismaOrganizationMembership": ... 156 ↛ exitline 156 didn't return from function 'create' because

157 

158 async def find_unique( 158 ↛ exitline 158 didn't return from function 'find_unique' because

159 self, where: Mapping[str, object], include: Mapping[str, object] | None = None 

160 ) -> "PrismaOrganizationMembership | None": ... 

161 

162 async def find_many( 162 ↛ exitline 162 didn't return from function 'find_many' because

163 self, where: Mapping[str, object] | None = None 

164 ) -> "Sequence[PrismaOrganizationMembership]": ... 

165 

166 async def update( 166 ↛ exitline 166 didn't return from function 'update' because

167 self, where: Mapping[str, object], data: Mapping[str, object] 

168 ) -> "PrismaOrganizationMembership": ... 

169 

170 async def delete(self, where: Mapping[str, object]) -> "PrismaOrganizationMembership | None": ... 170 ↛ exitline 170 didn't return from function 'delete' because

171 

172 async def delete_many(self, where: Mapping[str, object]) -> int: ... 172 ↛ exitline 172 didn't return from function 'delete_many' because

173 

174 

175class _TeamTableClient(Protocol): 

176 async def delete_many(self, where: Mapping[str, object]) -> int: ... 176 ↛ exitline 176 didn't return from function 'delete_many' because

177 

178 

179class _VerificationTokenTableClient(Protocol): 

180 async def find_many(self, where: Mapping[str, object] | None = None) -> "Sequence[PrismaVerificationToken]": ... 180 ↛ exitline 180 didn't return from function 'find_many' because

181 

182 async def delete_many(self, where: Mapping[str, object]) -> int: ... 182 ↛ exitline 182 didn't return from function 'delete_many' because

183 

184 

185class _OrganizationIdFilter(TypedDict): 

186 organization_id: ReadOnly[str] 

187 

188 

189class _ObjectPermissionTxClient(Protocol): 

190 async def upsert( 190 ↛ exitline 190 didn't return from function 'upsert' because

191 self, where: Mapping[str, object], data: Mapping[str, object] 

192 ) -> "PrismaObjectPermissionTable": ... 

193 

194 

195class _BudgetTxClient(Protocol): 

196 async def update(self, where: Mapping[str, object], data: Mapping[str, object]) -> "PrismaBudgetTable | None": ... 196 ↛ exitline 196 didn't return from function 'update' because

197 

198 

199class _TransactionTables(Protocol): 

200 @property 

201 def litellm_objectpermissiontable(self) -> "_ObjectPermissionTxClient": ... 201 ↛ exitline 201 didn't return from function 'litellm_objectpermissiontable' because

202 

203 @property 

204 def litellm_budgettable(self) -> "_BudgetTxClient": ... 204 ↛ exitline 204 didn't return from function 'litellm_budgettable' because

205 

206 @property 

207 def litellm_organizationtable(self) -> "_OrganizationTableClient": ... 207 ↛ exitline 207 didn't return from function 'litellm_organizationtable' because

208 

209 

210class _TransactionManager(Protocol): 

211 async def __aenter__(self) -> "_TransactionTables": ... 211 ↛ exitline 211 didn't return from function '__aenter__' because

212 

213 async def __aexit__( 213 ↛ exitline 213 didn't return from function '__aexit__' because

214 self, 

215 exc_type: type[BaseException] | None, 

216 exc_value: BaseException | None, 

217 traceback: "TracebackType | None", 

218 ) -> bool | None: ... 

219 

220 

221@overload 

222def _table(repository: BudgetRepository) -> "_BudgetTableClient": ... 222 ↛ exitline 222 didn't return from function '_table' because

223 

224 

225@overload 

226def _table(repository: ObjectPermissionRepository) -> "_ObjectPermissionTableClient": ... 226 ↛ exitline 226 didn't return from function '_table' because

227 

228 

229@overload 

230def _table(repository: OrganizationRepository) -> "_OrganizationTableClient": ... 230 ↛ exitline 230 didn't return from function '_table' because

231 

232 

233@overload 

234def _table(repository: OrganizationMembershipRepository) -> "_OrganizationMembershipTableClient": ... 234 ↛ exitline 234 didn't return from function '_table' because

235 

236 

237@overload 

238def _table(repository: TeamRepository) -> "_TeamTableClient": ... 238 ↛ exitline 238 didn't return from function '_table' because

239 

240 

241@overload 

242def _table(repository: UserRepository) -> "_UserTableClient": ... 242 ↛ exitline 242 didn't return from function '_table' because

243 

244 

245@overload 

246def _table(repository: VerificationTokenRepository) -> "_VerificationTokenTableClient": ... 246 ↛ exitline 246 didn't return from function '_table' because

247 

248 

249def _table( 

250 repository: BudgetRepository 

251 | ObjectPermissionRepository 

252 | OrganizationRepository 

253 | OrganizationMembershipRepository 

254 | TeamRepository 

255 | UserRepository 

256 | VerificationTokenRepository, 

257) -> object: 

258 prisma_table: Final[object] = repository.table 

259 return prisma_table 

260 

261 

262async def _verify_org_access( 

263 organization_id: str, 

264 user_api_key_dict: UserAPIKeyAuth, 

265 prisma_client: PrismaClient, 

266) -> None: 

267 """ 

268 Verify the caller is either a proxy admin or an org admin of the given organization. 

269 

270 Raises HTTPException(403) if the caller does not have access. 

271 """ 

272 if _user_has_admin_view(user_api_key_dict): 272 ↛ 275line 272 didn't jump to line 275 because the condition on line 272 was always true

273 return 

274 

275 if not user_api_key_dict.user_id: 

276 raise HTTPException( 

277 status_code=status.HTTP_403_FORBIDDEN, 

278 detail="You do not have access to this organization", 

279 ) 

280 

281 from litellm.proxy.proxy_server import proxy_logging_obj, user_api_key_cache 

282 

283 caller_user: Final = await get_user_object( 

284 user_id=user_api_key_dict.user_id, 

285 prisma_client=prisma_client, 

286 user_api_key_cache=user_api_key_cache, 

287 user_id_upsert=False, 

288 proxy_logging_obj=proxy_logging_obj, 

289 ) 

290 if caller_user is None: 

291 raise HTTPException( 

292 status_code=status.HTTP_403_FORBIDDEN, 

293 detail="You do not have access to this organization", 

294 ) 

295 

296 for m in caller_user.organization_memberships or []: 

297 if m.organization_id == organization_id and m.user_role == LitellmUserRoles.ORG_ADMIN.value: 

298 return 

299 

300 raise HTTPException( 

301 status_code=status.HTTP_403_FORBIDDEN, 

302 detail="You do not have access to this organization", 

303 ) 

304 

305 

306_STR_OBJECT_DICT_ADAPTER: Final = TypeAdapter(dict[str, object]) 

307_BUDGET_SETTABLE_FIELDS: Final = frozenset(LiteLLM_BudgetTable.model_fields.keys()) - {"budget_id"} 

308_ORG_COLUMN_FIELDS: Final = frozenset({"organization_alias", "models"}) 

309_ORG_METADATA_FIELDS: Final = tuple( 

310 field for field in LiteLLM_ManagementEndpoint_MetadataFields if field not in _BUDGET_SETTABLE_FIELDS 

311) 

312 

313 

314def build_budget_write_data(budget_updates: Mapping[str, object], updated_by: str) -> Mapping[str, object]: 

315 """ 

316 Budget-row columns to write. ``budget_reset_at`` tracks any sent ``budget_duration``: 

317 recomputed for a new duration, cleared alongside a ``None`` duration so no stale reset 

318 timestamp survives. Other sent fields (including a ``None`` clear) are written as-is. 

319 """ 

320 budget_duration: Final = budget_updates.get("budget_duration") 

321 recomputed_reset_at: Final[Mapping[str, object]] = ( 

322 { 

323 "budget_reset_at": ( 

324 get_budget_reset_time(budget_duration=budget_duration) if isinstance(budget_duration, str) else None 

325 ) 

326 } 

327 if "budget_duration" in budget_updates 

328 else {} 

329 ) 

330 return {**budget_updates, **recomputed_reset_at, "updated_by": updated_by} 

331 

332 

333def handle_nested_budget_structure_in_organization_update_request( 

334 raw_data: dict, 

335) -> dict: 

336 """ 

337 Transform organization update request to handle UI payload format. 

338 

339 The UI sends nested budget data in 'litellm_budget_table', but our 

340 model expects flat budget fields at the top level. 

341 """ 

342 transformed_data: Final = raw_data.copy() 

343 

344 # Handle nested budget structure from UI 

345 if "litellm_budget_table" in transformed_data: 

346 budget_data: Final = transformed_data.pop("litellm_budget_table", {}) 

347 if budget_data: 

348 # Extract valid budget fields and merge into top level 

349 budget_fields: Final = LiteLLM_BudgetTable.model_fields.keys() 

350 for key, value in budget_data.items(): 

351 if key in budget_fields: 

352 transformed_data[key] = value 

353 

354 return transformed_data 

355 

356 

357@router.post( 

358 "/organization/new", 

359 tags=["organization management"], 

360 dependencies=[Depends(user_api_key_auth)], 

361 response_model=NewOrganizationResponse, 

362) 

363async def new_organization( 

364 data: NewOrganizationRequest, 

365 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), 

366): 

367 """ 

368 Allow orgs to own teams 

369 

370 Set org level budgets + model access. 

371 

372 Only admins can create orgs. 

373 

374 # Parameters 

375 

376 - organization_alias: *str* - The name of the organization. 

377 - models: *List* - The models the organization has access to. 

378 - budget_id: *Optional[str]* - The id for a budget (tpm/rpm/max budget) for the organization. 

379 ### IF NO BUDGET ID - CREATE ONE WITH THESE PARAMS ### 

380 - max_budget: *Optional[float]* - Max budget for org 

381 - tpm_limit: *Optional[int]* - Max tpm limit for org 

382 - rpm_limit: *Optional[int]* - Max rpm limit for org 

383 - tpd_limit: *Optional[int]* - Max tokens per day stored on the org budget. Batch submissions enforce tpd_limit at the key, team and end user scopes only. 

384 - model_rpm_limit: *Optional[Dict[str, int]]* - The RPM (Requests Per Minute) limit per model for this organization. 

385 - model_tpm_limit: *Optional[Dict[str, int]]* - The TPM (Tokens Per Minute) limit per model for this organization. 

386 - max_parallel_requests: *Optional[int]* - [Not Implemented Yet] Max parallel requests for org 

387 - soft_budget: *Optional[float]* - [Not Implemented Yet] Get a slack alert when this soft budget is reached. Don't block requests. 

388 - model_max_budget: *Optional[dict]* - Max budget for a specific model 

389 - budget_duration: *Optional[str]* - Frequency of reseting org budget 

390 - metadata: *Optional[dict]* - Metadata for organization, store information for organization. Example metadata - {"extra_info": "some info"} 

391 - blocked: *bool* - Flag indicating if the org is blocked or not - will stop all calls from keys with this org_id. 

392 - 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). 

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

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

395 - object_permission: Optional[LiteLLM_ObjectPermissionBase] - organization-specific object permission. Example - {"vector_stores": ["vector_store_1", "vector_store_2"]}. IF null or {} then no object permission. 

396 - allowed_models: Optional[List[str]] - List of models the organization is allowed to access. If not set, defaults to the models field. 

397 - temp_budget_increase: *Optional[float]* - Stored on the org budget row but only enforced for team member budgets today. 

398 - temp_budget_expiry: *Optional[str]* - Stored on the org budget row but only enforced for team member budgets today. 

399 Case 1: Create new org **without** a budget_id 

400 

401 ```bash 

402 curl --location 'http://0.0.0.0:4000/organization/new' \ 

403 

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

405 

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

407 

408 --data '{ 

409 "organization_alias": "my-secret-org", 

410 "models": ["model1", "model2"], 

411 "max_budget": 100 

412 }' 

413 

414 

415 ``` 

416 

417 Case 2: Create new org **with** a budget_id 

418 

419 ```bash 

420 curl --location 'http://0.0.0.0:4000/organization/new' \ 

421 

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

423 

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

425 

426 --data '{ 

427 "organization_alias": "my-secret-org", 

428 "models": ["model1", "model2"], 

429 "budget_id": "428eeaa8-f3ac-4e85-a8fb-7dc8d7aa8689" 

430 }' 

431 ``` 

432 """ 

433 

434 from litellm.proxy.proxy_server import ( 

435 litellm_proxy_admin_name, 

436 llm_router, 

437 prisma_client, 

438 ) 

439 

440 if prisma_client is None: 

441 raise HTTPException( 

442 status_code=500, 

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

444 ) 

445 

446 if user_api_key_dict.user_role is None or user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: 

447 raise HTTPException( 

448 status_code=401, 

449 detail={"error": f"Only admins can create orgs. Your role is = {user_api_key_dict.user_role}"}, 

450 ) 

451 

452 if llm_router is None: 

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

454 

455 # Validate budget values are not negative 

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

457 raise HTTPException( 

458 status_code=400, 

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

460 ) 

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

462 raise HTTPException( 

463 status_code=400, 

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

465 ) 

466 

467 user_object_correct_type: LiteLLM_UserTable | None = None 

468 

469 if user_api_key_dict.user_id is not None: 

470 try: 

471 user_object: Final = await _table(UserRepository(prisma_client)).find_unique( 

472 where={"user_id": user_api_key_dict.user_id} 

473 ) 

474 if user_object is not None: 

475 user_object_correct_type = LiteLLM_UserTable.model_validate(user_object.model_dump()) 

476 except Exception: 

477 pass 

478 

479 if data.budget_id is None: 

480 """ 

481 Every organization needs a budget attached. 

482 

483 If none provided, create one based on provided values 

484 """ 

485 budget_params: Final = LiteLLM_BudgetTable.model_fields.keys() 

486 

487 # Only include Budget Params when creating an entry in litellm_budgettable 

488 _json_data: Final = _STR_OBJECT_DICT_ADAPTER.validate_python(data.json(exclude_none=True)) 

489 _budget_data: Final = {k: v for k, v in _json_data.items() if k in budget_params} 

490 budget_row: Final = LiteLLM_BudgetTable.model_validate(_budget_data) 

491 

492 new_budget: Final = _STR_OBJECT_DICT_ADAPTER.validate_python( 

493 prisma_client.jsonify_object(budget_row.json(exclude_none=True)) 

494 ) 

495 

496 _budget: Final = await _table(BudgetRepository(prisma_client)).create( 

497 data={ 

498 **new_budget, 

499 "created_by": user_api_key_dict.user_id or litellm_proxy_admin_name, 

500 "updated_by": user_api_key_dict.user_id or litellm_proxy_admin_name, 

501 } 

502 ) 

503 

504 data.budget_id = _budget.budget_id 

505 

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

507 object_permission_id: Final = await _set_object_permission( 

508 data=data, 

509 prisma_client=prisma_client, 

510 ) 

511 

512 """ 

513 Ensure only models that user has access to, are given to org 

514 """ 

515 if len(user_api_key_dict.models) == 0: # user has access to all models 

516 pass 

517 else: 

518 if len(data.models) == 0: 

519 raise HTTPException( 

520 status_code=400, 

521 detail={ 

522 "error": "User not allowed to give access to all models. Select models you want org to have access to." 

523 }, 

524 ) 

525 

526 for m in data.models: 

527 await can_user_call_model(m, llm_router=llm_router, user_object=user_object_correct_type) 

528 

529 organization_payload: Final = _STR_OBJECT_DICT_ADAPTER.validate_python(data.json(exclude_none=True)) 

530 organization_payload["object_permission_id"] = object_permission_id 

531 organization_payload["created_by"] = user_api_key_dict.user_id or litellm_proxy_admin_name 

532 organization_payload["updated_by"] = user_api_key_dict.user_id or litellm_proxy_admin_name 

533 organization_row: Final = LiteLLM_OrganizationTable.model_validate(organization_payload) 

534 

535 for field in _ORG_METADATA_FIELDS: 

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

537 _set_object_metadata_field( 

538 object_data=organization_row, 

539 field_name=field, 

540 value=getattr(data, field), 

541 ) 

542 

543 new_organization_row: Final = _STR_OBJECT_DICT_ADAPTER.validate_python( 

544 prisma_client.jsonify_object(organization_row.json(exclude_none=True)) 

545 ) 

546 verbose_proxy_logger.info("new_organization_row: %s", json.dumps(new_organization_row, indent=2)) 

547 response: Final = await _table(OrganizationRepository(prisma_client)).create( 

548 data={ 

549 **new_organization_row, 

550 }, 

551 include={"litellm_budget_table": True}, 

552 ) 

553 

554 return response 

555 

556 

557@router.get( 

558 "/organization/daily/activity", 

559 response_model=SpendAnalyticsPaginatedResponse, 

560 tags=["organization management"], 

561) 

562async def get_organization_daily_activity( 

563 organization_ids: str | None = None, 

564 start_date: str | None = None, 

565 end_date: str | None = None, 

566 model: str | None = None, 

567 api_key: str | None = None, 

568 page: int = 1, 

569 page_size: int = 10, 

570 exclude_organization_ids: str | None = None, 

571 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), 

572): 

573 """ 

574 Get daily activity for specific organizations or all accessible organizations. 

575 """ 

576 from litellm.proxy.proxy_server import ( 

577 prisma_client, 

578 ) 

579 

580 if prisma_client is None: 

581 raise HTTPException( 

582 status_code=500, 

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

584 ) 

585 

586 # Parse comma-separated ids 

587 org_ids_list = organization_ids.split(",") if organization_ids else None 

588 exclude_org_ids_list: list[str] | None = None 

589 if exclude_organization_ids: 

590 exclude_org_ids_list = exclude_organization_ids.split(",") if exclude_organization_ids else None 

591 

592 # Restrict non-proxy-admins to only organizations where they are org_admin 

593 if not _user_has_admin_view(user_api_key_dict): 

594 memberships: Final = await _table(OrganizationMembershipRepository(prisma_client)).find_many( 

595 where={"user_id": user_api_key_dict.user_id} 

596 ) 

597 admin_org_ids = [m.organization_id for m in memberships if m.user_role == LitellmUserRoles.ORG_ADMIN.value] 

598 if org_ids_list is None: 

599 # Default to orgs where user is org_admin 

600 org_ids_list = admin_org_ids 

601 else: 

602 # Ensure user is org_admin for all requested orgs 

603 for org_id in org_ids_list: 

604 if org_id not in admin_org_ids: 

605 raise HTTPException( 

606 status_code=403, 

607 detail={"error": f"User is not org_admin for Organization= {org_id}."}, 

608 ) 

609 

610 # Fetch organization aliases for metadata 

611 where_condition: Final = _STR_OBJECT_DICT_ADAPTER.validate_python({}) 

612 if org_ids_list is not None: 

613 where_condition["organization_id"] = {"in": list(org_ids_list)} 

614 org_aliases: Final = await _table(OrganizationRepository(prisma_client)).find_many(where=where_condition) 

615 

616 # Query daily activity for organizations 

617 return await get_daily_activity( 

618 prisma_client=prisma_client, 

619 table_name="litellm_dailyorganizationspend", 

620 entity_id_field="organization_id", 

621 entity_id=org_ids_list, 

622 entity_metadata_field={o.organization_id: {"organization_alias": o.organization_alias} for o in org_aliases}, 

623 exclude_entity_ids=exclude_org_ids_list, 

624 start_date=start_date, 

625 end_date=end_date, 

626 model=model, 

627 api_key=api_key, 

628 page=page, 

629 page_size=page_size, 

630 ) 

631 

632 

633async def _set_object_permission( 

634 data: NewOrganizationRequest, 

635 prisma_client: PrismaClient | None, 

636) -> str | None: 

637 """ 

638 Creates the LiteLLM_ObjectPermissionTable record for the organization. 

639 - Handles permissions for vector stores and mcp servers. 

640 

641 Returns the object_permission_id if created, otherwise None. 

642 """ 

643 if prisma_client is None: 

644 return None 

645 

646 if data.object_permission is not None: 

647 await reject_ambiguous_mcp_tool_permission_keys( 

648 new_mcp_tool_permissions=data.object_permission.mcp_tool_permissions, 

649 existing_mcp_tool_permissions=None, 

650 prisma_client=prisma_client, 

651 ) 

652 created_object_permission: Final = await _table(ObjectPermissionRepository(prisma_client)).create( 

653 data=data.object_permission.model_dump(exclude_none=True), 

654 ) 

655 del data.object_permission 

656 return created_object_permission.object_permission_id 

657 return None 

658 

659 

660@router.patch( 

661 "/organization/update", 

662 tags=["organization management"], 

663 dependencies=[Depends(user_api_key_auth)], 

664 response_model=LiteLLM_OrganizationTableWithMembers, 

665) 

666async def update_organization( 

667 request: Request, 

668 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), 

669): 

670 """ 

671 Update an organization 

672 """ 

673 from litellm.proxy.proxy_server import prisma_client 

674 

675 if prisma_client is None: 

676 raise HTTPException( 

677 status_code=500, 

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

679 ) 

680 

681 if user_api_key_dict.user_id is None: 

682 raise HTTPException( 

683 status_code=400, 

684 detail={ 

685 "error": "Cannot associate a user_id to this action. Check `/key/info` to validate if 'user_id' is set." 

686 }, 

687 ) 

688 

689 # Transform UI payload to expected format 

690 raw_data: Final[dict[str, object]] = await request.json() 

691 raw_data_with_flat_budget_fields: Final = handle_nested_budget_structure_in_organization_update_request(raw_data) 

692 

693 # Create validated data model 

694 data: Final = LiteLLM_OrganizationTableUpdate(**raw_data_with_flat_budget_fields) 

695 

696 # Validate budget values are not negative 

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

698 raise HTTPException( 

699 status_code=400, 

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

701 ) 

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

703 raise HTTPException( 

704 status_code=400, 

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

706 ) 

707 

708 if data.updated_by is None: 

709 data.updated_by = user_api_key_dict.user_id 

710 

711 if data.organization_id is None: 

712 raise HTTPException( 

713 status_code=400, 

714 detail={"error": "organization_id is required"}, 

715 ) 

716 

717 # IDOR guard: only proxy admins / org admins of THIS org may update 

718 # it. Without this, any authenticated key holder could rewrite 

719 # another organization's metadata, budgets, and object permissions. 

720 await _verify_org_access( 

721 organization_id=data.organization_id, 

722 user_api_key_dict=user_api_key_dict, 

723 prisma_client=prisma_client, 

724 ) 

725 

726 existing_organization_row: Final = await OrganizationRepository(prisma_client).table.find_unique( 

727 where={"organization_id": data.organization_id}, 

728 ) 

729 

730 if existing_organization_row is None: 

731 raise ValueError(f"Organization not found for organization_id={data.organization_id}") 

732 

733 updated_organization_row_json: Final = data.model_dump(exclude_none=True) 

734 # Merge metadata from existing organization with updated metadata 

735 if updated_organization_row_json.get("metadata") is not None: 

736 existing_metadata: Final = existing_organization_row.metadata or {} 

737 updated_metadata: Final[dict[str, object]] = updated_organization_row_json.get("metadata", {}) 

738 merged_metadata: Final[Mapping[str, object]] = _update_dictionary( 

739 existing_dict=cast( # cast-ok: prisma de-serializes a Json column to the plain python dict it stores 

740 "dict[str, object]", existing_metadata 

741 ).copy(), 

742 new_dict=updated_metadata, 

743 ) 

744 updated_organization_row_json["metadata"] = merged_metadata 

745 

746 updated_organization_row = _STR_OBJECT_DICT_ADAPTER.validate_python( 

747 prisma_client.jsonify_object(updated_organization_row_json) 

748 ) 

749 if data.object_permission is not None: 

750 updated_organization_row = await handle_update_object_permission( 

751 data_json=updated_organization_row, 

752 existing_organization_row=existing_organization_row, 

753 ) 

754 

755 budget_fields: Final = { 

756 k: v for k, v in data.model_dump().items() if k in _BUDGET_SETTABLE_FIELDS and k in data.model_fields_set 

757 } 

758 

759 if budget_fields and existing_organization_row.budget_id: 

760 await update_budget( 

761 budget_obj=BudgetNewRequest(budget_id=existing_organization_row.budget_id, **budget_fields), 

762 user_api_key_dict=user_api_key_dict, 

763 ) 

764 

765 # Remove budget fields from organization update data 

766 for field in LiteLLM_BudgetTable.model_fields: 

767 updated_organization_row.pop(field, None) 

768 

769 response: Final = await _table(OrganizationRepository(prisma_client)).update( 

770 where={"organization_id": data.organization_id}, 

771 data=updated_organization_row, 

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

773 ) 

774 

775 return response 

776 

777 

778async def handle_update_object_permission( 

779 data_json: dict[str, object], 

780 existing_organization_row: _ObjectPermissionRow, 

781) -> dict[str, object]: 

782 """ 

783 Handle the update of object permission for an organization. 

784 

785 - Upserts the new object permission into the LiteLLM_ObjectPermissionTable 

786 - Adds object_permission_id to data_json (this gets added in the DB) 

787 - Pops the object_permission from data_json 

788 """ 

789 from litellm.proxy.proxy_server import prisma_client 

790 

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

792 object_permission_id: Final = await handle_update_object_permission_common( 

793 data_json=data_json, 

794 existing_object_permission_id=existing_organization_row.object_permission_id, 

795 prisma_client=prisma_client, 

796 ) 

797 

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

799 if object_permission_id is not None: 

800 data_json["object_permission_id"] = object_permission_id 

801 

802 return data_json 

803 

804 

805@router.patch( 

806 "/v2/organization/{organization_id}", 

807 tags=["organization management"], 

808 dependencies=[Depends(user_api_key_auth)], 

809 response_model=LiteLLM_OrganizationTableWithMembers, 

810) 

811async def update_organization_v2( 

812 organization_id: str, 

813 data: OrganizationUpdateRequestV2, 

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

815): 

816 """ 

817 Partial update of an organization (RESTful PATCH, RFC 7396 merge-patch semantics). 

818 

819 A sent field is written and an omitted one is left untouched (presence is read from 

820 ``model_fields_set``). Clear tokens are per field: budget limits and ``metadata`` clear with 

821 ``null``, ``models`` with ``[]``, and ``object_permission`` with ``null`` (it merges when sent, 

822 so an empty ``{}`` is rejected). ``organization_alias`` is required and cannot be cleared. 

823 Validation failures return 422; the object-permission upsert, budget-row write, and 

824 org-row write are one transaction. 

825 """ 

826 from litellm.proxy.proxy_server import prisma_client 

827 

828 if prisma_client is None: 

829 raise HTTPException( 

830 status_code=500, 

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

832 ) 

833 

834 if user_api_key_dict.user_id is None: 

835 raise HTTPException( 

836 status_code=400, 

837 detail={ 

838 "error": "Cannot associate a user_id to this action. Check `/key/info` to validate if 'user_id' is set." 

839 }, 

840 ) 

841 

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

843 raise HTTPException( 

844 status_code=422, 

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

846 ) 

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

848 raise HTTPException( 

849 status_code=422, 

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

851 ) 

852 for limit_name, limit_value in ( 

853 ("tpm_limit", data.tpm_limit), 

854 ("rpm_limit", data.rpm_limit), 

855 ("max_parallel_requests", data.max_parallel_requests), 

856 ): 

857 if limit_value is not None and limit_value < 0: 

858 raise HTTPException( 

859 status_code=422, 

860 detail={"error": f"{limit_name} must be non-negative. Received: {limit_value}"}, 

861 ) 

862 validate_budget_duration(data.budget_duration, status_code=422) 

863 if data.model_max_budget: 

864 from litellm.proxy.management_endpoints.key_management_endpoints import ( 

865 validate_model_max_budget, 

866 ) 

867 

868 try: 

869 validate_model_max_budget(data.model_max_budget) 

870 except ValueError as e: 

871 raise HTTPException(status_code=422, detail={"error": str(e)}) 

872 

873 if "organization_alias" in data.model_fields_set and data.organization_alias is None: 

874 raise HTTPException( 

875 status_code=422, 

876 detail={"error": "organization_alias cannot be cleared; it is required"}, 

877 ) 

878 if "models" in data.model_fields_set and data.models is None: 

879 raise HTTPException( 

880 status_code=422, 

881 detail={"error": "models cannot be set to null; send [] to clear it"}, 

882 ) 

883 if data.object_permission is not None and not data.object_permission.model_dump(exclude_none=True): 

884 raise HTTPException( 

885 status_code=422, 

886 detail={ 

887 "error": "object_permission cannot be an empty object; send null to clear it, or a non-empty object to set grants" 

888 }, 

889 ) 

890 

891 await _verify_org_access( 

892 organization_id=organization_id, 

893 user_api_key_dict=user_api_key_dict, 

894 prisma_client=prisma_client, 

895 ) 

896 

897 existing_organization_row: Final = await _table(OrganizationRepository(prisma_client)).find_unique( 

898 where={"organization_id": organization_id}, 

899 ) 

900 if existing_organization_row is None: 

901 raise HTTPException( 

902 status_code=404, 

903 detail={"error": f"Organization not found for organization_id={organization_id}"}, 

904 ) 

905 

906 field_values: Final = _STR_OBJECT_DICT_ADAPTER.validate_python(data.model_dump()) 

907 present_fields: Final = data.model_fields_set 

908 budget_updates: Final = {field: field_values[field] for field in present_fields if field in _BUDGET_SETTABLE_FIELDS} 

909 org_column_updates: Final[Mapping[str, object]] = { 

910 **{field: field_values[field] for field in present_fields if field in _ORG_COLUMN_FIELDS}, 

911 **({"metadata": data.metadata or {}} if "metadata" in present_fields else {}), 

912 } 

913 

914 object_permission_cleared: Final = "object_permission" in present_fields and data.object_permission is None 

915 object_permission_upsert: Final = ( 

916 await prepare_object_permission_upsert( 

917 new_object_permission=data.object_permission.model_dump(exclude_none=True), 

918 existing_object_permission_id=existing_organization_row.object_permission_id, 

919 prisma_client=prisma_client, 

920 ) 

921 if data.object_permission is not None 

922 else None 

923 ) 

924 object_permission_write: Final[Mapping[str, object]] = ( 

925 {"object_permission_id": object_permission_upsert.object_permission_id} 

926 if object_permission_upsert is not None 

927 else ({"object_permission_id": None} if object_permission_cleared else {}) 

928 ) 

929 

930 organization_write_data: Final = _STR_OBJECT_DICT_ADAPTER.validate_python( 

931 prisma_client.jsonify_object( 

932 { 

933 **org_column_updates, 

934 **object_permission_write, 

935 "updated_by": user_api_key_dict.user_id, 

936 } 

937 ) 

938 ) 

939 

940 tx_manager: Final[_TransactionManager] = prisma_client.db.tx() 

941 async with tx_manager as tx: 

942 if object_permission_upsert is not None: 

943 await tx.litellm_objectpermissiontable.upsert( 

944 where={"object_permission_id": object_permission_upsert.object_permission_id}, 

945 data={ 

946 "create": object_permission_upsert.record, 

947 "update": object_permission_upsert.record, 

948 }, 

949 ) 

950 if budget_updates: 

951 budget_write_data: Final = _STR_OBJECT_DICT_ADAPTER.validate_python( 

952 prisma_client.jsonify_object(dict(build_budget_write_data(budget_updates, user_api_key_dict.user_id))) 

953 ) 

954 await tx.litellm_budgettable.update( 

955 where={"budget_id": existing_organization_row.budget_id}, 

956 data=budget_write_data, 

957 ) 

958 response: Final = await tx.litellm_organizationtable.update( 

959 where={"organization_id": organization_id}, 

960 data=organization_write_data, 

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

962 ) 

963 

964 return response 

965 

966 

967@router.delete( 

968 "/organization/delete", 

969 tags=["organization management"], 

970 dependencies=[Depends(user_api_key_auth)], 

971 response_model=list[LiteLLM_OrganizationTableWithMembers], 

972) 

973async def delete_organization( 

974 data: DeleteOrganizationRequest, 

975 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), 

976): 

977 """ 

978 Delete an organization 

979 

980 # Parameters: 

981 

982 - organization_ids: List[str] - The organization ids to delete. 

983 """ 

984 from litellm.proxy.proxy_server import prisma_client, proxy_logging_obj, user_api_key_cache 

985 

986 if prisma_client is None: 

987 raise HTTPException( 

988 status_code=500, 

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

990 ) 

991 

992 if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: 

993 raise HTTPException( 

994 status_code=401, 

995 detail={"error": "Only proxy admins can delete organizations"}, 

996 ) 

997 

998 deleted_orgs: Final = [] 

999 for organization_id in data.organization_ids: 

1000 # delete all teams in the organization 

1001 await _table(TeamRepository(prisma_client)).delete_many(where={"organization_id": organization_id}) 

1002 # delete all members in the organization 

1003 await _table(OrganizationMembershipRepository(prisma_client)).delete_many( 

1004 where={"organization_id": organization_id} 

1005 ) 

1006 await _delete_organization_keys( 

1007 organization_id=organization_id, 

1008 prisma_client=prisma_client, 

1009 user_api_key_cache=user_api_key_cache, 

1010 proxy_logging_obj=proxy_logging_obj, 

1011 ) 

1012 # delete the organization 

1013 deleted_org = await _table(OrganizationRepository(prisma_client)).delete( 

1014 where={"organization_id": organization_id}, 

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

1016 ) 

1017 if deleted_org is None: 

1018 raise HTTPException( 

1019 status_code=404, 

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

1021 ) 

1022 deleted_orgs.append(deleted_org) 

1023 

1024 return deleted_orgs 

1025 

1026 

1027async def _delete_organization_keys( 

1028 organization_id: str, 

1029 prisma_client: PrismaClient, 

1030 user_api_key_cache: UserApiKeyCache, 

1031 proxy_logging_obj: ProxyLogging | None, 

1032) -> None: 

1033 key_filter: Final[_OrganizationIdFilter] = {"organization_id": organization_id} 

1034 keys_to_delete: Final = await _table(VerificationTokenRepository(prisma_client)).find_many(where=key_filter) 

1035 hashed_tokens_to_delete: Final = tuple(key.token for key in keys_to_delete) 

1036 jwt_mapping_cache_keys: Final = await get_jwt_key_mapping_cache_keys_for_tokens( 

1037 hashed_tokens=hashed_tokens_to_delete, 

1038 prisma_client=prisma_client, 

1039 ) 

1040 await _table(VerificationTokenRepository(prisma_client)).delete_many(where=key_filter) 

1041 await delete_cache_key_objects( 

1042 hashed_tokens=hashed_tokens_to_delete, 

1043 user_api_key_cache=user_api_key_cache, 

1044 proxy_logging_obj=proxy_logging_obj, 

1045 ) 

1046 await evict_and_broadcast(cache_keys=jwt_mapping_cache_keys, user_api_key_cache=user_api_key_cache) 

1047 

1048 

1049@router.get( 

1050 "/organization/list", 

1051 tags=["organization management"], 

1052 dependencies=[Depends(user_api_key_auth)], 

1053 response_model=list[LiteLLM_OrganizationTableWithMembers], 

1054) 

1055async def list_organization( 

1056 org_id: str | None = fastapi.Query(default=None, description="Filter organizations by exact organization_id match"), 

1057 org_alias: str | None = fastapi.Query( 

1058 default=None, 

1059 description="Filter organizations by partial organization_alias match. Supports case-insensitive search.", 

1060 ), 

1061 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), 

1062): 

1063 """ 

1064 Get a list of organizations with optional filtering. 

1065 

1066 Parameters: 

1067 org_id: Optional[str] 

1068 Filter organizations by exact organization_id match 

1069 org_alias: Optional[str] 

1070 Filter organizations by partial organization_alias match (case-insensitive) 

1071 

1072 Example: 

1073 ``` 

1074 curl --location --request GET 'http://0.0.0.0:4000/organization/list?org_alias=my-org' \ 

1075 --header 'Authorization: Bearer sk-1234' 

1076 ``` 

1077 

1078 Example with org_id: 

1079 ``` 

1080 curl --location --request GET 'http://0.0.0.0:4000/organization/list?org_id=123e4567-e89b-12d3-a456-426614174000' \ 

1081 --header 'Authorization: Bearer sk-1234' 

1082 ``` 

1083 """ 

1084 from litellm.proxy.proxy_server import prisma_client 

1085 

1086 if prisma_client is None: 

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

1088 

1089 if prisma_client is None: 

1090 raise HTTPException( 

1091 status_code=400, 

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

1093 ) 

1094 

1095 # Build where conditions based on provided filters 

1096 where_conditions: Final[dict[str, object]] = {} 

1097 

1098 if org_id: 

1099 where_conditions["organization_id"] = org_id 

1100 

1101 if org_alias: 

1102 where_conditions["organization_alias"] = { 

1103 "contains": org_alias, 

1104 "mode": "insensitive", # Case-insensitive search 

1105 } 

1106 

1107 # if proxy admin or admin viewer - get all orgs (with optional filters) 

1108 if _user_has_admin_view(user_api_key_dict): 

1109 response = await _table(OrganizationRepository(prisma_client)).find_many( 

1110 where=where_conditions if where_conditions else None, 

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

1112 ) 

1113 # if internal user - get orgs they are a member of (with optional filters) 

1114 else: 

1115 org_memberships: Final = await _table(OrganizationMembershipRepository(prisma_client)).find_many( 

1116 where={"user_id": user_api_key_dict.user_id} 

1117 ) 

1118 membership_org_ids: Final = [membership.organization_id for membership in org_memberships] 

1119 

1120 # Combine membership filter with provided filters 

1121 if membership_org_ids: 

1122 if org_id: 

1123 # If org_id is provided, ensure user is a member of that org 

1124 if org_id not in membership_org_ids: 

1125 # User is not a member of the requested org, return empty list 

1126 response = [] 

1127 else: 

1128 where_conditions["organization_id"] = org_id 

1129 response = await _table(OrganizationRepository(prisma_client)).find_many( 

1130 where=where_conditions, 

1131 include={ 

1132 "litellm_budget_table": True, 

1133 "members": True, 

1134 "teams": True, 

1135 }, 

1136 ) 

1137 else: 

1138 # Filter by membership and any additional filters 

1139 where_conditions["organization_id"] = {"in": membership_org_ids} 

1140 response = await _table(OrganizationRepository(prisma_client)).find_many( 

1141 where=where_conditions, 

1142 include={ 

1143 "litellm_budget_table": True, 

1144 "members": True, 

1145 "teams": True, 

1146 }, 

1147 ) 

1148 else: 

1149 # User is not a member of any orgs 

1150 response = [] 

1151 

1152 return response 

1153 

1154 

1155@router.get( 

1156 "/organization/info", 

1157 tags=["organization management"], 

1158 dependencies=[Depends(user_api_key_auth)], 

1159 response_model=LiteLLM_OrganizationTableWithMembers, 

1160) 

1161async def info_organization( 

1162 organization_id: str, 

1163 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), 

1164): 

1165 """ 

1166 Get the org specific information 

1167 """ 

1168 from litellm.proxy.proxy_server import prisma_client 

1169 

1170 if prisma_client is None: 

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

1172 

1173 # Verify caller has access to this organization 

1174 await _verify_org_access( 

1175 organization_id=organization_id, 

1176 user_api_key_dict=user_api_key_dict, 

1177 prisma_client=prisma_client, 

1178 ) 

1179 

1180 response: Final = await _table(OrganizationRepository(prisma_client)).find_unique( 

1181 where={"organization_id": organization_id}, 

1182 include={ 

1183 "litellm_budget_table": True, 

1184 "members": { 

1185 "include": { 

1186 "user": True, 

1187 } 

1188 }, 

1189 "teams": True, 

1190 "object_permission": True, 

1191 }, 

1192 ) 

1193 

1194 if response is None: 

1195 raise HTTPException(status_code=404, detail={"error": "Organization not found"}) 

1196 

1197 response_pydantic_obj: Final = LiteLLM_OrganizationTableWithMembers.model_validate(response.model_dump()) 

1198 

1199 return response_pydantic_obj 

1200 

1201 

1202@router.post( 

1203 "/organization/info", 

1204 tags=["organization management"], 

1205 dependencies=[Depends(user_api_key_auth)], 

1206) 

1207async def deprecated_info_organization( 

1208 data: OrganizationRequest, 

1209 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), 

1210): 

1211 """ 

1212 DEPRECATED: Use GET /organization/info instead 

1213 """ 

1214 from litellm.proxy.proxy_server import prisma_client 

1215 

1216 if prisma_client is None: 

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

1218 

1219 if len(data.organizations) == 0: 

1220 raise HTTPException( 

1221 status_code=400, 

1222 detail={"error": f"Specify list of organization id's to query. Passed in={data.organizations}"}, 

1223 ) 

1224 

1225 # Verify caller has access to each requested organization 

1226 for org_id in data.organizations: 

1227 await _verify_org_access( 

1228 organization_id=org_id, 

1229 user_api_key_dict=user_api_key_dict, 

1230 prisma_client=prisma_client, 

1231 ) 

1232 

1233 response: Final = await _table(OrganizationRepository(prisma_client)).find_many( 

1234 where={"organization_id": {"in": data.organizations}}, 

1235 include={"litellm_budget_table": True}, 

1236 ) 

1237 

1238 return response 

1239 

1240 

1241@router.post( 

1242 "/organization/member_add", 

1243 tags=["organization management"], 

1244 dependencies=[Depends(user_api_key_auth)], 

1245 response_model=OrganizationAddMemberResponse, 

1246) 

1247@management_endpoint_wrapper 

1248async def organization_member_add( 

1249 data: OrganizationMemberAddRequest, 

1250 http_request: Request, 

1251 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), 

1252) -> OrganizationAddMemberResponse: 

1253 """ 

1254 [BETA] 

1255 

1256 Add new members (either via user_email or user_id) to an organization 

1257 

1258 If user doesn't exist, new user row will also be added to User Table 

1259 

1260 Only proxy_admin or org_admin of organization, allowed to access this endpoint. 

1261 

1262 # Parameters: 

1263 

1264 - organization_id: str (required) 

1265 - member: Union[List[Member], Member] (required) 

1266 - role: Literal[LitellmUserRoles] (required) 

1267 - user_id: Optional[str] 

1268 - user_email: Optional[str] 

1269 

1270 Note: Either user_id or user_email must be provided for each member. 

1271 

1272 Example: 

1273 ``` 

1274 curl -X POST 'http://0.0.0.0:4000/organization/member_add' \ 

1275 -H 'Authorization: Bearer sk-1234' \ 

1276 -H 'Content-Type: application/json' \ 

1277 -d '{ 

1278 "organization_id": "45e3e396-ee08-4a61-a88e-16b3ce7e0849", 

1279 "member": { 

1280 "role": "internal_user", 

1281 "user_id": "krrish247652@berri.ai" 

1282 }, 

1283 "max_budget_in_organization": 100.0 

1284 }' 

1285 ``` 

1286 

1287 The following is executed in this function: 

1288 

1289 1. Check if organization exists 

1290 2. Creates a new Internal User if the user_id or user_email is not found in LiteLLM_UserTable 

1291 3. Add Internal User to the `LiteLLM_OrganizationMembership` table 

1292 """ 

1293 try: 

1294 from litellm.proxy.proxy_server import prisma_client 

1295 

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

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

1298 

1299 # IDOR guard: docstring says "Only proxy_admin or org_admin of 

1300 # organization, allowed to access this endpoint" — but the code 

1301 # never enforced that. Any authenticated key holder could add 

1302 # members to any org. Now gated explicitly. 

1303 await _verify_org_access( 

1304 organization_id=data.organization_id, 

1305 user_api_key_dict=user_api_key_dict, 

1306 prisma_client=prisma_client, 

1307 ) 

1308 

1309 # Check if organization exists 

1310 existing_organization_row: Final = await _table(OrganizationRepository(prisma_client)).find_unique( 

1311 where={"organization_id": data.organization_id} 

1312 ) 

1313 if existing_organization_row is None: 1313 ↛ 1322line 1313 didn't jump to line 1322 because the condition on line 1313 was always true

1314 raise HTTPException( 

1315 status_code=404, 

1316 detail={ 

1317 "error": f"Organization not found for organization_id={getattr(data, 'organization_id', None)}" 

1318 }, 

1319 ) 

1320 

1321 members: Sequence[OrgMember] 

1322 if isinstance(data.member, list): 

1323 members = data.member 

1324 else: 

1325 members = [data.member] 

1326 

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

1328 updated_organization_memberships: Final[list[LiteLLM_OrganizationMembershipTable]] = [] 

1329 

1330 for member in members: 

1331 ( 

1332 updated_user, 

1333 updated_organization_membership, 

1334 ) = await add_member_to_organization( 

1335 member=member, 

1336 organization_id=data.organization_id, 

1337 prisma_client=prisma_client, 

1338 ) 

1339 

1340 updated_users.append(updated_user) 

1341 updated_organization_memberships.append(updated_organization_membership) 

1342 

1343 return OrganizationAddMemberResponse( 

1344 organization_id=data.organization_id, 

1345 updated_users=updated_users, 

1346 updated_organization_memberships=updated_organization_memberships, 

1347 ) 

1348 except Exception as e: 

1349 verbose_proxy_logger.exception("Error adding member to organization: %s", e) 

1350 if isinstance(e, HTTPException): 1350 ↛ 1357line 1350 didn't jump to line 1357 because the condition on line 1350 was always true

1351 raise ProxyException( 

1352 message=getattr(e, "detail", f"Authentication Error({e})"), 

1353 type=ProxyErrorTypes.auth_error, 

1354 param=getattr(e, "param", "None"), 

1355 code=getattr(e, "status_code", status.HTTP_500_INTERNAL_SERVER_ERROR), 

1356 ) 

1357 elif isinstance(e, ProxyException): 

1358 raise e 

1359 raise ProxyException( 

1360 message="Authentication Error, " + str(e), 

1361 type=ProxyErrorTypes.auth_error, 

1362 param=getattr(e, "param", "None"), 

1363 code=status.HTTP_500_INTERNAL_SERVER_ERROR, 

1364 ) 

1365 

1366 

1367async def find_member_if_email(user_email: str, prisma_client: PrismaClient) -> LiteLLM_UserTable: 

1368 """ 

1369 Find a member if the user_email is in LiteLLM_UserTable 

1370 """ 

1371 

1372 not_unique_user_email_error: Final = HTTPException( 

1373 status_code=400, 

1374 detail={ 

1375 "error": f"Unique user not found for user_email={user_email}. Potential duplicate OR non-existent user_email in LiteLLM_UserTable. Use 'user_id' instead." 

1376 }, 

1377 ) 

1378 try: 

1379 existing_user_email_row: Final = await UserRepository(prisma_client).table.find_unique( 

1380 where={"user_email": user_email} 

1381 ) 

1382 except Exception: 

1383 raise not_unique_user_email_error 

1384 if existing_user_email_row is None: 

1385 raise not_unique_user_email_error 

1386 existing_user_email_row_pydantic: Final = LiteLLM_UserTable.model_validate(existing_user_email_row.model_dump()) 

1387 return existing_user_email_row_pydantic 

1388 

1389 

1390@router.patch( 

1391 "/organization/member_update", 

1392 tags=["organization management"], 

1393 dependencies=[Depends(user_api_key_auth)], 

1394 response_model=LiteLLM_OrganizationMembershipTable, 

1395) 

1396@management_endpoint_wrapper 

1397async def organization_member_update( 

1398 data: OrganizationMemberUpdateRequest, 

1399 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), 

1400): 

1401 """ 

1402 Update a member's role in an organization 

1403 """ 

1404 try: 

1405 from litellm.proxy.proxy_server import prisma_client 

1406 

1407 if prisma_client is None: 

1408 raise HTTPException( 

1409 status_code=500, 

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

1411 ) 

1412 

1413 # IDOR guard: only proxy admins / org admins of THIS org may 

1414 # update member roles. The PROXY_ADMIN-target check below was 

1415 # the only access control; without this, any authenticated user 

1416 # could change any non-admin member's role in any org. 

1417 await _verify_org_access( 

1418 organization_id=data.organization_id, 

1419 user_api_key_dict=user_api_key_dict, 

1420 prisma_client=prisma_client, 

1421 ) 

1422 

1423 # Check if organization exists 

1424 existing_organization_row: Final = await _table(OrganizationRepository(prisma_client)).find_unique( 

1425 where={"organization_id": data.organization_id} 

1426 ) 

1427 if existing_organization_row is None: 

1428 raise HTTPException( 

1429 status_code=400, 

1430 detail={ 

1431 "error": f"Organization not found for organization_id={getattr(data, 'organization_id', None)}" 

1432 }, 

1433 ) 

1434 

1435 # Check if member exists in organization 

1436 if data.user_email is not None and data.user_id is None: 

1437 existing_user_email_row: Final = await find_member_if_email(data.user_email, prisma_client) 

1438 data.user_id = existing_user_email_row.user_id 

1439 

1440 try: 

1441 existing_organization_membership: Final = await _table( 

1442 OrganizationMembershipRepository(prisma_client) 

1443 ).find_unique( 

1444 where={ 

1445 "user_id_organization_id": { 

1446 "user_id": data.user_id, 

1447 "organization_id": data.organization_id, 

1448 } 

1449 } 

1450 ) 

1451 except Exception as e: 

1452 raise HTTPException( 

1453 status_code=400, 

1454 detail={ 

1455 "error": f"Error finding organization membership for user_id={data.user_id} in organization={data.organization_id}: {e}" 

1456 }, 

1457 ) 

1458 if existing_organization_membership is None: 

1459 raise HTTPException( 

1460 status_code=404, 

1461 detail={"error": f"Member not found in organization for user_id={data.user_id}"}, 

1462 ) 

1463 

1464 # Reject attempts to change the role of a global PROXY_ADMIN via 

1465 # org-scoped operations. An org-admin of any org could otherwise 

1466 # alter a PROXY_ADMIN user's per-org role, which has downstream 

1467 # effects on admin UI filtering and scope derivation. 

1468 target_user_row = await _table(UserRepository(prisma_client)).find_unique(where={"user_id": data.user_id}) 

1469 if target_user_row is not None and getattr(target_user_row, "user_role", None) in ( 

1470 LitellmUserRoles.PROXY_ADMIN.value, 

1471 LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value, 

1472 ): 

1473 if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value: 

1474 raise HTTPException( 

1475 status_code=403, 

1476 detail={ 

1477 "error": ( 

1478 "Only PROXY_ADMIN may modify the organization role of a user who is a global PROXY_ADMIN." 

1479 ) 

1480 }, 

1481 ) 

1482 

1483 # Update member role 

1484 if data.role is not None: 

1485 await _table(OrganizationMembershipRepository(prisma_client)).update( 

1486 where={ 

1487 "user_id_organization_id": { 

1488 "user_id": data.user_id, 

1489 "organization_id": data.organization_id, 

1490 } 

1491 }, 

1492 data={"user_role": data.role}, 

1493 ) 

1494 if data.max_budget_in_organization is not None: 

1495 # if budget_id is None, create a new budget 

1496 budget_id: Final = existing_organization_membership.budget_id or str(uuid.uuid4()) 

1497 if existing_organization_membership.budget_id is None: 

1498 new_budget_obj = BudgetNewRequest(budget_id=budget_id, max_budget=data.max_budget_in_organization) 

1499 await new_budget(budget_obj=new_budget_obj, user_api_key_dict=user_api_key_dict) 

1500 else: 

1501 # update budget table with new max_budget 

1502 await update_budget( 

1503 budget_obj=BudgetNewRequest(budget_id=budget_id, max_budget=data.max_budget_in_organization), 

1504 user_api_key_dict=user_api_key_dict, 

1505 ) 

1506 

1507 # update organization membership with new budget_id 

1508 await _table(OrganizationMembershipRepository(prisma_client)).update( 

1509 where={ 

1510 "user_id_organization_id": { 

1511 "user_id": data.user_id, 

1512 "organization_id": data.organization_id, 

1513 } 

1514 }, 

1515 data={"budget_id": budget_id}, 

1516 ) 

1517 final_organization_membership = await _table(OrganizationMembershipRepository(prisma_client)).find_unique( 

1518 where={ 

1519 "user_id_organization_id": { 

1520 "user_id": data.user_id, 

1521 "organization_id": data.organization_id, 

1522 } 

1523 }, 

1524 include={"litellm_budget_table": True}, 

1525 ) 

1526 

1527 if final_organization_membership is None: 

1528 raise HTTPException( 

1529 status_code=400, 

1530 detail={"error": f"Member not found in organization={data.organization_id} for user_id={data.user_id}"}, 

1531 ) 

1532 

1533 final_organization_membership_pydantic: Final = LiteLLM_OrganizationMembershipTable.model_validate( 

1534 final_organization_membership.model_dump(exclude_none=True) 

1535 ) 

1536 return final_organization_membership_pydantic 

1537 except Exception as e: 

1538 verbose_proxy_logger.exception("Error updating member in organization: %s", e) 

1539 raise e 

1540 

1541 

1542@router.delete( 

1543 "/organization/member_delete", 

1544 tags=["organization management"], 

1545 dependencies=[Depends(user_api_key_auth)], 

1546) 

1547async def organization_member_delete( 

1548 data: OrganizationMemberDeleteRequest, 

1549 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), 

1550): 

1551 """ 

1552 Delete a member from an organization 

1553 """ 

1554 try: 

1555 from litellm.proxy.proxy_server import prisma_client 

1556 

1557 if prisma_client is None: 

1558 raise HTTPException( 

1559 status_code=500, 

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

1561 ) 

1562 

1563 # IDOR guard: only proxy admins / org admins of THIS org may 

1564 # delete members. Without this, any authenticated key holder 

1565 # could remove any user from any org. 

1566 await _verify_org_access( 

1567 organization_id=data.organization_id, 

1568 user_api_key_dict=user_api_key_dict, 

1569 prisma_client=prisma_client, 

1570 ) 

1571 

1572 if data.user_email is not None and data.user_id is None: 

1573 existing_user_email_row: Final = await find_member_if_email(data.user_email, prisma_client) 

1574 data.user_id = existing_user_email_row.user_id 

1575 

1576 member_to_delete: Final = await _table(OrganizationMembershipRepository(prisma_client)).delete( 

1577 where={ 

1578 "user_id_organization_id": { 

1579 "user_id": data.user_id, 

1580 "organization_id": data.organization_id, 

1581 } 

1582 } 

1583 ) 

1584 return member_to_delete 

1585 

1586 except Exception as e: 

1587 verbose_proxy_logger.exception("Error deleting member from organization: %s", e) 

1588 raise e 

1589 

1590 

1591async def add_member_to_organization( 

1592 member: OrgMember, 

1593 organization_id: str, 

1594 prisma_client: PrismaClient, 

1595) -> tuple[LiteLLM_UserTable, LiteLLM_OrganizationMembershipTable]: 

1596 """ 

1597 Add a member to an organization 

1598 

1599 - Checks if member.user_id or member.user_email is in LiteLLM_UserTable 

1600 - If not found, create a new user in LiteLLM_UserTable 

1601 - Add user to organization in LiteLLM_OrganizationMembership 

1602 """ 

1603 

1604 try: 

1605 user_object: LiteLLM_UserTable | None = None 

1606 existing_user_id_row = None 

1607 existing_user_email_row = None 

1608 ## Check if user exists in LiteLLM_UserTable - user exists - either the user_id or user_email is in LiteLLM_UserTable 

1609 if member.user_id is not None: 

1610 existing_user_id_row = await _table(UserRepository(prisma_client)).find_unique( 

1611 where={"user_id": member.user_id} 

1612 ) 

1613 

1614 if existing_user_id_row is None and member.user_email is not None: 

1615 try: 

1616 existing_user_email_row = await UserRepository(prisma_client).table.find_unique( 

1617 where={"user_email": member.user_email} 

1618 ) 

1619 except Exception as e: 

1620 raise ValueError( 

1621 f"Potential NON-Existent or Duplicate user email in DB: Error finding a unique instance of user_email={member.user_email} in LiteLLM_UserTable.: {e}" 

1622 ) 

1623 

1624 ## If user does not exist, create a new user 

1625 if existing_user_id_row is None and existing_user_email_row is None: 

1626 # Create a new user - since user does not exist 

1627 user_id: Final[str] = member.user_id or str(uuid.uuid4()) 

1628 new_user_defaults: Final = get_new_internal_user_defaults( 

1629 user_id=user_id, 

1630 user_email=member.user_email, 

1631 ) 

1632 

1633 _returned_user = await prisma_client.insert_data(data=new_user_defaults, table_name="user") 

1634 if _returned_user is not None: 

1635 user_object = LiteLLM_UserTable.model_validate(_returned_user.model_dump()) 

1636 elif existing_user_email_row is not None and ( 

1637 len(existing_user_email_row) # pyright: ignore[reportArgumentType] # find_unique yields a row, not a list 

1638 > 1 

1639 ): 

1640 raise HTTPException( 

1641 status_code=400, 

1642 detail={"error": "Multiple users with this email found in db. Please use 'user_id' instead."}, 

1643 ) 

1644 elif existing_user_email_row is not None: 

1645 user_object = LiteLLM_UserTable.model_validate(existing_user_email_row.model_dump()) 

1646 elif existing_user_id_row is not None: 

1647 user_object = LiteLLM_UserTable.model_validate(existing_user_id_row.model_dump()) 

1648 else: 

1649 raise HTTPException( 

1650 status_code=404, 

1651 detail={"error": f"User not found for user_id={member.user_id} and user_email={member.user_email}"}, 

1652 ) 

1653 

1654 if user_object is None: 

1655 raise ValueError( 

1656 f"User does not exist in LiteLLM_UserTable. user_id={member.user_id} and user_email={member.user_email}" 

1657 ) 

1658 

1659 # Add user to organization 

1660 _organization_membership: Final = await _table(OrganizationMembershipRepository(prisma_client)).create( 

1661 data={ 

1662 "organization_id": organization_id, 

1663 "user_id": user_object.user_id, 

1664 "user_role": member.role, 

1665 } 

1666 ) 

1667 organization_membership: Final = LiteLLM_OrganizationMembershipTable.model_validate( 

1668 _organization_membership.model_dump() 

1669 ) 

1670 return user_object, organization_membership 

1671 

1672 except Exception as e: 

1673 raise ValueError(f"Error adding member={member} to organization={organization_id}: {e}")