From 65d3f85a693e3232e02d557ba97562d9c1c594ab Mon Sep 17 00:00:00 2001 From: Krish Dholakia Date: Sun, 2 Feb 2025 23:02:33 -0800 Subject: [PATCH] Easier user onboarding via SSO (#8187) * fix(ui_sso.py): use common `get_user_object` logic across jwt + ui sso auth Allows finding users by their email, and attaching the sso user id to the user if found * Improve Team Management flow on UI (#8204) * build(teams.tsx): refactor teams page to make it easier to add members to a team make a row in table clickable -> allows user to add users to team they intended * build(teams.tsx): make it clear user should click on team id to view team details simplifies team management by putting team details on separate page * build(team_info.tsx): separately show user id and user email make it easy for user to understand the information they're seeing * build(team_info.tsx): add back in 'add member' button * build(team_info.tsx): working team member update on team_info.tsx * build(team_info.tsx): enable team member delete on ui allow user to delete accidental adds * build(internal_user_endpoints.py): expose new endpoint for ui to allow filtering on user table allows proxy admin to quickly find user they're looking for * feat(team_endpoints.py): expose new team filter endpoint for ui allows proxy admin to easily find team they're looking for * feat(user_search_modal.tsx): allow admin to filter on users when adding new user to teams * test: mark flaky test * test: mark flaky test * fix(exception_mapping_utils.py): fix anthropic text route error * fix(ui_sso.py): handle situation when user not in db --- .../exception_mapping_utils.py | 13 +- .../internal_user_endpoints.py | 78 +++++ .../management_endpoints/team_endpoints.py | 78 +++++ litellm/proxy/management_endpoints/ui_sso.py | 42 ++- .../test_anthropic_text_completion.py | 1 + .../test_langfuse_e2e_test.py | 1 + .../proxy_admin_ui_tests/test_sso_sign_in.py | 3 + ui/litellm-dashboard/package-lock.json | 7 + ui/litellm-dashboard/package.json | 1 + .../common_components/user_search_modal.tsx | 175 ++++++++++ .../src/components/networking.tsx | 71 ++++ .../src/components/team/edit_membership.tsx | 35 +- .../src/components/team/team_info.tsx | 326 ++++++++++++++++++ ui/litellm-dashboard/src/components/teams.tsx | 142 ++++---- 14 files changed, 862 insertions(+), 111 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/common_components/user_search_modal.tsx create mode 100644 ui/litellm-dashboard/src/components/team/team_info.tsx diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index 866c32fe05..648330241e 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -14,6 +14,7 @@ from ..exceptions import ( BadRequestError, ContentPolicyViolationError, ContextWindowExceededError, + InternalServerError, NotFoundError, PermissionDeniedError, RateLimitError, @@ -467,7 +468,10 @@ def exception_type( # type: ignore # noqa: PLR0915 method="POST", url="https://api.openai.com/v1/" ), ) - elif custom_llm_provider == "anthropic": # one of the anthropics + elif ( + custom_llm_provider == "anthropic" + or custom_llm_provider == "anthropic_text" + ): # one of the anthropics if "prompt is too long" in error_str or "prompt: length" in error_str: exception_mapping_worked = True raise ContextWindowExceededError( @@ -475,6 +479,13 @@ def exception_type( # type: ignore # noqa: PLR0915 model=model, llm_provider="anthropic", ) + elif "overloaded_error" in error_str: + exception_mapping_worked = True + raise InternalServerError( + message="AnthropicError - {}".format(error_str), + model=model, + llm_provider="anthropic", + ) if "Invalid API Key" in error_str: exception_mapping_worked = True raise AuthenticationError( diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index e0717f0886..d9f64ea752 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -926,3 +926,81 @@ async def add_internal_user_to_organization( return new_membership except Exception as e: raise Exception(f"Failed to add user to organization: {str(e)}") + + +@router.get( + "/user/filter/ui", + tags=["Internal User management"], + dependencies=[Depends(user_api_key_auth)], + include_in_schema=False, + responses={ + 200: {"model": List[LiteLLM_UserTable]}, + }, +) +async def ui_view_users( + user_id: Optional[str] = fastapi.Query( + default=None, description="User ID in the request parameters" + ), + user_email: Optional[str] = fastapi.Query( + default=None, description="User email in the request parameters" + ), + page: int = fastapi.Query( + default=1, description="Page number for pagination", ge=1 + ), + page_size: int = fastapi.Query( + default=50, description="Number of items per page", ge=1, le=100 + ), + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """ + [PROXY-ADMIN ONLY]Filter users based on partial match of user_id or email with pagination. + + Args: + user_id (Optional[str]): Partial user ID to search for + user_email (Optional[str]): Partial email to search for + page (int): Page number for pagination (starts at 1) + page_size (int): Number of items per page (max 100) + user_api_key_dict (UserAPIKeyAuth): User authentication information + + Returns: + List[LiteLLM_SpendLogs]: Paginated list of matching user records + """ + from litellm.proxy.proxy_server import prisma_client + + if prisma_client is None: + raise HTTPException(status_code=500, detail={"error": "No db connected"}) + + try: + # Calculate offset for pagination + skip = (page - 1) * page_size + + # Build where conditions based on provided parameters + where_conditions = {} + + if user_id: + where_conditions["user_id"] = { + "contains": user_id, + "mode": "insensitive", # Case-insensitive search + } + + if user_email: + where_conditions["user_email"] = { + "contains": user_email, + "mode": "insensitive", # Case-insensitive search + } + + # Query users with pagination and filters + users = await prisma_client.db.litellm_usertable.find_many( + where=where_conditions, + skip=skip, + take=page_size, + order={"created_at": "desc"}, + ) + + if not users: + return [] + + return users + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error searching users: {str(e)}") diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index afc423cc5f..b57411d556 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -1637,3 +1637,81 @@ def _set_team_metadata_field( _premium_user_check() team_data.metadata = team_data.metadata or {} team_data.metadata[field_name] = value + + +@router.get( + "/team/filter/ui", + tags=["team management"], + dependencies=[Depends(user_api_key_auth)], + include_in_schema=False, + responses={ + 200: {"model": List[LiteLLM_TeamTable]}, + }, +) +async def ui_view_teams( + team_id: Optional[str] = fastapi.Query( + default=None, description="Team ID in the request parameters" + ), + team_alias: Optional[str] = fastapi.Query( + default=None, description="Team alias in the request parameters" + ), + page: int = fastapi.Query( + default=1, description="Page number for pagination", ge=1 + ), + page_size: int = fastapi.Query( + default=50, description="Number of items per page", ge=1, le=100 + ), + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """ + [PROXY-ADMIN ONLY] Filter teams based on partial match of team_id or team_alias with pagination. + + Args: + user_id (Optional[str]): Partial user ID to search for + user_email (Optional[str]): Partial email to search for + page (int): Page number for pagination (starts at 1) + page_size (int): Number of items per page (max 100) + user_api_key_dict (UserAPIKeyAuth): User authentication information + + Returns: + List[LiteLLM_SpendLogs]: Paginated list of matching user records + """ + from litellm.proxy.proxy_server import prisma_client + + if prisma_client is None: + raise HTTPException(status_code=500, detail={"error": "No db connected"}) + + try: + # Calculate offset for pagination + skip = (page - 1) * page_size + + # Build where conditions based on provided parameters + where_conditions = {} + + if team_id: + where_conditions["team_id"] = { + "contains": team_id, + "mode": "insensitive", # Case-insensitive search + } + + if team_alias: + where_conditions["team_alias"] = { + "contains": team_alias, + "mode": "insensitive", # Case-insensitive search + } + + # Query users with pagination and filters + teams = await prisma_client.db.litellm_teamtable.find_many( + where=where_conditions, + skip=skip, + take=page_size, + order={"created_at": "desc"}, + ) + + if not teams: + return [] + + return teams + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error searching teams: {str(e)}") diff --git a/litellm/proxy/management_endpoints/ui_sso.py b/litellm/proxy/management_endpoints/ui_sso.py index f6366f1067..86dec9fcaf 100644 --- a/litellm/proxy/management_endpoints/ui_sso.py +++ b/litellm/proxy/management_endpoints/ui_sso.py @@ -8,7 +8,7 @@ Has all /sso/* routes import asyncio import os import uuid -from typing import TYPE_CHECKING, Any, Dict, List, Optional, cast +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union, cast from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi.responses import RedirectResponse @@ -17,6 +17,7 @@ import litellm from litellm._logging import verbose_proxy_logger from litellm.constants import MAX_SPENDLOG_ROWS_TO_QUERY from litellm.proxy._types import ( + LiteLLM_UserTable, LitellmUserRoles, Member, NewUserRequest, @@ -27,6 +28,7 @@ from litellm.proxy._types import ( TeamMemberAddRequest, UserAPIKeyAuth, ) +from litellm.proxy.auth.auth_checks import get_user_object from litellm.proxy.auth.auth_utils import _has_user_setup_sso from litellm.proxy.auth.handle_jwt import JWTHandler from litellm.proxy.auth.user_api_key_auth import user_api_key_auth @@ -368,7 +370,9 @@ async def create_team_member_add_task(team_id, user_info): ) -async def add_missing_team_member(user_info: NewUserResponse, sso_teams: List[str]): +async def add_missing_team_member( + user_info: Union[NewUserResponse, LiteLLM_UserTable], sso_teams: List[str] +): """ - Get missing teams (diff b/w user_info.team_ids and sso_teams) - Add missing user to missing teams @@ -414,7 +418,9 @@ async def auth_callback(request: Request): # noqa: PLR0915 master_key, premium_user, prisma_client, + proxy_logging_obj, ui_access_mode, + user_api_key_cache, user_custom_sso, ) @@ -557,20 +563,30 @@ async def auth_callback(request: Request): # noqa: PLR0915 user_role = None try: if prisma_client is not None: - user_info = await prisma_client.get_data(user_id=user_id, table_name="user") + try: + user_info = await get_user_object( + user_id=user_id, + user_email=user_email, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + user_id_upsert=False, + parent_otel_span=None, + proxy_logging_obj=proxy_logging_obj, + sso_user_id=user_id, + ) + except Exception as e: + verbose_proxy_logger.debug(f"Error getting user object: {e}") + user_info = None + verbose_proxy_logger.debug( f"user_info: {user_info}; litellm.default_internal_user_params: {litellm.default_internal_user_params}" ) - if user_info is None: - ## check if user-email in db ## - user_info = await prisma_client.db.litellm_usertable.find_first( - where={"user_email": user_email} - ) - if user_info is not None and user_id is not None: + if user_info is not None: + user_id = user_info.user_id user_defined_values = SSOUserDefinedValues( models=getattr(user_info, "models", user_id_models), - user_id=user_id, + user_id=user_info.user_id, user_email=getattr(user_info, "user_email", user_email), user_role=getattr(user_info, "user_role", None), max_budget=getattr( @@ -588,6 +604,9 @@ async def auth_callback(request: Request): # noqa: PLR0915 where={"user_email": user_email}, data={"user_id": user_id} # type: ignore ) else: + verbose_proxy_logger.info( + "user not in DB, inserting user into LiteLLM DB" + ) # user not in DB, insert User into LiteLLM DB user_info = await insert_sso_user( result_openid=result, @@ -624,9 +643,6 @@ async def auth_callback(request: Request): # noqa: PLR0915 key = response["token"] # type: ignore user_id = response["user_id"] # type: ignore - # This should always be true - # User_id on SSO == user_id in the LiteLLM_VerificationToken Table - assert user_id == _user_id_from_sso litellm_dashboard_ui = "/ui/" user_role = user_role or LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value if ( diff --git a/tests/llm_translation/test_anthropic_text_completion.py b/tests/llm_translation/test_anthropic_text_completion.py index 3a18d1ccbd..c0aa5b5d88 100644 --- a/tests/llm_translation/test_anthropic_text_completion.py +++ b/tests/llm_translation/test_anthropic_text_completion.py @@ -24,6 +24,7 @@ import pytest @pytest.mark.asyncio @pytest.mark.parametrize("model", ["claude-2", "anthropic/claude-2"]) +@pytest.mark.flaky(retries=6, delay=1) async def test_acompletion_claude2(model): try: litellm.set_verbose = True diff --git a/tests/logging_callback_tests/test_langfuse_e2e_test.py b/tests/logging_callback_tests/test_langfuse_e2e_test.py index 60d25b3340..79197d6c25 100644 --- a/tests/logging_callback_tests/test_langfuse_e2e_test.py +++ b/tests/logging_callback_tests/test_langfuse_e2e_test.py @@ -327,6 +327,7 @@ class TestLangfuseLogging: ({}, "empty_metadata.json"), ], ) + @pytest.mark.flaky(retries=6, delay=1) async def test_langfuse_logging_with_various_metadata_types( self, mock_setup, test_metadata, response_json_file ): diff --git a/tests/proxy_admin_ui_tests/test_sso_sign_in.py b/tests/proxy_admin_ui_tests/test_sso_sign_in.py index 17ee445ace..3d5dd9ffcc 100644 --- a/tests/proxy_admin_ui_tests/test_sso_sign_in.py +++ b/tests/proxy_admin_ui_tests/test_sso_sign_in.py @@ -61,6 +61,9 @@ async def test_auth_callback_new_user(mock_google_sso, mock_env_vars, prisma_cli Tests that a new SSO Sign In user is by default given an 'INTERNAL_USER_VIEW_ONLY' role """ import uuid + import litellm + + litellm._turn_on_debug() # Generate a unique user ID unique_user_id = str(uuid.uuid4()) diff --git a/ui/litellm-dashboard/package-lock.json b/ui/litellm-dashboard/package-lock.json index 6cc6f2eccc..b944efe4cd 100644 --- a/ui/litellm-dashboard/package-lock.json +++ b/ui/litellm-dashboard/package-lock.json @@ -30,6 +30,7 @@ }, "devDependencies": { "@tailwindcss/forms": "^0.5.7", + "@types/lodash": "^4.17.15", "@types/node": "^20", "@types/react": "18.2.48", "@types/react-copy-to-clipboard": "^5.0.7", @@ -944,6 +945,12 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/lodash": { + "version": "4.17.15", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.15.tgz", + "integrity": "sha512-w/P33JFeySuhN6JLkysYUK2gEmy9kHHFN7E8ro0tkfmlDOgxBDzWEZ/J8cWA+fHqFevpswDTFZnDx+R9lbL6xw==", + "dev": true + }, "node_modules/@types/mdast": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.3.tgz", diff --git a/ui/litellm-dashboard/package.json b/ui/litellm-dashboard/package.json index 407ca262d4..9bf0b7ec49 100644 --- a/ui/litellm-dashboard/package.json +++ b/ui/litellm-dashboard/package.json @@ -31,6 +31,7 @@ }, "devDependencies": { "@tailwindcss/forms": "^0.5.7", + "@types/lodash": "^4.17.15", "@types/node": "^20", "@types/react": "18.2.48", "@types/react-copy-to-clipboard": "^5.0.7", diff --git a/ui/litellm-dashboard/src/components/common_components/user_search_modal.tsx b/ui/litellm-dashboard/src/components/common_components/user_search_modal.tsx new file mode 100644 index 0000000000..e31c038482 --- /dev/null +++ b/ui/litellm-dashboard/src/components/common_components/user_search_modal.tsx @@ -0,0 +1,175 @@ +import { useState, useCallback } from 'react'; +import { Modal, Form, Button, Select } from 'antd'; +import debounce from 'lodash/debounce'; +import { userFilterUICall } from "@/components/networking"; + +interface User { + user_id: string; + user_email: string; + role?: string; +} + +interface UserOption { + label: string; + value: string; + user: User; +} + +interface FormValues { + user_email: string; + user_id: string; + role: 'admin' | 'user'; +} + +interface UserSearchModalProps { + isVisible: boolean; + onCancel: () => void; + onSubmit: (values: FormValues) => void; + accessToken: string | null; +} + +const UserSearchModal: React.FC = ({ + isVisible, + onCancel, + onSubmit, + accessToken +}) => { + const [form] = Form.useForm(); + const [userOptions, setUserOptions] = useState([]); + const [loading, setLoading] = useState(false); + const [selectedField, setSelectedField] = useState<'user_email' | 'user_id'>('user_email'); + + const fetchUsers = async (searchText: string, fieldName: 'user_email' | 'user_id'): Promise => { + if (!searchText) { + setUserOptions([]); + return; + } + + setLoading(true); + try { + const params = new URLSearchParams(); + params.append(fieldName, searchText); + if (accessToken == null) { + return; + } + const response = await userFilterUICall(accessToken, params); + + const data: User[] = response + const options: UserOption[] = data.map(user => ({ + label: fieldName === 'user_email' + ? `${user.user_email}` + : `${user.user_id}`, + value: fieldName === 'user_email' ? user.user_email : user.user_id, + user + })); + setUserOptions(options); + } catch (error) { + console.error('Error fetching users:', error); + } finally { + setLoading(false); + } + }; + + const debouncedSearch = useCallback( + debounce((text: string, fieldName: 'user_email' | 'user_id') => fetchUsers(text, fieldName), 300), + [] + ); + + const handleSearch = (value: string, fieldName: 'user_email' | 'user_id'): void => { + setSelectedField(fieldName); + debouncedSearch(value, fieldName); + }; + + const handleSelect = (_value: string, option: UserOption): void => { + const selectedUser = option.user; + form.setFieldsValue({ + user_email: selectedUser.user_email, + user_id: selectedUser.user_id, + role: form.getFieldValue('role') // Preserve current role selection + }); + }; + + const handleClose = (): void => { + form.resetFields(); + setUserOptions([]); + onCancel(); + }; + + return ( + + + form={form} + onFinish={onSubmit} + labelCol={{ span: 8 }} + wrapperCol={{ span: 16 }} + labelAlign="left" + initialValues={{ + role: "user", + }} + > + + handleSearch(value, 'user_id')} + onSelect={(value, option) => handleSelect(value, option as UserOption)} + options={selectedField === 'user_id' ? userOptions : []} + loading={loading} + allowClear + /> + + + + + + +
+ +
+ +
+ ); +}; + +export default UserSearchModal; \ No newline at end of file diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 6869bcdc58..28e1b73422 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -1510,6 +1510,36 @@ export const allEndUsersCall = async (accessToken: String) => { } }; +export const userFilterUICall = async (accessToken: String, params: URLSearchParams) => { + try { + let url = proxyBaseUrl ? `${proxyBaseUrl}/user/filter/ui` : `/user/filter/ui`; + + if (params.get("user_email")) { + url += `?user_email=${params.get("user_email")}`; + } + if (params.get("user_id")) { + url += `?user_id=${params.get("user_id")}`; + } + + const response = await fetch(url, { + method: "GET", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + if (!response.ok) { + const errorData = await response.text(); + handleError(errorData); + throw new Error("Network response was not ok"); + } + return await response.json(); + } catch (error) { + console.error("Failed to create key:", error); + throw error; + } +} + export const userSpendLogsCall = async ( accessToken: String, token: String, @@ -2402,6 +2432,47 @@ export const teamMemberUpdateCall = async ( } } +export const teamMemberDeleteCall = async ( + accessToken: string, + teamId: string, + formValues: Member // Assuming formValues is an object +) => { + try { + console.log("Form Values in teamMemberAddCall:", formValues); // Log the form values before making the API call + + const url = proxyBaseUrl + ? `${proxyBaseUrl}/team/member_delete` + : `/team/member_delete`; + const response = await fetch(url, { + method: "POST", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + team_id: teamId, + ...(formValues.user_email && { user_email: formValues.user_email }), + ...(formValues.user_id && { user_id: formValues.user_id }) + }), + }); + + 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; + // Handle success - you might want to update some state or UI based on the created key + } catch (error) { + console.error("Failed to create key:", error); + throw error; + } +} + export const organizationMemberAddCall = async ( accessToken: string, organizationId: string, diff --git a/ui/litellm-dashboard/src/components/team/edit_membership.tsx b/ui/litellm-dashboard/src/components/team/edit_membership.tsx index 3e2e47b448..2e22f2a5d6 100644 --- a/ui/litellm-dashboard/src/components/team/edit_membership.tsx +++ b/ui/litellm-dashboard/src/components/team/edit_membership.tsx @@ -1,19 +1,13 @@ -import React, { useState } from 'react'; +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"; - -export interface TeamMember { - id?: string; - email?: string; - role: 'admin' | 'user'; -} - +import { Member } from "@/components/networking"; interface TeamMemberModalProps { visible: boolean; onCancel: () => void; - onSubmit: (data: TeamMember) => void; - initialData?: TeamMember | null; + onSubmit: (data: Member) => void; + initialData?: Member | null; mode: 'add' | 'edit'; } @@ -26,11 +20,22 @@ const TeamMemberModal: React.FC = ({ }) => { const [form] = Form.useForm(); + useEffect(() => { + if (initialData) { + form.setFieldsValue({ + user_email: initialData.user_email, + user_id: initialData.user_id, + role: initialData.role, + }); + } + }, [initialData, form]); + + const handleSubmit = async (values: any) => { try { - const formData: TeamMember = { - email: values.user_email, - id: values.user_id, + const formData: Member = { + user_email: values.user_email, + user_id: values.user_id, role: values.role, }; @@ -60,8 +65,8 @@ const TeamMemberModal: React.FC = ({ wrapperCol={{ span: 16 }} labelAlign="left" initialValues={{ - user_email: initialData?.email?.trim() || '', - user_id: initialData?.id?.trim() || '', + user_email: initialData?.user_email?.trim() || '', + user_id: initialData?.user_id?.trim() || '', role: initialData?.role || 'user', }} > diff --git a/ui/litellm-dashboard/src/components/team/team_info.tsx b/ui/litellm-dashboard/src/components/team/team_info.tsx new file mode 100644 index 0000000000..32a59ce2d6 --- /dev/null +++ b/ui/litellm-dashboard/src/components/team/team_info.tsx @@ -0,0 +1,326 @@ +import React, { useState, useEffect } from "react"; +import { + Card, + Title, + Text, + Tab, + TabList, + TabGroup, + TabPanel, + TabPanels, + Grid, + Badge, + Button as TremorButton, + TableRow, + TableCell, + TableHead, + TableHeaderCell, + TableBody, + Table, + Icon +} from "@tremor/react"; +import { teamInfoCall, teamMemberDeleteCall, teamMemberAddCall, teamMemberUpdateCall, Member } from "@/components/networking"; +import { Button, Modal, Form, Input, Select as AntSelect, message } from "antd"; +import { PencilAltIcon, PlusIcon, TrashIcon } from "@heroicons/react/outline"; +import TeamMemberModal from "./edit_membership"; +import UserSearchModal from "@/components/common_components/user_search_modal"; +interface TeamData { + team_id: string; + team_info: { + team_alias: string; + team_id: string; + organization_id: string | null; + admins: string[]; + members: string[]; + members_with_roles: Member[]; + metadata: Record; + tpm_limit: number | null; + rpm_limit: number | null; + max_budget: number | null; + budget_duration: string | null; + models: string[]; + blocked: boolean; + spend: number; + max_parallel_requests: number | null; + budget_reset_at: string | null; + model_id: string | null; + litellm_model_table: string | null; + created_at: string; + }; + keys: any[]; + team_memberships: any[]; +} + +interface TeamInfoProps { + teamId: string; + onClose: () => void; + accessToken: string | null; + is_team_admin: boolean; + is_proxy_admin: boolean; +} + +const TeamInfoView: React.FC = ({ + teamId, + onClose, + accessToken, + is_team_admin, + is_proxy_admin +}) => { + const [teamData, setTeamData] = useState(null); + const [loading, setLoading] = useState(true); + const [isAddMemberModalVisible, setIsAddMemberModalVisible] = useState(false); + const [form] = Form.useForm(); + const [isEditMemberModalVisible, setIsEditMemberModalVisible] = useState(false); + const [selectedEditMember, setSelectedEditMember] = useState(null); + + const canManageMembers = is_team_admin || is_proxy_admin; + + const fetchTeamInfo = async () => { + try { + setLoading(true); + if (!accessToken) return; + const response = await teamInfoCall(accessToken, teamId); + setTeamData(response); + } catch (error) { + message.error("Failed to load team information"); + console.error("Error fetching team info:", error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchTeamInfo(); + }, [teamId, accessToken]); + + + const handleMemberCreate = async (values: any) => { + try { + if (accessToken == null) { + return; + } + + const member: Member = { + user_email: values.user_email, + user_id: values.user_id, + role: values.role, + } + const response = await teamMemberAddCall(accessToken, teamId, member); + + + message.success("Team member added successfully"); + setIsAddMemberModalVisible(false); + form.resetFields(); + fetchTeamInfo(); + } catch (error) { + message.error("Failed to add team member"); + console.error("Error adding team member:", error); + } + }; + + const handleMemberDelete = async (member: Member) => { + try { + if (accessToken == null) { + return; + } + + const response = await teamMemberDeleteCall(accessToken, teamId, member); + + message.success("Team member removed successfully"); + fetchTeamInfo(); + } catch (error) { + message.error("Failed to remove team member"); + console.error("Error removing team member:", error); + } + }; + + const handleMemberUpdate = async (values: any) => { + try { + if (accessToken == null) { + return; + } + + const member: Member = { + user_email: values.user_email, + user_id: values.user_id, + role: values.role, + } + + const response = await teamMemberUpdateCall(accessToken, teamId, member); + + message.success("Team member updated successfully"); + setIsEditMemberModalVisible(false); + fetchTeamInfo(); + } catch (error) { + message.error("Failed to update team member"); + console.error("Error updating team member:", error); + } + } + + if (loading) { + return
Loading...
; + } + + if (!teamData?.team_info) { + return
Team not found
; + } + + const { team_info: info } = teamData; + + const renderMembersPanel = () => ( +
+ + + + + User ID + User Email + Role + + + + + {teamData + ? teamData.team_info.members_with_roles.map( + (member: any, index: number) => ( + + + {member["user_id"]} + + + {member["user_email"]} + + + {member["role"]} + + + {is_team_admin ? ( + <> + { + setSelectedEditMember(member); + setIsEditMemberModalVisible(true); + }} + /> + {handleMemberDelete(member)}} + icon={TrashIcon} + size="sm" + /> + + ) : null} + + + ) + ) + : null} + +
+
+ setIsAddMemberModalVisible(true)}>Add Member +
+ ); + + return ( +
+
+
+ + {info.team_alias} + {info.team_id} +
+
+ + + + Overview + Members + Settings + + + + + + + Budget Status +
+ ${info.spend.toFixed(6)} + of {info.max_budget === null ? "Unlimited" : `$${info.max_budget}`} + {info.budget_duration && ( + Reset: {info.budget_duration} + )} +
+
+ + + Rate Limits +
+ TPM: {info.tpm_limit || 'Unlimited'} + RPM: {info.rpm_limit || 'Unlimited'} + {info.max_parallel_requests && ( + Max Parallel Requests: {info.max_parallel_requests} + )} +
+
+ + + Models +
+ {info.models.map((model, index) => ( + + {model} + + ))} +
+
+
+
+ + + {renderMembersPanel()} + + + + + Team Settings +
+
+ Team ID + {info.team_id} +
+
+ Created At + {new Date(info.created_at).toLocaleString()} +
+
+ Status + + {info.blocked ? 'Blocked' : 'Active'} + +
+
+
+ setIsEditMemberModalVisible(false)} + onSubmit={handleMemberUpdate} + initialData={selectedEditMember} + mode="edit" + /> +
+
+
+ + setIsAddMemberModalVisible(false)} + onSubmit={handleMemberCreate} + accessToken={accessToken} + /> +
+ ); +}; + +export default TeamInfoView; \ No newline at end of file diff --git a/ui/litellm-dashboard/src/components/teams.tsx b/ui/litellm-dashboard/src/components/teams.tsx index a549fecec3..e7fe98a601 100644 --- a/ui/litellm-dashboard/src/components/teams.tsx +++ b/ui/litellm-dashboard/src/components/teams.tsx @@ -25,7 +25,7 @@ import { fetchAvailableModelsForTeamOrKey, getModelDisplayName } from "./key_tea import { Select, SelectItem } from "@tremor/react"; import { InfoCircleOutlined } from '@ant-design/icons'; import { getGuardrailsList } from "./networking"; - +import TeamInfoView from "@/components/team/team_info"; import { Table, TableBody, @@ -129,8 +129,9 @@ const Team: React.FC = ({ const [editModalVisible, setEditModalVisible] = useState(false); const [selectedTeam, setSelectedTeam] = useState( - teams ? teams[0] : null + null ); + const [selectedTeamId, setSelectedTeamId] = useState(null); const [isTeamModalVisible, setIsTeamModalVisible] = useState(false); const [isAddMemberModalVisible, setIsAddMemberModalVisible] = useState(false); @@ -500,6 +501,9 @@ const Team: React.FC = ({ }; const is_team_admin = (team: any) => { + if (team == null || team.members_with_roles == null) { + return false; + } for (let i = 0; i < team.members_with_roles.length; i++) { let member = team.members_with_roles[i]; if (member.user_id == userID && member.role == "admin") { @@ -509,58 +513,7 @@ const Team: React.FC = ({ return false; } - const _common_member_update_call = async (formValues: Record, callType: "add" | "edit") => { - try { - if (accessToken != null && teams != null) { - message.info("Adding Member"); - const user_role: Member = { - role: formValues.role, - user_email: formValues.user_email, - user_id: formValues.user_id, - }; - let response: any; - if (callType == "add") { - response = await teamMemberAddCall( - accessToken, - selectedTeam["team_id"], - user_role - ); - message.success("Member added"); - } else { - response = await teamMemberUpdateCall( - accessToken, - selectedTeam["team_id"], - { - "role": formValues.role, - "user_id": formValues.id, - "user_email": formValues.email - } - ); - message.success("Member updated"); - } - - // Checking if the team exists in the list and updating or adding accordingly - const foundIndex = teams.findIndex((team) => { - console.log( - `team.team_id=${team.team_id}; response.data.team_id=${response.data.team_id}` - ); - return team.team_id === response.data.team_id; - }); - console.log(`foundIndex: ${foundIndex}`); - if (foundIndex !== -1) { - // If the team is found, update it - const updatedTeams = [...teams]; // Copy the current state - updatedTeams[foundIndex] = response.data; // Update the specific team - setTeams(updatedTeams); // Set the new state - setSelectedTeam(response.data); - } - setIsAddMemberModalVisible(false); - - } - } catch (error) { - console.error("Error creating the team:", error); - } - } + const handleRefreshClick = () => { // Update the 'lastRefreshed' state to the current date and time @@ -577,6 +530,15 @@ const Team: React.FC = ({ } return (
+ {selectedTeamId ? ( + setSelectedTeamId(null)} + accessToken={accessToken} + is_team_admin={is_team_admin(teams?.find((team) => team.team_id === selectedTeamId))} + is_proxy_admin={userRole == "Admin"} + /> + ) : (
@@ -596,6 +558,9 @@ const Team: React.FC = ({ + + Click on "Team ID" to view team details and manage team members. + @@ -628,19 +593,27 @@ const Team: React.FC = ({ > {team["team_alias"]} - - - {team.team_id} - + + +
+ + + +
+
+ = ({ ) : null} - + {/* Team Members - If you belong to multiple teams, this setting controls which teams - members you see. + If you belong to multiple teams, this setting controls which teams' members you see. {teams && teams.length > 0 ? ( ) : ( @@ -1112,7 +1090,7 @@ const Team: React.FC = ({
- + */} @@ -1123,7 +1101,7 @@ const Team: React.FC = ({ -
+ )}
); };