mirror of
https://github.com/BerriAI/litellm.git
synced 2025-04-25 18:54:30 +00:00
Add remaining org CRUD endpoints + support deleting orgs on UI (#8561)
* feat(organization_endpoints.py): expose new `/organization/delete` endpoint. Cascade org deletion to member, teams and keys Ensures any org deletion is handled correctly * test(test_organizations.py): add simple test to ensure org deletion works * feat(organization_endpoints.py): expose /organization/update endpoint, and define response models for org delete + update * fix(organizations.tsx): support org delete on UI + move org/delete endpoint to use DELETE * feat(organization_endpoints.py): support `/organization/member_update` endpoint Allow admin to update member's role within org * feat(organization_endpoints.py): support deleting member from org * test(test_organizations.py): add e2e test to ensure org member flow works * fix(organization_endpoints.py): fix code qa check * fix(schema.prisma): don't introduce ondelete:cascade - breaking change * docs(organization_endpoints.py): document missing params * refactor(organization_view.tsx): initial commit creating a generic update member component shared between org and team member classes * feat(organization_view.tsx): support updating org member role on UI * feat(organization_view.tsx): allow proxy admin to delete members from org
This commit is contained in:
parent
1bac18a98c
commit
01ace78dcc
9 changed files with 932 additions and 94 deletions
File diff suppressed because one or more lines are too long
|
@ -5,7 +5,14 @@ from datetime import datetime
|
|||
from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Union
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel, ConfigDict, Field, Json, model_validator
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
Json,
|
||||
field_validator,
|
||||
model_validator,
|
||||
)
|
||||
from typing_extensions import Required, TypedDict
|
||||
|
||||
from litellm.types.integrations.slack_alerting import AlertType
|
||||
|
@ -393,6 +400,7 @@ class LiteLLMRoutes(enum.Enum):
|
|||
"/organization/info",
|
||||
"/organization/delete",
|
||||
"/organization/member_add",
|
||||
"/organization/member_update",
|
||||
]
|
||||
|
||||
# All routes accesible by an Org Admin
|
||||
|
@ -1110,6 +1118,10 @@ class OrganizationRequest(LiteLLMPydanticObjectBase):
|
|||
organizations: List[str]
|
||||
|
||||
|
||||
class DeleteOrganizationRequest(LiteLLMPydanticObjectBase):
|
||||
organization_ids: List[str] # required
|
||||
|
||||
|
||||
class KeyManagementSystem(enum.Enum):
|
||||
GOOGLE_KMS = "google_kms"
|
||||
AZURE_KEY_VAULT = "azure_key_vault"
|
||||
|
@ -1494,6 +1506,18 @@ class LiteLLM_OrganizationTable(LiteLLMPydanticObjectBase):
|
|||
updated_by: str
|
||||
|
||||
|
||||
class LiteLLM_OrganizationTableUpdate(LiteLLMPydanticObjectBase):
|
||||
"""Represents user-controllable params for a LiteLLM_OrganizationTable record"""
|
||||
|
||||
organization_id: Optional[str] = None
|
||||
organization_alias: Optional[str] = None
|
||||
budget_id: Optional[str] = None
|
||||
spend: Optional[float] = None
|
||||
metadata: Optional[dict] = None
|
||||
models: Optional[List[str]] = None
|
||||
updated_by: Optional[str] = None
|
||||
|
||||
|
||||
class LiteLLM_OrganizationTableWithMembers(LiteLLM_OrganizationTable):
|
||||
"""Returned by the /organization/info endpoint and /organization/list endpoint"""
|
||||
|
||||
|
@ -2097,8 +2121,26 @@ class OrganizationMemberDeleteRequest(MemberDeleteRequest):
|
|||
organization_id: str
|
||||
|
||||
|
||||
ROLES_WITHIN_ORG = [
|
||||
LitellmUserRoles.ORG_ADMIN,
|
||||
LitellmUserRoles.INTERNAL_USER,
|
||||
LitellmUserRoles.INTERNAL_USER_VIEW_ONLY,
|
||||
]
|
||||
|
||||
|
||||
class OrganizationMemberUpdateRequest(OrganizationMemberDeleteRequest):
|
||||
max_budget_in_organization: float
|
||||
max_budget_in_organization: Optional[float] = None
|
||||
role: Optional[LitellmUserRoles] = None
|
||||
|
||||
@field_validator("role")
|
||||
def validate_role(
|
||||
cls, value: Optional[LitellmUserRoles]
|
||||
) -> Optional[LitellmUserRoles]:
|
||||
if value is not None and value not in ROLES_WITHIN_ORG:
|
||||
raise ValueError(
|
||||
f"Invalid role. Must be one of: {[role.value for role in ROLES_WITHIN_ORG]}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
class OrganizationMemberUpdateResponse(MemberUpdateResponse):
|
||||
|
|
|
@ -19,6 +19,10 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status
|
|||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.proxy._types import *
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.management_endpoints.budget_management_endpoints import (
|
||||
new_budget,
|
||||
update_budget,
|
||||
)
|
||||
from litellm.proxy.management_helpers.utils import (
|
||||
get_new_internal_user_defaults,
|
||||
management_endpoint_wrapper,
|
||||
|
@ -179,24 +183,105 @@ async def new_organization(
|
|||
return response
|
||||
|
||||
|
||||
@router.post(
|
||||
@router.patch(
|
||||
"/organization/update",
|
||||
tags=["organization management"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
response_model=LiteLLM_OrganizationTableWithMembers,
|
||||
)
|
||||
async def update_organization():
|
||||
"""[TODO] Not Implemented yet. Let us know if you need this - https://github.com/BerriAI/litellm/issues"""
|
||||
raise NotImplementedError("Not Implemented Yet")
|
||||
async def update_organization(
|
||||
data: LiteLLM_OrganizationTableUpdate,
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
):
|
||||
"""
|
||||
Update an organization
|
||||
"""
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={"error": CommonProxyErrors.db_not_connected_error.value},
|
||||
)
|
||||
|
||||
if user_api_key_dict.user_id is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": "Cannot associate a user_id to this action. Check `/key/info` to validate if 'user_id' is set."
|
||||
},
|
||||
)
|
||||
|
||||
if data.updated_by is None:
|
||||
data.updated_by = user_api_key_dict.user_id
|
||||
|
||||
response = await prisma_client.db.litellm_organizationtable.update(
|
||||
where={"organization_id": data.organization_id},
|
||||
data=data.model_dump(exclude_none=True),
|
||||
include={"members": True, "teams": True, "litellm_budget_table": True},
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.post(
|
||||
@router.delete(
|
||||
"/organization/delete",
|
||||
tags=["organization management"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
response_model=List[LiteLLM_OrganizationTableWithMembers],
|
||||
)
|
||||
async def delete_organization():
|
||||
"""[TODO] Not Implemented yet. Let us know if you need this - https://github.com/BerriAI/litellm/issues"""
|
||||
raise NotImplementedError("Not Implemented Yet")
|
||||
async def delete_organization(
|
||||
data: DeleteOrganizationRequest,
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
):
|
||||
"""
|
||||
Delete an organization
|
||||
|
||||
# Parameters:
|
||||
|
||||
- organization_ids: List[str] - The organization ids to delete.
|
||||
"""
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={"error": CommonProxyErrors.db_not_connected_error.value},
|
||||
)
|
||||
|
||||
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={"error": "Only proxy admins can delete organizations"},
|
||||
)
|
||||
|
||||
deleted_orgs = []
|
||||
for organization_id in data.organization_ids:
|
||||
# delete all teams in the organization
|
||||
await prisma_client.db.litellm_teamtable.delete_many(
|
||||
where={"organization_id": organization_id}
|
||||
)
|
||||
# delete all members in the organization
|
||||
await prisma_client.db.litellm_organizationmembership.delete_many(
|
||||
where={"organization_id": organization_id}
|
||||
)
|
||||
# delete all keys in the organization
|
||||
await prisma_client.db.litellm_verificationtoken.delete_many(
|
||||
where={"organization_id": organization_id}
|
||||
)
|
||||
# delete the organization
|
||||
deleted_org = await prisma_client.db.litellm_organizationtable.delete(
|
||||
where={"organization_id": organization_id},
|
||||
include={"members": True, "teams": True, "litellm_budget_table": True},
|
||||
)
|
||||
if deleted_org is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={"error": f"Organization={organization_id} not found"},
|
||||
)
|
||||
deleted_orgs.append(deleted_org)
|
||||
|
||||
return deleted_orgs
|
||||
|
||||
|
||||
@router.get(
|
||||
|
@ -429,6 +514,213 @@ async def organization_member_add(
|
|||
)
|
||||
|
||||
|
||||
async def find_member_if_email(
|
||||
user_email: str, prisma_client: PrismaClient
|
||||
) -> LiteLLM_UserTable:
|
||||
"""
|
||||
Find a member if the user_email is in LiteLLM_UserTable
|
||||
"""
|
||||
|
||||
try:
|
||||
existing_user_email_row: BaseModel = (
|
||||
await prisma_client.db.litellm_usertable.find_unique(
|
||||
where={"user_email": user_email}
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"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."
|
||||
},
|
||||
)
|
||||
existing_user_email_row_pydantic = LiteLLM_UserTable(
|
||||
**existing_user_email_row.model_dump()
|
||||
)
|
||||
return existing_user_email_row_pydantic
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/organization/member_update",
|
||||
tags=["organization management"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
response_model=LiteLLM_OrganizationMembershipTable,
|
||||
)
|
||||
@management_endpoint_wrapper
|
||||
async def organization_member_update(
|
||||
data: OrganizationMemberUpdateRequest,
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
):
|
||||
"""
|
||||
Update a member's role in an organization
|
||||
"""
|
||||
try:
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={"error": CommonProxyErrors.db_not_connected_error.value},
|
||||
)
|
||||
|
||||
# Check if organization exists
|
||||
existing_organization_row = (
|
||||
await prisma_client.db.litellm_organizationtable.find_unique(
|
||||
where={"organization_id": data.organization_id}
|
||||
)
|
||||
)
|
||||
if existing_organization_row is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": f"Organization not found for organization_id={getattr(data, 'organization_id', None)}"
|
||||
},
|
||||
)
|
||||
|
||||
# Check if member exists in organization
|
||||
if data.user_email is not None and data.user_id is None:
|
||||
existing_user_email_row = await find_member_if_email(
|
||||
data.user_email, prisma_client
|
||||
)
|
||||
data.user_id = existing_user_email_row.user_id
|
||||
|
||||
try:
|
||||
existing_organization_membership = (
|
||||
await prisma_client.db.litellm_organizationmembership.find_unique(
|
||||
where={
|
||||
"user_id_organization_id": {
|
||||
"user_id": data.user_id,
|
||||
"organization_id": data.organization_id,
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": f"Error finding organization membership for user_id={data.user_id} in organization={data.organization_id}: {e}"
|
||||
},
|
||||
)
|
||||
if existing_organization_membership is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={
|
||||
"error": f"Member not found in organization for user_id={data.user_id}"
|
||||
},
|
||||
)
|
||||
|
||||
# Update member role
|
||||
if data.role is not None:
|
||||
await prisma_client.db.litellm_organizationmembership.update(
|
||||
where={
|
||||
"user_id_organization_id": {
|
||||
"user_id": data.user_id,
|
||||
"organization_id": data.organization_id,
|
||||
}
|
||||
},
|
||||
data={"user_role": data.role},
|
||||
)
|
||||
if data.max_budget_in_organization is not None:
|
||||
# if budget_id is None, create a new budget
|
||||
budget_id = existing_organization_membership.budget_id or str(uuid.uuid4())
|
||||
if existing_organization_membership.budget_id is None:
|
||||
new_budget_obj = BudgetNewRequest(
|
||||
budget_id=budget_id, max_budget=data.max_budget_in_organization
|
||||
)
|
||||
await new_budget(
|
||||
budget_obj=new_budget_obj, user_api_key_dict=user_api_key_dict
|
||||
)
|
||||
else:
|
||||
# update budget table with new max_budget
|
||||
await update_budget(
|
||||
budget_obj=BudgetNewRequest(
|
||||
budget_id=budget_id, max_budget=data.max_budget_in_organization
|
||||
),
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
)
|
||||
|
||||
# update organization membership with new budget_id
|
||||
await prisma_client.db.litellm_organizationmembership.update(
|
||||
where={
|
||||
"user_id_organization_id": {
|
||||
"user_id": data.user_id,
|
||||
"organization_id": data.organization_id,
|
||||
}
|
||||
},
|
||||
data={"budget_id": budget_id},
|
||||
)
|
||||
final_organization_membership: Optional[BaseModel] = (
|
||||
await prisma_client.db.litellm_organizationmembership.find_unique(
|
||||
where={
|
||||
"user_id_organization_id": {
|
||||
"user_id": data.user_id,
|
||||
"organization_id": data.organization_id,
|
||||
}
|
||||
},
|
||||
include={"litellm_budget_table": True},
|
||||
)
|
||||
)
|
||||
|
||||
if final_organization_membership is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": f"Member not found in organization={data.organization_id} for user_id={data.user_id}"
|
||||
},
|
||||
)
|
||||
|
||||
final_organization_membership_pydantic = LiteLLM_OrganizationMembershipTable(
|
||||
**final_organization_membership.model_dump(exclude_none=True)
|
||||
)
|
||||
return final_organization_membership_pydantic
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception(f"Error updating member in organization: {e}")
|
||||
raise e
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/organization/member_delete",
|
||||
tags=["organization management"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
)
|
||||
async def organization_member_delete(
|
||||
data: OrganizationMemberDeleteRequest,
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
):
|
||||
"""
|
||||
Delete a member from an organization
|
||||
"""
|
||||
try:
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={"error": CommonProxyErrors.db_not_connected_error.value},
|
||||
)
|
||||
|
||||
if data.user_email is not None and data.user_id is None:
|
||||
existing_user_email_row = await find_member_if_email(
|
||||
data.user_email, prisma_client
|
||||
)
|
||||
data.user_id = existing_user_email_row.user_id
|
||||
|
||||
member_to_delete = await prisma_client.db.litellm_organizationmembership.delete(
|
||||
where={
|
||||
"user_id_organization_id": {
|
||||
"user_id": data.user_id,
|
||||
"organization_id": data.organization_id,
|
||||
}
|
||||
}
|
||||
)
|
||||
return member_to_delete
|
||||
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception(f"Error deleting member from organization: {e}")
|
||||
raise e
|
||||
|
||||
|
||||
async def add_member_to_organization(
|
||||
member: OrgMember,
|
||||
organization_id: str,
|
||||
|
|
|
@ -7,6 +7,49 @@ import time, uuid
|
|||
from openai import AsyncOpenAI
|
||||
|
||||
|
||||
async def new_user(
|
||||
session,
|
||||
i,
|
||||
user_id=None,
|
||||
budget=None,
|
||||
budget_duration=None,
|
||||
models=["azure-models"],
|
||||
team_id=None,
|
||||
user_email=None,
|
||||
):
|
||||
url = "http://0.0.0.0:4000/user/new"
|
||||
headers = {"Authorization": "Bearer sk-1234", "Content-Type": "application/json"}
|
||||
data = {
|
||||
"models": models,
|
||||
"aliases": {"mistral-7b": "gpt-3.5-turbo"},
|
||||
"duration": None,
|
||||
"max_budget": budget,
|
||||
"budget_duration": budget_duration,
|
||||
"user_email": user_email,
|
||||
}
|
||||
|
||||
if user_id is not None:
|
||||
data["user_id"] = user_id
|
||||
|
||||
if team_id is not None:
|
||||
data["team_id"] = team_id
|
||||
|
||||
async with session.post(url, headers=headers, json=data) as response:
|
||||
status = response.status
|
||||
response_text = await response.text()
|
||||
|
||||
print(f"Response {i} (Status code: {status}):")
|
||||
print(response_text)
|
||||
print()
|
||||
|
||||
if status != 200:
|
||||
raise Exception(
|
||||
f"Request {i} did not return a 200 status code: {status}, response: {response_text}"
|
||||
)
|
||||
|
||||
return await response.json()
|
||||
|
||||
|
||||
async def new_organization(session, i, organization_alias, max_budget=None):
|
||||
url = "http://0.0.0.0:4000/organization/new"
|
||||
headers = {"Authorization": "Bearer sk-1234", "Content-Type": "application/json"}
|
||||
|
@ -30,6 +73,99 @@ async def new_organization(session, i, organization_alias, max_budget=None):
|
|||
return await response.json()
|
||||
|
||||
|
||||
async def add_member_to_org(
|
||||
session, i, organization_id, user_id, user_role="internal_user"
|
||||
):
|
||||
url = "http://0.0.0.0:4000/organization/member_add"
|
||||
headers = {"Authorization": "Bearer sk-1234", "Content-Type": "application/json"}
|
||||
data = {
|
||||
"organization_id": organization_id,
|
||||
"member": {
|
||||
"user_id": user_id,
|
||||
"role": user_role,
|
||||
},
|
||||
}
|
||||
|
||||
async with session.post(url, headers=headers, json=data) as response:
|
||||
status = response.status
|
||||
response_text = await response.text()
|
||||
|
||||
print(f"Response {i} (Status code: {status}):")
|
||||
print(response_text)
|
||||
print()
|
||||
|
||||
if status != 200:
|
||||
raise Exception(f"Request {i} did not return a 200 status code: {status}")
|
||||
|
||||
return await response.json()
|
||||
|
||||
|
||||
async def update_member_role(
|
||||
session, i, organization_id, user_id, user_role="internal_user"
|
||||
):
|
||||
url = "http://0.0.0.0:4000/organization/member_update"
|
||||
headers = {"Authorization": "Bearer sk-1234", "Content-Type": "application/json"}
|
||||
data = {
|
||||
"organization_id": organization_id,
|
||||
"user_id": user_id,
|
||||
"role": user_role,
|
||||
}
|
||||
|
||||
async with session.patch(url, headers=headers, json=data) as response:
|
||||
status = response.status
|
||||
response_text = await response.text()
|
||||
|
||||
print(f"Response {i} (Status code: {status}):")
|
||||
print(response_text)
|
||||
print()
|
||||
|
||||
if status != 200:
|
||||
raise Exception(f"Request {i} did not return a 200 status code: {status}")
|
||||
|
||||
return await response.json()
|
||||
|
||||
|
||||
async def delete_member_from_org(session, i, organization_id, user_id):
|
||||
url = "http://0.0.0.0:4000/organization/member_delete"
|
||||
headers = {"Authorization": "Bearer sk-1234", "Content-Type": "application/json"}
|
||||
data = {
|
||||
"organization_id": organization_id,
|
||||
"user_id": user_id,
|
||||
}
|
||||
|
||||
async with session.delete(url, headers=headers, json=data) as response:
|
||||
status = response.status
|
||||
response_text = await response.text()
|
||||
|
||||
print(f"Response {i} (Status code: {status}):")
|
||||
print(response_text)
|
||||
print()
|
||||
|
||||
if status != 200:
|
||||
raise Exception(f"Request {i} did not return a 200 status code: {status}")
|
||||
|
||||
return await response.json()
|
||||
|
||||
|
||||
async def delete_organization(session, i, organization_id):
|
||||
url = "http://0.0.0.0:4000/organization/delete"
|
||||
headers = {"Authorization": "Bearer sk-1234", "Content-Type": "application/json"}
|
||||
data = {"organization_ids": [organization_id]}
|
||||
|
||||
async with session.delete(url, headers=headers, json=data) as response:
|
||||
status = response.status
|
||||
response_text = await response.text()
|
||||
|
||||
print(f"Response {i} (Status code: {status}):")
|
||||
print(response_text)
|
||||
print()
|
||||
|
||||
if status != 200:
|
||||
raise Exception(f"Request {i} did not return a 200 status code: {status}")
|
||||
|
||||
return await response.json()
|
||||
|
||||
|
||||
async def list_organization(session, i):
|
||||
url = "http://0.0.0.0:4000/organization/list"
|
||||
headers = {"Authorization": "Bearer sk-1234", "Content-Type": "application/json"}
|
||||
|
@ -84,3 +220,91 @@ async def test_organization_list():
|
|||
|
||||
if len(response_json) == 0:
|
||||
raise Exception("Return empty list of organization")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_organization_delete():
|
||||
"""
|
||||
create a new organization
|
||||
delete the organization
|
||||
check if the Organization list is set
|
||||
"""
|
||||
organization_alias = f"Organization: {uuid.uuid4()}"
|
||||
async with aiohttp.ClientSession() as session:
|
||||
tasks = [
|
||||
new_organization(
|
||||
session=session, i=0, organization_alias=organization_alias
|
||||
)
|
||||
]
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
response_json = await list_organization(session, i=0)
|
||||
print(len(response_json))
|
||||
|
||||
organization_id = response_json[0]["organization_id"]
|
||||
await delete_organization(session, i=0, organization_id=organization_id)
|
||||
|
||||
response_json = await list_organization(session, i=0)
|
||||
print(len(response_json))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_organization_member_flow():
|
||||
"""
|
||||
create a new organization
|
||||
add a new member to the organization
|
||||
check if the member is added to the organization
|
||||
update the member's role in the organization
|
||||
delete the member from the organization
|
||||
check if the member is deleted from the organization
|
||||
"""
|
||||
organization_alias = f"Organization: {uuid.uuid4()}"
|
||||
async with aiohttp.ClientSession() as session:
|
||||
response_json = await new_organization(
|
||||
session=session, i=0, organization_alias=organization_alias
|
||||
)
|
||||
organization_id = response_json["organization_id"]
|
||||
|
||||
response_json = await list_organization(session, i=0)
|
||||
print(len(response_json))
|
||||
|
||||
new_user_response_json = await new_user(
|
||||
session=session, i=0, user_email=f"test_user_{uuid.uuid4()}@example.com"
|
||||
)
|
||||
user_id = new_user_response_json["user_id"]
|
||||
|
||||
await add_member_to_org(
|
||||
session, i=0, organization_id=organization_id, user_id=user_id
|
||||
)
|
||||
|
||||
response_json = await list_organization(session, i=0)
|
||||
print(len(response_json))
|
||||
|
||||
for orgs in response_json:
|
||||
tmp_organization_id = orgs["organization_id"]
|
||||
if (
|
||||
tmp_organization_id is not None
|
||||
and tmp_organization_id == organization_id
|
||||
):
|
||||
user_id = orgs["members"][0]["user_id"]
|
||||
|
||||
response_json = await list_organization(session, i=0)
|
||||
print(len(response_json))
|
||||
|
||||
await update_member_role(
|
||||
session,
|
||||
i=0,
|
||||
organization_id=organization_id,
|
||||
user_id=user_id,
|
||||
user_role="org_admin",
|
||||
)
|
||||
|
||||
response_json = await list_organization(session, i=0)
|
||||
print(len(response_json))
|
||||
|
||||
await delete_member_from_org(
|
||||
session, i=0, organization_id=organization_id, user_id=user_id
|
||||
)
|
||||
|
||||
response_json = await list_organization(session, i=0)
|
||||
print(len(response_json))
|
||||
|
|
|
@ -905,6 +905,37 @@ export const organizationCreateCall = async (
|
|||
}
|
||||
};
|
||||
|
||||
export const organizationDeleteCall = async (
|
||||
accessToken: string,
|
||||
organizationID: string
|
||||
) => {
|
||||
try {
|
||||
const url = proxyBaseUrl ? `${proxyBaseUrl}/organization/delete` : `/organization/delete`;
|
||||
const response = await fetch(url, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
organization_ids: [organizationID]
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.text();
|
||||
handleError(errorData);
|
||||
throw new Error(`Error deleting organization: ${errorData}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error("Failed to delete organization:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getTotalSpendCall = async (accessToken: String) => {
|
||||
/**
|
||||
* Get all models on proxy
|
||||
|
@ -2642,6 +2673,85 @@ export const organizationMemberAddCall = async (
|
|||
}
|
||||
};
|
||||
|
||||
export const organizationMemberDeleteCall = async (
|
||||
accessToken: string,
|
||||
organizationId: string,
|
||||
userId: string
|
||||
) => {
|
||||
try {
|
||||
console.log("Form Values in organizationMemberDeleteCall:", userId); // Log the form values before making the API call
|
||||
|
||||
const url = proxyBaseUrl
|
||||
? `${proxyBaseUrl}/organization/member_delete`
|
||||
: `/organization/member_delete`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
organization_id: organizationId,
|
||||
user_id: userId
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.text();
|
||||
handleError(errorData);
|
||||
console.error("Error response from the server:", errorData);
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log("API Response:", data);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error("Failed to delete organization member:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
export const organizationMemberUpdateCall = async (
|
||||
accessToken: string,
|
||||
organizationId: string,
|
||||
formValues: Member // Assuming formValues is an object
|
||||
) => {
|
||||
try {
|
||||
console.log("Form Values in organizationMemberUpdateCall:", formValues); // Log the form values before making the API call
|
||||
|
||||
const url = proxyBaseUrl
|
||||
? `${proxyBaseUrl}/organization/member_update`
|
||||
: `/organization/member_update`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
organization_id: organizationId,
|
||||
...formValues, // Include formValues in the request body
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.text();
|
||||
handleError(errorData);
|
||||
console.error("Error response from the server:", errorData);
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log("API Response:", data);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error("Failed to update organization member:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const userUpdateUserCall = async (
|
||||
accessToken: string,
|
||||
formValues: any, // Assuming formValues is an object
|
||||
|
|
|
@ -23,9 +23,9 @@ import { Button, Form, Input, Select, message, InputNumber, Tooltip } from "antd
|
|||
import { InfoCircleOutlined } from '@ant-design/icons';
|
||||
import { PencilAltIcon, TrashIcon } from "@heroicons/react/outline";
|
||||
import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key";
|
||||
import { Member, Organization, organizationInfoCall, organizationMemberAddCall } from "../networking";
|
||||
import { Member, Organization, organizationInfoCall, organizationMemberAddCall, organizationMemberUpdateCall, organizationMemberDeleteCall } from "../networking";
|
||||
import UserSearchModal from "../common_components/user_search_modal";
|
||||
|
||||
import MemberModal from "../team/edit_membership";
|
||||
|
||||
interface OrganizationInfoProps {
|
||||
organizationId: string;
|
||||
|
@ -51,6 +51,8 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
|
|||
const [form] = Form.useForm();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isAddMemberModalVisible, setIsAddMemberModalVisible] = useState(false);
|
||||
const [isEditMemberModalVisible, setIsEditMemberModalVisible] = useState(false);
|
||||
const [selectedEditMember, setSelectedEditMember] = useState<Member | null>(null);
|
||||
|
||||
const canEditOrg = is_org_admin || is_proxy_admin;
|
||||
|
||||
|
@ -95,6 +97,42 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
const handleMemberUpdate = async (values: any) => {
|
||||
try {
|
||||
if (!accessToken) return;
|
||||
|
||||
const member: Member = {
|
||||
user_email: values.user_email,
|
||||
user_id: values.user_id,
|
||||
role: values.role,
|
||||
}
|
||||
|
||||
const response = await organizationMemberUpdateCall(accessToken, organizationId, member);
|
||||
message.success("Organization member updated successfully");
|
||||
setIsEditMemberModalVisible(false);
|
||||
form.resetFields();
|
||||
fetchOrgInfo();
|
||||
} catch (error) {
|
||||
message.error("Failed to update organization member");
|
||||
console.error("Error updating organization member:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMemberDelete = async (values: any) => {
|
||||
try {
|
||||
if (!accessToken) return;
|
||||
|
||||
await organizationMemberDeleteCall(accessToken, organizationId, values.user_id);
|
||||
message.success("Organization member deleted successfully");
|
||||
setIsEditMemberModalVisible(false);
|
||||
form.resetFields();
|
||||
fetchOrgInfo();
|
||||
} catch (error) {
|
||||
message.error("Failed to delete organization member");
|
||||
console.error("Error deleting organization member:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOrgUpdate = async (values: any) => {
|
||||
try {
|
||||
if (!accessToken) return;
|
||||
|
@ -241,14 +279,19 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
|
|||
icon={PencilAltIcon}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// TODO: Implement edit member functionality
|
||||
setSelectedEditMember({
|
||||
"role": member.user_role,
|
||||
"user_email": member.user_email,
|
||||
"user_id": member.user_id
|
||||
});
|
||||
setIsEditMemberModalVisible(true);
|
||||
}}
|
||||
/>
|
||||
<Icon
|
||||
icon={TrashIcon}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// TODO: Implement delete member functionality
|
||||
handleMemberDelete(member);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
@ -403,6 +446,23 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
|
|||
]}
|
||||
defaultRole="internal_user"
|
||||
/>
|
||||
<MemberModal
|
||||
visible={isEditMemberModalVisible}
|
||||
onCancel={() => setIsEditMemberModalVisible(false)}
|
||||
onSubmit={handleMemberUpdate}
|
||||
initialData={selectedEditMember}
|
||||
mode="edit"
|
||||
config={{
|
||||
title: "Edit Member",
|
||||
showEmail: true,
|
||||
showUserId: true,
|
||||
roleOptions: [
|
||||
{ label: "Org Admin", value: "org_admin" },
|
||||
{ label: "Internal User", value: "internal_user" },
|
||||
{ label: "Internal User Viewer", value: "internal_user_viewer" }
|
||||
]
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -24,8 +24,9 @@ import { InfoCircleOutlined } from '@ant-design/icons';
|
|||
import { PencilAltIcon, TrashIcon, RefreshIcon } from "@heroicons/react/outline";
|
||||
import { TextInput } from "@tremor/react";
|
||||
import { getModelDisplayName } from './key_team_helpers/fetch_available_models_team_key';
|
||||
import { message } from 'antd';
|
||||
import OrganizationInfoView from './organization/organization_view';
|
||||
import { Organization, organizationListCall, organizationCreateCall } from './networking';
|
||||
import { Organization, organizationListCall, organizationCreateCall, organizationDeleteCall } from './networking';
|
||||
interface OrganizationsTableProps {
|
||||
organizations: Organization[];
|
||||
userRole: string;
|
||||
|
@ -80,18 +81,13 @@ const OrganizationsTable: React.FC<OrganizationsTableProps> = ({
|
|||
if (!orgToDelete || !accessToken) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/organization/delete?organization_id=${orgToDelete}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to delete organization');
|
||||
await organizationDeleteCall(accessToken, orgToDelete);
|
||||
message.success('Organization deleted successfully');
|
||||
|
||||
setIsDeleteModalOpen(false);
|
||||
setOrgToDelete(null);
|
||||
// Refresh organizations list
|
||||
fetchOrganizations(accessToken, setOrganizations);
|
||||
} catch (error) {
|
||||
console.error('Error deleting organization:', error);
|
||||
}
|
||||
|
|
|
@ -2,58 +2,130 @@ import React, { useState, useEffect } from 'react';
|
|||
import { Modal, Form, Input, Select as AntSelect, Button as AntButton, message } from 'antd';
|
||||
import { Select, SelectItem } from "@tremor/react";
|
||||
import { Card, Text } from "@tremor/react";
|
||||
import { Member } from "@/components/networking";
|
||||
interface TeamMemberModalProps {
|
||||
visible: boolean;
|
||||
onCancel: () => void;
|
||||
onSubmit: (data: Member) => void;
|
||||
initialData?: Member | null;
|
||||
mode: 'add' | 'edit';
|
||||
|
||||
interface BaseMember {
|
||||
user_email?: string;
|
||||
user_id?: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
const TeamMemberModal: React.FC<TeamMemberModalProps> = ({
|
||||
interface ModalConfig {
|
||||
title: string;
|
||||
roleOptions: Array<{
|
||||
label: string;
|
||||
value: string;
|
||||
}>;
|
||||
defaultRole?: string;
|
||||
showEmail?: boolean;
|
||||
showUserId?: boolean;
|
||||
additionalFields?: Array<{
|
||||
name: string;
|
||||
label: string;
|
||||
type: 'input' | 'select';
|
||||
options?: Array<{ label: string; value: string }>;
|
||||
rules?: any[];
|
||||
}>;
|
||||
}
|
||||
|
||||
interface MemberModalProps<T extends BaseMember> {
|
||||
visible: boolean;
|
||||
onCancel: () => void;
|
||||
onSubmit: (data: T) => void;
|
||||
initialData?: T | null;
|
||||
mode: 'add' | 'edit';
|
||||
config: ModalConfig;
|
||||
}
|
||||
|
||||
const MemberModal = <T extends BaseMember>({
|
||||
visible,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
initialData,
|
||||
mode
|
||||
}) => {
|
||||
mode,
|
||||
config
|
||||
}: MemberModalProps<T>) => {
|
||||
const [form] = Form.useForm();
|
||||
|
||||
console.log("Initial Data:", initialData);
|
||||
|
||||
// Reset form and set initial values when modal becomes visible or initialData changes
|
||||
useEffect(() => {
|
||||
if (initialData) {
|
||||
if (visible) {
|
||||
if (mode === 'edit' && initialData) {
|
||||
// For edit mode, use the initialData values
|
||||
form.setFieldsValue({
|
||||
user_email: initialData.user_email,
|
||||
user_id: initialData.user_id,
|
||||
role: initialData.role,
|
||||
...initialData,
|
||||
// Ensure role is set correctly for editing
|
||||
role: initialData.role || config.defaultRole
|
||||
});
|
||||
} else {
|
||||
// For add mode, reset to defaults
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
role: config.defaultRole || config.roleOptions[0]?.value
|
||||
});
|
||||
}
|
||||
}, [initialData, form]);
|
||||
|
||||
}
|
||||
}, [visible, initialData, mode, form, config.defaultRole, config.roleOptions]);
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
try {
|
||||
const formData: Member = {
|
||||
user_email: values.user_email,
|
||||
user_id: values.user_id,
|
||||
role: values.role,
|
||||
};
|
||||
// Trim string values
|
||||
const formData = Object.entries(values).reduce((acc, [key, value]) => ({
|
||||
...acc,
|
||||
[key]: typeof value === 'string' ? value.trim() : value
|
||||
}), {}) as T;
|
||||
|
||||
onSubmit(formData);
|
||||
form.resetFields();
|
||||
message.success(`Successfully ${mode === 'add' ? 'added' : 'updated'} team member`);
|
||||
message.success(`Successfully ${mode === 'add' ? 'added' : 'updated'} member`);
|
||||
} catch (error) {
|
||||
message.error('Failed to submit form');
|
||||
console.error('Form submission error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to get role label from value
|
||||
const getRoleLabel = (value: string) => {
|
||||
return config.roleOptions.find(option => option.value === value)?.label || value;
|
||||
};
|
||||
|
||||
const renderField = (field: {
|
||||
name: string;
|
||||
label: string;
|
||||
type: 'input' | 'select';
|
||||
options?: Array<{ label: string; value: string }>;
|
||||
rules?: any[];
|
||||
}) => {
|
||||
switch (field.type) {
|
||||
case 'input':
|
||||
return (
|
||||
<Input
|
||||
className="px-3 py-2 border rounded-md w-full"
|
||||
onChange={(e) => {
|
||||
e.target.value = e.target.value.trim();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case 'select':
|
||||
return (
|
||||
<AntSelect>
|
||||
{field.options?.map(option => (
|
||||
<AntSelect.Option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</AntSelect.Option>
|
||||
))}
|
||||
</AntSelect>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={mode === 'add' ? "Add Team Member" : "Edit Team Member"}
|
||||
visible={visible}
|
||||
title={config.title || (mode === 'add' ? "Add Member" : "Edit Member")}
|
||||
open={visible}
|
||||
width={800}
|
||||
footer={null}
|
||||
onCancel={onCancel}
|
||||
|
@ -64,12 +136,8 @@ const TeamMemberModal: React.FC<TeamMemberModalProps> = ({
|
|||
labelCol={{ span: 8 }}
|
||||
wrapperCol={{ span: 16 }}
|
||||
labelAlign="left"
|
||||
initialValues={{
|
||||
user_email: initialData?.user_email?.trim() || '',
|
||||
user_id: initialData?.user_id?.trim() || '',
|
||||
role: initialData?.role || 'user',
|
||||
}}
|
||||
>
|
||||
{config.showEmail && (
|
||||
<Form.Item
|
||||
label="Email"
|
||||
name="user_email"
|
||||
|
@ -79,7 +147,6 @@ const TeamMemberModal: React.FC<TeamMemberModalProps> = ({
|
|||
]}
|
||||
>
|
||||
<Input
|
||||
name="user_email"
|
||||
className="px-3 py-2 border rounded-md w-full"
|
||||
placeholder="user@example.com"
|
||||
onChange={(e) => {
|
||||
|
@ -87,18 +154,21 @@ const TeamMemberModal: React.FC<TeamMemberModalProps> = ({
|
|||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{config.showEmail && config.showUserId && (
|
||||
<div className="text-center mb-4">
|
||||
<Text>OR</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config.showUserId && (
|
||||
<Form.Item
|
||||
label="User ID"
|
||||
name="user_id"
|
||||
className="mb-4"
|
||||
>
|
||||
<Input
|
||||
name="user_id"
|
||||
className="px-3 py-2 border rounded-md w-full"
|
||||
placeholder="user_123"
|
||||
onChange={(e) => {
|
||||
|
@ -106,25 +176,61 @@ const TeamMemberModal: React.FC<TeamMemberModalProps> = ({
|
|||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
label="Member Role"
|
||||
label={
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Role</span>
|
||||
{mode === 'edit' && initialData && (
|
||||
<span className="text-gray-500 text-sm">
|
||||
(Current: {getRoleLabel(initialData.role)})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
name="role"
|
||||
className="mb-4"
|
||||
rules={[
|
||||
{ required: true, message: 'Please select a role!' }
|
||||
]}
|
||||
>
|
||||
<AntSelect defaultValue="user">
|
||||
<AntSelect.Option value="admin">admin</AntSelect.Option>
|
||||
<AntSelect.Option value="user">user</AntSelect.Option>
|
||||
<AntSelect>
|
||||
{mode === 'edit' && initialData
|
||||
? [
|
||||
// Current role first
|
||||
...config.roleOptions.filter(option => option.value === initialData.role),
|
||||
// Then all other roles
|
||||
...config.roleOptions.filter(option => option.value !== initialData.role)
|
||||
].map(option => (
|
||||
<AntSelect.Option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</AntSelect.Option>
|
||||
))
|
||||
: config.roleOptions.map(option => (
|
||||
<AntSelect.Option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</AntSelect.Option>
|
||||
))}
|
||||
</AntSelect>
|
||||
</Form.Item>
|
||||
|
||||
<div style={{ textAlign: "right", marginTop: "20px" }}>
|
||||
{config.additionalFields?.map(field => (
|
||||
<Form.Item
|
||||
key={field.name}
|
||||
label={field.label}
|
||||
name={field.name}
|
||||
className="mb-4"
|
||||
rules={field.rules}
|
||||
>
|
||||
{renderField(field)}
|
||||
</Form.Item>
|
||||
))}
|
||||
|
||||
<div className="text-right mt-6">
|
||||
<AntButton
|
||||
onClick={onCancel}
|
||||
style={{ marginRight: 8 }}
|
||||
className="mr-2"
|
||||
>
|
||||
Cancel
|
||||
</AntButton>
|
||||
|
@ -140,4 +246,4 @@ const TeamMemberModal: React.FC<TeamMemberModalProps> = ({
|
|||
);
|
||||
};
|
||||
|
||||
export default TeamMemberModal;
|
||||
export default MemberModal;
|
|
@ -26,7 +26,7 @@ import {
|
|||
Select as Select2,
|
||||
} from "antd";
|
||||
import { PencilAltIcon, PlusIcon, TrashIcon } from "@heroicons/react/outline";
|
||||
import TeamMemberModal from "./edit_membership";
|
||||
import MemberModal from "./edit_membership";
|
||||
import UserSearchModal from "@/components/common_components/user_search_modal";
|
||||
import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key";
|
||||
import ModelAliasesCard from "./model_aliases_card";
|
||||
|
@ -486,12 +486,21 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
</TabPanels>
|
||||
</TabGroup>
|
||||
|
||||
<TeamMemberModal
|
||||
<MemberModal
|
||||
visible={isEditMemberModalVisible}
|
||||
onCancel={() => setIsEditMemberModalVisible(false)}
|
||||
onSubmit={handleMemberUpdate}
|
||||
initialData={selectedEditMember}
|
||||
mode="edit"
|
||||
config={{
|
||||
title: "Edit Member",
|
||||
showEmail: true,
|
||||
showUserId: true,
|
||||
roleOptions: [
|
||||
{ label: "Admin", value: "admin" },
|
||||
{ label: "User", value: "user" }
|
||||
]
|
||||
}}
|
||||
/>
|
||||
|
||||
<UserSearchModal
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue