mirror of
https://github.com/BerriAI/litellm.git
synced 2025-04-26 03:04:13 +00:00
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
This commit is contained in:
parent
8900b18504
commit
65d3f85a69
14 changed files with 862 additions and 111 deletions
|
@ -14,6 +14,7 @@ from ..exceptions import (
|
||||||
BadRequestError,
|
BadRequestError,
|
||||||
ContentPolicyViolationError,
|
ContentPolicyViolationError,
|
||||||
ContextWindowExceededError,
|
ContextWindowExceededError,
|
||||||
|
InternalServerError,
|
||||||
NotFoundError,
|
NotFoundError,
|
||||||
PermissionDeniedError,
|
PermissionDeniedError,
|
||||||
RateLimitError,
|
RateLimitError,
|
||||||
|
@ -467,7 +468,10 @@ def exception_type( # type: ignore # noqa: PLR0915
|
||||||
method="POST", url="https://api.openai.com/v1/"
|
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:
|
if "prompt is too long" in error_str or "prompt: length" in error_str:
|
||||||
exception_mapping_worked = True
|
exception_mapping_worked = True
|
||||||
raise ContextWindowExceededError(
|
raise ContextWindowExceededError(
|
||||||
|
@ -475,6 +479,13 @@ def exception_type( # type: ignore # noqa: PLR0915
|
||||||
model=model,
|
model=model,
|
||||||
llm_provider="anthropic",
|
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:
|
if "Invalid API Key" in error_str:
|
||||||
exception_mapping_worked = True
|
exception_mapping_worked = True
|
||||||
raise AuthenticationError(
|
raise AuthenticationError(
|
||||||
|
|
|
@ -926,3 +926,81 @@ async def add_internal_user_to_organization(
|
||||||
return new_membership
|
return new_membership
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception(f"Failed to add user to organization: {str(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)}")
|
||||||
|
|
|
@ -1637,3 +1637,81 @@ def _set_team_metadata_field(
|
||||||
_premium_user_check()
|
_premium_user_check()
|
||||||
team_data.metadata = team_data.metadata or {}
|
team_data.metadata = team_data.metadata or {}
|
||||||
team_data.metadata[field_name] = value
|
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)}")
|
||||||
|
|
|
@ -8,7 +8,7 @@ Has all /sso/* routes
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import uuid
|
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 import APIRouter, Depends, HTTPException, Request, status
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
|
@ -17,6 +17,7 @@ import litellm
|
||||||
from litellm._logging import verbose_proxy_logger
|
from litellm._logging import verbose_proxy_logger
|
||||||
from litellm.constants import MAX_SPENDLOG_ROWS_TO_QUERY
|
from litellm.constants import MAX_SPENDLOG_ROWS_TO_QUERY
|
||||||
from litellm.proxy._types import (
|
from litellm.proxy._types import (
|
||||||
|
LiteLLM_UserTable,
|
||||||
LitellmUserRoles,
|
LitellmUserRoles,
|
||||||
Member,
|
Member,
|
||||||
NewUserRequest,
|
NewUserRequest,
|
||||||
|
@ -27,6 +28,7 @@ from litellm.proxy._types import (
|
||||||
TeamMemberAddRequest,
|
TeamMemberAddRequest,
|
||||||
UserAPIKeyAuth,
|
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.auth_utils import _has_user_setup_sso
|
||||||
from litellm.proxy.auth.handle_jwt import JWTHandler
|
from litellm.proxy.auth.handle_jwt import JWTHandler
|
||||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
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)
|
- Get missing teams (diff b/w user_info.team_ids and sso_teams)
|
||||||
- Add missing user to missing teams
|
- Add missing user to missing teams
|
||||||
|
@ -414,7 +418,9 @@ async def auth_callback(request: Request): # noqa: PLR0915
|
||||||
master_key,
|
master_key,
|
||||||
premium_user,
|
premium_user,
|
||||||
prisma_client,
|
prisma_client,
|
||||||
|
proxy_logging_obj,
|
||||||
ui_access_mode,
|
ui_access_mode,
|
||||||
|
user_api_key_cache,
|
||||||
user_custom_sso,
|
user_custom_sso,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -557,20 +563,30 @@ async def auth_callback(request: Request): # noqa: PLR0915
|
||||||
user_role = None
|
user_role = None
|
||||||
try:
|
try:
|
||||||
if prisma_client is not None:
|
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(
|
verbose_proxy_logger.debug(
|
||||||
f"user_info: {user_info}; litellm.default_internal_user_params: {litellm.default_internal_user_params}"
|
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(
|
user_defined_values = SSOUserDefinedValues(
|
||||||
models=getattr(user_info, "models", user_id_models),
|
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_email=getattr(user_info, "user_email", user_email),
|
||||||
user_role=getattr(user_info, "user_role", None),
|
user_role=getattr(user_info, "user_role", None),
|
||||||
max_budget=getattr(
|
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
|
where={"user_email": user_email}, data={"user_id": user_id} # type: ignore
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
verbose_proxy_logger.info(
|
||||||
|
"user not in DB, inserting user into LiteLLM DB"
|
||||||
|
)
|
||||||
# user not in DB, insert User into LiteLLM DB
|
# user not in DB, insert User into LiteLLM DB
|
||||||
user_info = await insert_sso_user(
|
user_info = await insert_sso_user(
|
||||||
result_openid=result,
|
result_openid=result,
|
||||||
|
@ -624,9 +643,6 @@ async def auth_callback(request: Request): # noqa: PLR0915
|
||||||
key = response["token"] # type: ignore
|
key = response["token"] # type: ignore
|
||||||
user_id = response["user_id"] # 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/"
|
litellm_dashboard_ui = "/ui/"
|
||||||
user_role = user_role or LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value
|
user_role = user_role or LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value
|
||||||
if (
|
if (
|
||||||
|
|
|
@ -24,6 +24,7 @@ import pytest
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@pytest.mark.parametrize("model", ["claude-2", "anthropic/claude-2"])
|
@pytest.mark.parametrize("model", ["claude-2", "anthropic/claude-2"])
|
||||||
|
@pytest.mark.flaky(retries=6, delay=1)
|
||||||
async def test_acompletion_claude2(model):
|
async def test_acompletion_claude2(model):
|
||||||
try:
|
try:
|
||||||
litellm.set_verbose = True
|
litellm.set_verbose = True
|
||||||
|
|
|
@ -327,6 +327,7 @@ class TestLangfuseLogging:
|
||||||
({}, "empty_metadata.json"),
|
({}, "empty_metadata.json"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
@pytest.mark.flaky(retries=6, delay=1)
|
||||||
async def test_langfuse_logging_with_various_metadata_types(
|
async def test_langfuse_logging_with_various_metadata_types(
|
||||||
self, mock_setup, test_metadata, response_json_file
|
self, mock_setup, test_metadata, response_json_file
|
||||||
):
|
):
|
||||||
|
|
|
@ -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
|
Tests that a new SSO Sign In user is by default given an 'INTERNAL_USER_VIEW_ONLY' role
|
||||||
"""
|
"""
|
||||||
import uuid
|
import uuid
|
||||||
|
import litellm
|
||||||
|
|
||||||
|
litellm._turn_on_debug()
|
||||||
|
|
||||||
# Generate a unique user ID
|
# Generate a unique user ID
|
||||||
unique_user_id = str(uuid.uuid4())
|
unique_user_id = str(uuid.uuid4())
|
||||||
|
|
7
ui/litellm-dashboard/package-lock.json
generated
7
ui/litellm-dashboard/package-lock.json
generated
|
@ -30,6 +30,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/forms": "^0.5.7",
|
"@tailwindcss/forms": "^0.5.7",
|
||||||
|
"@types/lodash": "^4.17.15",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "18.2.48",
|
"@types/react": "18.2.48",
|
||||||
"@types/react-copy-to-clipboard": "^5.0.7",
|
"@types/react-copy-to-clipboard": "^5.0.7",
|
||||||
|
@ -944,6 +945,12 @@
|
||||||
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
|
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/@types/mdast": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.3.tgz",
|
||||||
|
|
|
@ -31,6 +31,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/forms": "^0.5.7",
|
"@tailwindcss/forms": "^0.5.7",
|
||||||
|
"@types/lodash": "^4.17.15",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "18.2.48",
|
"@types/react": "18.2.48",
|
||||||
"@types/react-copy-to-clipboard": "^5.0.7",
|
"@types/react-copy-to-clipboard": "^5.0.7",
|
||||||
|
|
|
@ -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<UserSearchModalProps> = ({
|
||||||
|
isVisible,
|
||||||
|
onCancel,
|
||||||
|
onSubmit,
|
||||||
|
accessToken
|
||||||
|
}) => {
|
||||||
|
const [form] = Form.useForm<FormValues>();
|
||||||
|
const [userOptions, setUserOptions] = useState<UserOption[]>([]);
|
||||||
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
|
const [selectedField, setSelectedField] = useState<'user_email' | 'user_id'>('user_email');
|
||||||
|
|
||||||
|
const fetchUsers = async (searchText: string, fieldName: 'user_email' | 'user_id'): Promise<void> => {
|
||||||
|
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 (
|
||||||
|
<Modal
|
||||||
|
title="Add Team Member"
|
||||||
|
open={isVisible}
|
||||||
|
onCancel={handleClose}
|
||||||
|
footer={null}
|
||||||
|
width={800}
|
||||||
|
>
|
||||||
|
<Form<FormValues>
|
||||||
|
form={form}
|
||||||
|
onFinish={onSubmit}
|
||||||
|
labelCol={{ span: 8 }}
|
||||||
|
wrapperCol={{ span: 16 }}
|
||||||
|
labelAlign="left"
|
||||||
|
initialValues={{
|
||||||
|
role: "user",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
label="Email"
|
||||||
|
name="user_email"
|
||||||
|
className="mb-4"
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
showSearch
|
||||||
|
className="w-full"
|
||||||
|
placeholder="Search by email"
|
||||||
|
filterOption={false}
|
||||||
|
onSearch={(value) => handleSearch(value, 'user_email')}
|
||||||
|
onSelect={(value, option) => handleSelect(value, option as UserOption)}
|
||||||
|
options={selectedField === 'user_email' ? userOptions : []}
|
||||||
|
loading={loading}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<div className="text-center mb-4">OR</div>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="User ID"
|
||||||
|
name="user_id"
|
||||||
|
className="mb-4"
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
showSearch
|
||||||
|
className="w-full"
|
||||||
|
placeholder="Search by user ID"
|
||||||
|
filterOption={false}
|
||||||
|
onSearch={(value) => handleSearch(value, 'user_id')}
|
||||||
|
onSelect={(value, option) => handleSelect(value, option as UserOption)}
|
||||||
|
options={selectedField === 'user_id' ? userOptions : []}
|
||||||
|
loading={loading}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="Member Role"
|
||||||
|
name="role"
|
||||||
|
className="mb-4"
|
||||||
|
>
|
||||||
|
<Select defaultValue="user">
|
||||||
|
<Select.Option value="admin">admin</Select.Option>
|
||||||
|
<Select.Option value="user">user</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<div className="text-right mt-4">
|
||||||
|
<Button type="primary" htmlType="submit">
|
||||||
|
Add Member
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserSearchModal;
|
|
@ -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 (
|
export const userSpendLogsCall = async (
|
||||||
accessToken: String,
|
accessToken: String,
|
||||||
token: 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 (
|
export const organizationMemberAddCall = async (
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
|
|
|
@ -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 { Modal, Form, Input, Select as AntSelect, Button as AntButton, message } from 'antd';
|
||||||
import { Select, SelectItem } from "@tremor/react";
|
import { Select, SelectItem } from "@tremor/react";
|
||||||
import { Card, Text } from "@tremor/react";
|
import { Card, Text } from "@tremor/react";
|
||||||
|
import { Member } from "@/components/networking";
|
||||||
export interface TeamMember {
|
|
||||||
id?: string;
|
|
||||||
email?: string;
|
|
||||||
role: 'admin' | 'user';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TeamMemberModalProps {
|
interface TeamMemberModalProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onSubmit: (data: TeamMember) => void;
|
onSubmit: (data: Member) => void;
|
||||||
initialData?: TeamMember | null;
|
initialData?: Member | null;
|
||||||
mode: 'add' | 'edit';
|
mode: 'add' | 'edit';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -26,11 +20,22 @@ const TeamMemberModal: React.FC<TeamMemberModalProps> = ({
|
||||||
}) => {
|
}) => {
|
||||||
const [form] = Form.useForm();
|
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) => {
|
const handleSubmit = async (values: any) => {
|
||||||
try {
|
try {
|
||||||
const formData: TeamMember = {
|
const formData: Member = {
|
||||||
email: values.user_email,
|
user_email: values.user_email,
|
||||||
id: values.user_id,
|
user_id: values.user_id,
|
||||||
role: values.role,
|
role: values.role,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -60,8 +65,8 @@ const TeamMemberModal: React.FC<TeamMemberModalProps> = ({
|
||||||
wrapperCol={{ span: 16 }}
|
wrapperCol={{ span: 16 }}
|
||||||
labelAlign="left"
|
labelAlign="left"
|
||||||
initialValues={{
|
initialValues={{
|
||||||
user_email: initialData?.email?.trim() || '',
|
user_email: initialData?.user_email?.trim() || '',
|
||||||
user_id: initialData?.id?.trim() || '',
|
user_id: initialData?.user_id?.trim() || '',
|
||||||
role: initialData?.role || 'user',
|
role: initialData?.role || 'user',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
326
ui/litellm-dashboard/src/components/team/team_info.tsx
Normal file
326
ui/litellm-dashboard/src/components/team/team_info.tsx
Normal file
|
@ -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<string, any>;
|
||||||
|
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<TeamInfoProps> = ({
|
||||||
|
teamId,
|
||||||
|
onClose,
|
||||||
|
accessToken,
|
||||||
|
is_team_admin,
|
||||||
|
is_proxy_admin
|
||||||
|
}) => {
|
||||||
|
const [teamData, setTeamData] = useState<TeamData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [isAddMemberModalVisible, setIsAddMemberModalVisible] = useState(false);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [isEditMemberModalVisible, setIsEditMemberModalVisible] = useState(false);
|
||||||
|
const [selectedEditMember, setSelectedEditMember] = useState<Member | null>(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 <div className="p-4">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!teamData?.team_info) {
|
||||||
|
return <div className="p-4">Team not found</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { team_info: info } = teamData;
|
||||||
|
|
||||||
|
const renderMembersPanel = () => (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card className="w-full mx-auto flex-auto overflow-y-auto max-h-[50vh]">
|
||||||
|
<Table>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableHeaderCell>User ID</TableHeaderCell>
|
||||||
|
<TableHeaderCell>User Email</TableHeaderCell>
|
||||||
|
<TableHeaderCell>Role</TableHeaderCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{teamData
|
||||||
|
? teamData.team_info.members_with_roles.map(
|
||||||
|
(member: any, index: number) => (
|
||||||
|
<TableRow key={index}>
|
||||||
|
<TableCell>
|
||||||
|
<Text className="font-mono">{member["user_id"]}</Text>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Text className="font-mono">{member["user_email"]}</Text>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Text className="font-mono">{member["role"]}</Text>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{is_team_admin ? (
|
||||||
|
<>
|
||||||
|
<Icon
|
||||||
|
icon={PencilAltIcon}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedEditMember(member);
|
||||||
|
setIsEditMemberModalVisible(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Icon
|
||||||
|
onClick={() => {handleMemberDelete(member)}}
|
||||||
|
icon={TrashIcon}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
<TremorButton onClick={() => setIsAddMemberModalVisible(true)}>Add Member</TremorButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<div>
|
||||||
|
<Button onClick={onClose} className="mb-4">← Back</Button>
|
||||||
|
<Title>{info.team_alias}</Title>
|
||||||
|
<Text className="text-gray-500 font-mono">{info.team_id}</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TabGroup>
|
||||||
|
<TabList className="mb-4">
|
||||||
|
<Tab>Overview</Tab>
|
||||||
|
<Tab>Members</Tab>
|
||||||
|
<Tab>Settings</Tab>
|
||||||
|
</TabList>
|
||||||
|
|
||||||
|
<TabPanels>
|
||||||
|
<TabPanel>
|
||||||
|
<Grid numItems={1} numItemsSm={2} numItemsLg={3} className="gap-6">
|
||||||
|
<Card>
|
||||||
|
<Text>Budget Status</Text>
|
||||||
|
<div className="mt-2">
|
||||||
|
<Title>${info.spend.toFixed(6)}</Title>
|
||||||
|
<Text>of {info.max_budget === null ? "Unlimited" : `$${info.max_budget}`}</Text>
|
||||||
|
{info.budget_duration && (
|
||||||
|
<Text className="text-gray-500">Reset: {info.budget_duration}</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Text>Rate Limits</Text>
|
||||||
|
<div className="mt-2">
|
||||||
|
<Text>TPM: {info.tpm_limit || 'Unlimited'}</Text>
|
||||||
|
<Text>RPM: {info.rpm_limit || 'Unlimited'}</Text>
|
||||||
|
{info.max_parallel_requests && (
|
||||||
|
<Text>Max Parallel Requests: {info.max_parallel_requests}</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Text>Models</Text>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
{info.models.map((model, index) => (
|
||||||
|
<Badge key={index} color="red">
|
||||||
|
{model}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
<TabPanel>
|
||||||
|
{renderMembersPanel()}
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
<TabPanel>
|
||||||
|
<Card>
|
||||||
|
<Title>Team Settings</Title>
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<Text className="font-medium">Team ID</Text>
|
||||||
|
<Text className="font-mono">{info.team_id}</Text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Text className="font-medium">Created At</Text>
|
||||||
|
<Text>{new Date(info.created_at).toLocaleString()}</Text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Text className="font-medium">Status</Text>
|
||||||
|
<Badge color={info.blocked ? 'red' : 'green'}>
|
||||||
|
{info.blocked ? 'Blocked' : 'Active'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<TeamMemberModal
|
||||||
|
visible={isEditMemberModalVisible}
|
||||||
|
onCancel={() => setIsEditMemberModalVisible(false)}
|
||||||
|
onSubmit={handleMemberUpdate}
|
||||||
|
initialData={selectedEditMember}
|
||||||
|
mode="edit"
|
||||||
|
/>
|
||||||
|
</TabPanel>
|
||||||
|
</TabPanels>
|
||||||
|
</TabGroup>
|
||||||
|
|
||||||
|
<UserSearchModal
|
||||||
|
isVisible={isAddMemberModalVisible}
|
||||||
|
onCancel={() => setIsAddMemberModalVisible(false)}
|
||||||
|
onSubmit={handleMemberCreate}
|
||||||
|
accessToken={accessToken}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TeamInfoView;
|
|
@ -25,7 +25,7 @@ import { fetchAvailableModelsForTeamOrKey, getModelDisplayName } from "./key_tea
|
||||||
import { Select, SelectItem } from "@tremor/react";
|
import { Select, SelectItem } from "@tremor/react";
|
||||||
import { InfoCircleOutlined } from '@ant-design/icons';
|
import { InfoCircleOutlined } from '@ant-design/icons';
|
||||||
import { getGuardrailsList } from "./networking";
|
import { getGuardrailsList } from "./networking";
|
||||||
|
import TeamInfoView from "@/components/team/team_info";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
|
@ -129,8 +129,9 @@ const Team: React.FC<TeamProps> = ({
|
||||||
const [editModalVisible, setEditModalVisible] = useState(false);
|
const [editModalVisible, setEditModalVisible] = useState(false);
|
||||||
|
|
||||||
const [selectedTeam, setSelectedTeam] = useState<null | any>(
|
const [selectedTeam, setSelectedTeam] = useState<null | any>(
|
||||||
teams ? teams[0] : null
|
null
|
||||||
);
|
);
|
||||||
|
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
|
||||||
|
|
||||||
const [isTeamModalVisible, setIsTeamModalVisible] = useState(false);
|
const [isTeamModalVisible, setIsTeamModalVisible] = useState(false);
|
||||||
const [isAddMemberModalVisible, setIsAddMemberModalVisible] = useState(false);
|
const [isAddMemberModalVisible, setIsAddMemberModalVisible] = useState(false);
|
||||||
|
@ -500,6 +501,9 @@ const Team: React.FC<TeamProps> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const is_team_admin = (team: any) => {
|
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++) {
|
for (let i = 0; i < team.members_with_roles.length; i++) {
|
||||||
let member = team.members_with_roles[i];
|
let member = team.members_with_roles[i];
|
||||||
if (member.user_id == userID && member.role == "admin") {
|
if (member.user_id == userID && member.role == "admin") {
|
||||||
|
@ -509,58 +513,7 @@ const Team: React.FC<TeamProps> = ({
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const _common_member_update_call = async (formValues: Record<string, any>, 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 = () => {
|
const handleRefreshClick = () => {
|
||||||
// Update the 'lastRefreshed' state to the current date and time
|
// Update the 'lastRefreshed' state to the current date and time
|
||||||
|
@ -577,6 +530,15 @@ const Team: React.FC<TeamProps> = ({
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="w-full mx-4">
|
<div className="w-full mx-4">
|
||||||
|
{selectedTeamId ? (
|
||||||
|
<TeamInfoView
|
||||||
|
teamId={selectedTeamId}
|
||||||
|
onClose={() => setSelectedTeamId(null)}
|
||||||
|
accessToken={accessToken}
|
||||||
|
is_team_admin={is_team_admin(teams?.find((team) => team.team_id === selectedTeamId))}
|
||||||
|
is_proxy_admin={userRole == "Admin"}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<TabGroup className="gap-2 p-8 h-[75vh] w-full mt-2">
|
<TabGroup className="gap-2 p-8 h-[75vh] w-full mt-2">
|
||||||
<TabList className="flex justify-between mt-2 w-full items-center">
|
<TabList className="flex justify-between mt-2 w-full items-center">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
|
@ -596,6 +558,9 @@ const Team: React.FC<TeamProps> = ({
|
||||||
</TabList>
|
</TabList>
|
||||||
<TabPanels>
|
<TabPanels>
|
||||||
<TabPanel>
|
<TabPanel>
|
||||||
|
<Text>
|
||||||
|
Click on "Team ID" to view team details <b>and</b> manage team members.
|
||||||
|
</Text>
|
||||||
<Grid numItems={1} className="gap-2 pt-2 pb-2 h-[75vh] w-full mt-2">
|
<Grid numItems={1} className="gap-2 pt-2 pb-2 h-[75vh] w-full mt-2">
|
||||||
<Col numColSpan={1}>
|
<Col numColSpan={1}>
|
||||||
<Card className="w-full mx-auto flex-auto overflow-y-auto max-h-[50vh]">
|
<Card className="w-full mx-auto flex-auto overflow-y-auto max-h-[50vh]">
|
||||||
|
@ -628,19 +593,27 @@ const Team: React.FC<TeamProps> = ({
|
||||||
>
|
>
|
||||||
{team["team_alias"]}
|
{team["team_alias"]}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell
|
<TableRow>
|
||||||
style={{
|
<TableCell>
|
||||||
maxWidth: "4px",
|
<div className="overflow-hidden">
|
||||||
whiteSpace: "nowrap",
|
<Tooltip title={team.team_id}>
|
||||||
overflow: "hidden",
|
<Button
|
||||||
textOverflow: "ellipsis",
|
size="xs"
|
||||||
fontSize: "0.75em", // or any smaller size as needed
|
variant="light"
|
||||||
}}
|
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left overflow-hidden truncate max-w-[200px]"
|
||||||
>
|
|
||||||
<Tooltip title={team.team_id}>
|
onClick={() => {
|
||||||
{team.team_id}
|
// Add click handler
|
||||||
</Tooltip>
|
setSelectedTeamId(team.team_id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{team.team_id.slice(0, 7)}...
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
|
||||||
<TableCell
|
<TableCell
|
||||||
style={{
|
style={{
|
||||||
maxWidth: "4px",
|
maxWidth: "4px",
|
||||||
|
@ -964,25 +937,30 @@ const Team: React.FC<TeamProps> = ({
|
||||||
</Modal>
|
</Modal>
|
||||||
</Col>
|
</Col>
|
||||||
) : null}
|
) : null}
|
||||||
<Col numColSpan={1}>
|
{/* <Col numColSpan={1}>
|
||||||
<Title level={4}>Team Members</Title>
|
<Title level={4}>Team Members</Title>
|
||||||
<Paragraph>
|
<Paragraph>
|
||||||
If you belong to multiple teams, this setting controls which teams
|
If you belong to multiple teams, this setting controls which teams' members you see.
|
||||||
members you see.
|
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
{teams && teams.length > 0 ? (
|
{teams && teams.length > 0 ? (
|
||||||
<Select defaultValue="0">
|
<Select defaultValue="0">
|
||||||
{teams.map((team: any, index) => (
|
{[...teams]
|
||||||
<SelectItem
|
.sort((a, b) => {
|
||||||
key={index}
|
const aliasA = a.team_alias || '';
|
||||||
value={String(index)}
|
const aliasB = b.team_alias || '';
|
||||||
onClick={() => {
|
return aliasA.localeCompare(aliasB);
|
||||||
setSelectedTeam(team);
|
})
|
||||||
}}
|
.map((team: any, index) => (
|
||||||
>
|
<SelectItem
|
||||||
{team["team_alias"]}
|
key={index}
|
||||||
</SelectItem>
|
value={String(index)}
|
||||||
))}
|
onClick={() => {
|
||||||
|
setSelectedTeam(team);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{team.team_alias || 'Unnamed Team'}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
) : (
|
) : (
|
||||||
<Paragraph>
|
<Paragraph>
|
||||||
|
@ -1112,7 +1090,7 @@ const Team: React.FC<TeamProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
</Col>
|
</Col> */}
|
||||||
</Grid>
|
</Grid>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel>
|
<TabPanel>
|
||||||
|
@ -1123,7 +1101,7 @@ const Team: React.FC<TeamProps> = ({
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
</TabPanels>
|
</TabPanels>
|
||||||
|
|
||||||
</TabGroup>
|
</TabGroup>)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue