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
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
1import math
3"""
4Endpoints for /organization operations
6/organization/new
7/organization/update
8/organization/delete
9/organization/member_add
10/organization/info
11/organization/list
12"""
14#### ORGANIZATION MANAGEMENT ####
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)
26import fastapi
27from fastapi import APIRouter, Depends, HTTPException, Request, status
28from pydantic import TypeAdapter
29from typing_extensions import ReadOnly, TypedDict
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
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
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
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
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 )
108router: Final = APIRouter(dependencies=[Depends(_enterprise_license_required)])
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
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
120class _BudgetTableClient(Protocol):
121 async def create(self, data: Mapping[str, object]) -> "PrismaBudgetTable": ... 121 ↛ exitline 121 didn't return from function 'create' because
124class _ObjectPermissionTableClient(Protocol):
125 async def create(self, data: Mapping[str, object]) -> "PrismaObjectPermissionTable": ... 125 ↛ exitline 125 didn't return from function 'create' because
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": ...
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": ...
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]": ...
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": ...
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": ...
155class _OrganizationMembershipTableClient(Protocol):
156 async def create(self, data: Mapping[str, object]) -> "PrismaOrganizationMembership": ... 156 ↛ exitline 156 didn't return from function 'create' because
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": ...
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]": ...
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": ...
170 async def delete(self, where: Mapping[str, object]) -> "PrismaOrganizationMembership | None": ... 170 ↛ exitline 170 didn't return from function 'delete' because
172 async def delete_many(self, where: Mapping[str, object]) -> int: ... 172 ↛ exitline 172 didn't return from function 'delete_many' because
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
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
182 async def delete_many(self, where: Mapping[str, object]) -> int: ... 182 ↛ exitline 182 didn't return from function 'delete_many' because
185class _OrganizationIdFilter(TypedDict):
186 organization_id: ReadOnly[str]
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": ...
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
199class _TransactionTables(Protocol):
200 @property
201 def litellm_objectpermissiontable(self) -> "_ObjectPermissionTxClient": ... 201 ↛ exitline 201 didn't return from function 'litellm_objectpermissiontable' because
203 @property
204 def litellm_budgettable(self) -> "_BudgetTxClient": ... 204 ↛ exitline 204 didn't return from function 'litellm_budgettable' because
206 @property
207 def litellm_organizationtable(self) -> "_OrganizationTableClient": ... 207 ↛ exitline 207 didn't return from function 'litellm_organizationtable' because
210class _TransactionManager(Protocol):
211 async def __aenter__(self) -> "_TransactionTables": ... 211 ↛ exitline 211 didn't return from function '__aenter__' because
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: ...
221@overload
222def _table(repository: BudgetRepository) -> "_BudgetTableClient": ... 222 ↛ exitline 222 didn't return from function '_table' because
225@overload
226def _table(repository: ObjectPermissionRepository) -> "_ObjectPermissionTableClient": ... 226 ↛ exitline 226 didn't return from function '_table' because
229@overload
230def _table(repository: OrganizationRepository) -> "_OrganizationTableClient": ... 230 ↛ exitline 230 didn't return from function '_table' because
233@overload
234def _table(repository: OrganizationMembershipRepository) -> "_OrganizationMembershipTableClient": ... 234 ↛ exitline 234 didn't return from function '_table' because
237@overload
238def _table(repository: TeamRepository) -> "_TeamTableClient": ... 238 ↛ exitline 238 didn't return from function '_table' because
241@overload
242def _table(repository: UserRepository) -> "_UserTableClient": ... 242 ↛ exitline 242 didn't return from function '_table' because
245@overload
246def _table(repository: VerificationTokenRepository) -> "_VerificationTokenTableClient": ... 246 ↛ exitline 246 didn't return from function '_table' because
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
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.
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
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 )
281 from litellm.proxy.proxy_server import proxy_logging_obj, user_api_key_cache
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 )
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
300 raise HTTPException(
301 status_code=status.HTTP_403_FORBIDDEN,
302 detail="You do not have access to this organization",
303 )
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)
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}
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.
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()
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
354 return transformed_data
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
370 Set org level budgets + model access.
372 Only admins can create orgs.
374 # Parameters
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
401 ```bash
402 curl --location 'http://0.0.0.0:4000/organization/new' \
404 --header 'Authorization: Bearer sk-1234' \
406 --header 'Content-Type: application/json' \
408 --data '{
409 "organization_alias": "my-secret-org",
410 "models": ["model1", "model2"],
411 "max_budget": 100
412 }'
415 ```
417 Case 2: Create new org **with** a budget_id
419 ```bash
420 curl --location 'http://0.0.0.0:4000/organization/new' \
422 --header 'Authorization: Bearer sk-1234' \
424 --header 'Content-Type: application/json' \
426 --data '{
427 "organization_alias": "my-secret-org",
428 "models": ["model1", "model2"],
429 "budget_id": "428eeaa8-f3ac-4e85-a8fb-7dc8d7aa8689"
430 }'
431 ```
432 """
434 from litellm.proxy.proxy_server import (
435 litellm_proxy_admin_name,
436 llm_router,
437 prisma_client,
438 )
440 if prisma_client is None:
441 raise HTTPException(
442 status_code=500,
443 detail={"error": CommonProxyErrors.db_not_connected_error.value},
444 )
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 )
452 if llm_router is None:
453 raise HTTPException(status_code=500, detail={"error": CommonProxyErrors.no_llm_router.value})
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 )
467 user_object_correct_type: LiteLLM_UserTable | None = None
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
479 if data.budget_id is None:
480 """
481 Every organization needs a budget attached.
483 If none provided, create one based on provided values
484 """
485 budget_params: Final = LiteLLM_BudgetTable.model_fields.keys()
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)
492 new_budget: Final = _STR_OBJECT_DICT_ADAPTER.validate_python(
493 prisma_client.jsonify_object(budget_row.json(exclude_none=True))
494 )
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 )
504 data.budget_id = _budget.budget_id
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 )
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 )
526 for m in data.models:
527 await can_user_call_model(m, llm_router=llm_router, user_object=user_object_correct_type)
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)
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 )
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 )
554 return response
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 )
580 if prisma_client is None:
581 raise HTTPException(
582 status_code=500,
583 detail={"error": CommonProxyErrors.db_not_connected_error.value},
584 )
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
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 )
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)
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 )
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.
641 Returns the object_permission_id if created, otherwise None.
642 """
643 if prisma_client is None:
644 return None
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
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
675 if prisma_client is None:
676 raise HTTPException(
677 status_code=500,
678 detail={"error": CommonProxyErrors.db_not_connected_error.value},
679 )
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 )
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)
693 # Create validated data model
694 data: Final = LiteLLM_OrganizationTableUpdate(**raw_data_with_flat_budget_fields)
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 )
708 if data.updated_by is None:
709 data.updated_by = user_api_key_dict.user_id
711 if data.organization_id is None:
712 raise HTTPException(
713 status_code=400,
714 detail={"error": "organization_id is required"},
715 )
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 )
726 existing_organization_row: Final = await OrganizationRepository(prisma_client).table.find_unique(
727 where={"organization_id": data.organization_id},
728 )
730 if existing_organization_row is None:
731 raise ValueError(f"Organization not found for organization_id={data.organization_id}")
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
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 )
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 }
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 )
765 # Remove budget fields from organization update data
766 for field in LiteLLM_BudgetTable.model_fields:
767 updated_organization_row.pop(field, None)
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 )
775 return response
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.
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
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 )
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
802 return data_json
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).
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
828 if prisma_client is None:
829 raise HTTPException(
830 status_code=500,
831 detail={"error": CommonProxyErrors.db_not_connected_error.value},
832 )
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 )
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 )
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)})
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 )
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 )
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 )
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 }
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 )
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 )
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 )
964 return response
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
980 # Parameters:
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
986 if prisma_client is None:
987 raise HTTPException(
988 status_code=500,
989 detail={"error": CommonProxyErrors.db_not_connected_error.value},
990 )
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 )
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)
1024 return deleted_orgs
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)
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.
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)
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 ```
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
1086 if prisma_client is None:
1087 raise HTTPException(status_code=500, detail={"error": "No db connected"})
1089 if prisma_client is None:
1090 raise HTTPException(
1091 status_code=400,
1092 detail={"error": CommonProxyErrors.db_not_connected_error.value},
1093 )
1095 # Build where conditions based on provided filters
1096 where_conditions: Final[dict[str, object]] = {}
1098 if org_id:
1099 where_conditions["organization_id"] = org_id
1101 if org_alias:
1102 where_conditions["organization_alias"] = {
1103 "contains": org_alias,
1104 "mode": "insensitive", # Case-insensitive search
1105 }
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]
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 = []
1152 return response
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
1170 if prisma_client is None:
1171 raise HTTPException(status_code=500, detail={"error": "No db connected"})
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 )
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 )
1194 if response is None:
1195 raise HTTPException(status_code=404, detail={"error": "Organization not found"})
1197 response_pydantic_obj: Final = LiteLLM_OrganizationTableWithMembers.model_validate(response.model_dump())
1199 return response_pydantic_obj
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
1216 if prisma_client is None:
1217 raise HTTPException(status_code=500, detail={"error": "No db connected"})
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 )
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 )
1233 response: Final = await _table(OrganizationRepository(prisma_client)).find_many(
1234 where={"organization_id": {"in": data.organizations}},
1235 include={"litellm_budget_table": True},
1236 )
1238 return response
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]
1256 Add new members (either via user_email or user_id) to an organization
1258 If user doesn't exist, new user row will also be added to User Table
1260 Only proxy_admin or org_admin of organization, allowed to access this endpoint.
1262 # Parameters:
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]
1270 Note: Either user_id or user_email must be provided for each member.
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 ```
1287 The following is executed in this function:
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
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"})
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 )
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 )
1321 members: Sequence[OrgMember]
1322 if isinstance(data.member, list):
1323 members = data.member
1324 else:
1325 members = [data.member]
1327 updated_users: Final[list[LiteLLM_UserTable]] = []
1328 updated_organization_memberships: Final[list[LiteLLM_OrganizationMembershipTable]] = []
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 )
1340 updated_users.append(updated_user)
1341 updated_organization_memberships.append(updated_organization_membership)
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 )
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 """
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
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
1407 if prisma_client is None:
1408 raise HTTPException(
1409 status_code=500,
1410 detail={"error": CommonProxyErrors.db_not_connected_error.value},
1411 )
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 )
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 )
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
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 )
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 )
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 )
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 )
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 )
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
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
1557 if prisma_client is None:
1558 raise HTTPException(
1559 status_code=500,
1560 detail={"error": CommonProxyErrors.db_not_connected_error.value},
1561 )
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 )
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
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
1586 except Exception as e:
1587 verbose_proxy_logger.exception("Error deleting member from organization: %s", e)
1588 raise e
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
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 """
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 )
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 )
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 )
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 )
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 )
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
1672 except Exception as e:
1673 raise ValueError(f"Error adding member={member} to organization={organization_id}: {e}")