diff --git a/ui/litellm-dashboard/src/app/page.tsx b/ui/litellm-dashboard/src/app/page.tsx index 263e218c184..005f9ab6168 100644 --- a/ui/litellm-dashboard/src/app/page.tsx +++ b/ui/litellm-dashboard/src/app/page.tsx @@ -40,6 +40,7 @@ import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import { cx } from "@/lib/cva.config"; import useFeatureFlags from "@/hooks/useFeatureFlags"; import SidebarProvider from "@/app/(dashboard)/components/SidebarProvider"; +import OldTeams from "@/components/OldTeams"; function getCookie(name: string) { // Safer cookie read + decoding; handles '=' inside values @@ -376,7 +377,7 @@ export default function CreateKeyPage() { setKeys={setKeys} /> ) : page == "teams" ? ( - ) : page == "organizations" ? ( >; + userID: string | null; + userRole: string | null; + organizations: Organization[] | null; + premiumUser?: boolean; +} + +interface FilterState { + team_id: string; + team_alias: string; + organization_id: string; + sort_by: string; + sort_order: "asc" | "desc"; +} + +interface EditTeamModalProps { + visible: boolean; + onCancel: () => void; + team: any; // Assuming TeamType is a type representing your team object + onSubmit: (data: FormData) => void; // Assuming FormData is the type of data to be submitted +} + +import { + teamCreateCall, + teamMemberAddCall, + teamMemberUpdateCall, + Member, + modelAvailableCall, + v2TeamListCall, +} from "./networking"; +import { updateExistingKeys } from "@/utils/dataUtils"; +import { deprecate } from "node:util"; + +interface TeamInfo { + members_with_roles: Member[]; +} + +interface PerTeamInfo { + keys: KeyResponse[]; + team_info: TeamInfo; +} + +const getOrganizationModels = (organization: Organization | null, userModels: string[]) => { + let tempModelsToPick = []; + + if (organization) { + if (organization.models.length > 0) { + console.log(`organization.models: ${organization.models}`); + tempModelsToPick = organization.models; + } else { + // show all available models if the team has no models set + tempModelsToPick = userModels; + } + } else { + // no team set, show all available models + tempModelsToPick = userModels; + } + + return unfurlWildcardModelsInList(tempModelsToPick, userModels); +}; + +// @deprecated +const Teams: React.FC = ({ + teams, + searchParams, + accessToken, + setTeams, + userID, + userRole, + organizations, + premiumUser = false, +}) => { + const [lastRefreshed, setLastRefreshed] = useState(""); + const [currentOrg, setCurrentOrg] = useState(null); + const [currentOrgForCreateTeam, setCurrentOrgForCreateTeam] = useState(null); + const [showFilters, setShowFilters] = useState(false); + const [filters, setFilters] = useState({ + team_id: "", + team_alias: "", + organization_id: "", + sort_by: "created_at", + sort_order: "desc", + }); + + useEffect(() => { + console.log(`inside useeffect - ${lastRefreshed}`); + if (accessToken) { + // Call your function here + fetchTeams(accessToken, userID, userRole, currentOrg, setTeams); + } + handleRefreshClick(); + }, [lastRefreshed]); + + const [form] = Form.useForm(); + const [memberForm] = Form.useForm(); + const { Title, Paragraph } = Typography; + const [value, setValue] = useState(""); + const [editModalVisible, setEditModalVisible] = useState(false); + + const [selectedTeam, setSelectedTeam] = useState(null); + const [selectedTeamId, setSelectedTeamId] = useState(null); + const [editTeam, setEditTeam] = useState(false); + + const [isTeamModalVisible, setIsTeamModalVisible] = useState(false); + const [isAddMemberModalVisible, setIsAddMemberModalVisible] = useState(false); + const [isEditMemberModalVisible, setIsEditMemberModalVisible] = useState(false); + const [userModels, setUserModels] = useState([]); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [teamToDelete, setTeamToDelete] = useState(null); + const [modelsToPick, setModelsToPick] = useState([]); + const [perTeamInfo, setPerTeamInfo] = useState>({}); + + // Add this state near the other useState declarations + const [guardrailsList, setGuardrailsList] = useState([]); + const [expandedAccordions, setExpandedAccordions] = useState>({}); + const [loggingSettings, setLoggingSettings] = useState([]); + const [mcpAccessGroups, setMcpAccessGroups] = useState([]); + const [mcpAccessGroupsLoaded, setMcpAccessGroupsLoaded] = useState(false); + const [deleteConfirmInput, setDeleteConfirmInput] = useState(""); + const [modelAliases, setModelAliases] = useState<{ [key: string]: string }>({}); + + useEffect(() => { + console.log(`currentOrgForCreateTeam: ${currentOrgForCreateTeam}`); + const models = getOrganizationModels(currentOrgForCreateTeam, userModels); + console.log(`models: ${models}`); + setModelsToPick(models); + form.setFieldValue("models", []); + }, [currentOrgForCreateTeam, userModels]); + + // Add this useEffect to fetch guardrails + useEffect(() => { + const fetchGuardrails = async () => { + try { + if (accessToken == null) { + return; + } + + const response = await getGuardrailsList(accessToken); + const guardrailNames = response.guardrails.map((g: { guardrail_name: string }) => g.guardrail_name); + setGuardrailsList(guardrailNames); + } catch (error) { + console.error("Failed to fetch guardrails:", error); + } + }; + + fetchGuardrails(); + }, [accessToken]); + + const fetchMcpAccessGroups = async () => { + try { + if (accessToken == null) { + return; + } + const groups = await fetchMCPAccessGroups(accessToken); + setMcpAccessGroups(groups); + } catch (error) { + console.error("Failed to fetch MCP access groups:", error); + } + }; + + useEffect(() => { + fetchMcpAccessGroups(); + }, [accessToken]); + + useEffect(() => { + const fetchTeamInfo = () => { + if (!teams) return; + + const newPerTeamInfo = teams.reduce( + (acc, team) => { + acc[team.team_id] = { + keys: team.keys || [], + team_info: { + members_with_roles: team.members_with_roles || [], + }, + }; + return acc; + }, + {} as Record, + ); + + setPerTeamInfo(newPerTeamInfo); + }; + + fetchTeamInfo(); + }, [teams]); + + const handleOk = () => { + setIsTeamModalVisible(false); + form.resetFields(); + setLoggingSettings([]); + setModelAliases({}); + }; + + const handleMemberOk = () => { + setIsAddMemberModalVisible(false); + setIsEditMemberModalVisible(false); + memberForm.resetFields(); + }; + + const handleCancel = () => { + setIsTeamModalVisible(false); + form.resetFields(); + setLoggingSettings([]); + setModelAliases({}); + }; + + const handleMemberCancel = () => { + setIsAddMemberModalVisible(false); + setIsEditMemberModalVisible(false); + memberForm.resetFields(); + }; + + const handleDelete = async (team_id: string) => { + // Set the team to delete and open the confirmation modal + setTeamToDelete(team_id); + setIsDeleteModalOpen(true); + }; + + const confirmDelete = async () => { + if (teamToDelete == null || teams == null || accessToken == null) { + return; + } + + try { + await teamDeleteCall(accessToken, teamToDelete); + // Successfully completed the deletion. Update the state to trigger a rerender. + fetchTeams(accessToken, userID, userRole, currentOrg, setTeams); + } catch (error) { + console.error("Error deleting the team:", error); + // Handle any error situations, such as displaying an error message to the user. + } + + // Close the confirmation modal and reset the teamToDelete + setIsDeleteModalOpen(false); + setTeamToDelete(null); + }; + + const cancelDelete = () => { + // Close the confirmation modal and reset the teamToDelete + setIsDeleteModalOpen(false); + setTeamToDelete(null); + }; + + useEffect(() => { + const fetchUserModels = async () => { + try { + if (userID === null || userRole === null || accessToken === null) { + return; + } + const models = await fetchAvailableModelsForTeamOrKey(userID, userRole, accessToken); + if (models) { + setUserModels(models); + } + } catch (error) { + console.error("Error fetching user models:", error); + } + }; + + fetchUserModels(); + }, [accessToken, userID, userRole, teams]); + + const handleCreate = async (formValues: Record) => { + try { + console.log(`formValues: ${JSON.stringify(formValues)}`); + if (accessToken != null) { + const newTeamAlias = formValues?.team_alias; + const existingTeamAliases = teams?.map((t) => t.team_alias) ?? []; + let organizationId = formValues?.organization_id || currentOrg?.organization_id; + if (organizationId === "" || typeof organizationId !== "string") { + formValues.organization_id = null; + } else { + formValues.organization_id = organizationId.trim(); + } + + // Remove guardrails from top level since it's now in metadata + if (existingTeamAliases.includes(newTeamAlias)) { + throw new Error(`Team alias ${newTeamAlias} already exists, please pick another alias`); + } + + NotificationsManager.info("Creating Team"); + + // Handle logging settings in metadata + if (loggingSettings.length > 0) { + let metadata = {}; + if (formValues.metadata) { + try { + metadata = JSON.parse(formValues.metadata); + } catch (e) { + console.warn("Invalid JSON in metadata field, starting with empty object"); + } + } + + // Add logging settings to metadata + metadata = { + ...metadata, + logging: loggingSettings.filter((config) => config.callback_name), // Only include configs with callback_name + }; + + formValues.metadata = JSON.stringify(metadata); + } + + // Transform allowed_vector_store_ids and allowed_mcp_servers_and_groups into object_permission + if ( + (formValues.allowed_vector_store_ids && formValues.allowed_vector_store_ids.length > 0) || + (formValues.allowed_mcp_servers_and_groups && + (formValues.allowed_mcp_servers_and_groups.servers?.length > 0 || + formValues.allowed_mcp_servers_and_groups.accessGroups?.length > 0 || + formValues.allowed_mcp_servers_and_groups.toolPermissions)) + ) { + formValues.object_permission = {}; + if (formValues.allowed_vector_store_ids && formValues.allowed_vector_store_ids.length > 0) { + formValues.object_permission.vector_stores = formValues.allowed_vector_store_ids; + delete formValues.allowed_vector_store_ids; + } + if (formValues.allowed_mcp_servers_and_groups) { + const { servers, accessGroups } = formValues.allowed_mcp_servers_and_groups; + if (servers && servers.length > 0) { + formValues.object_permission.mcp_servers = servers; + } + if (accessGroups && accessGroups.length > 0) { + formValues.object_permission.mcp_access_groups = accessGroups; + } + delete formValues.allowed_mcp_servers_and_groups; + } + + // Add tool permissions separately + if (formValues.mcp_tool_permissions && Object.keys(formValues.mcp_tool_permissions).length > 0) { + if (!formValues.object_permission) { + formValues.object_permission = {}; + } + formValues.object_permission.mcp_tool_permissions = formValues.mcp_tool_permissions; + delete formValues.mcp_tool_permissions; + } + } + + // Transform allowed_mcp_access_groups into object_permission + if (formValues.allowed_mcp_access_groups && formValues.allowed_mcp_access_groups.length > 0) { + if (!formValues.object_permission) { + formValues.object_permission = {}; + } + formValues.object_permission.mcp_access_groups = formValues.allowed_mcp_access_groups; + delete formValues.allowed_mcp_access_groups; + } + + // Add model_aliases if any are defined + if (Object.keys(modelAliases).length > 0) { + formValues.model_aliases = modelAliases; + } + + const response: any = await teamCreateCall(accessToken, formValues); + if (teams !== null) { + setTeams([...teams, response]); + } else { + setTeams([response]); + } + console.log(`response for team create call: ${response}`); + NotificationsManager.success("Team created"); + form.resetFields(); + setLoggingSettings([]); + setModelAliases({}); + setIsTeamModalVisible(false); + } + } catch (error) { + console.error("Error creating the team:", error); + NotificationsManager.fromBackend("Error creating the team: " + error); + } + }; + + 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") { + return true; + } + } + return false; + }; + + const handleRefreshClick = () => { + // Update the 'lastRefreshed' state to the current date and time + const currentDate = new Date(); + setLastRefreshed(currentDate.toLocaleString()); + }; + + const handleFilterChange = (key: keyof FilterState, value: string) => { + const newFilters = { ...filters, [key]: value }; + setFilters(newFilters); + // Call teamListCall with the new filters + if (accessToken) { + v2TeamListCall( + accessToken, + newFilters.organization_id || null, + null, + newFilters.team_id || null, + newFilters.team_alias || null, + ) + .then((response) => { + if (response && response.teams) { + setTeams(response.teams); + } + }) + .catch((error) => { + console.error("Error fetching teams:", error); + }); + } + }; + + const handleSortChange = (sortBy: string, sortOrder: "asc" | "desc") => { + const newFilters = { + ...filters, + sort_by: sortBy, + sort_order: sortOrder, + }; + setFilters(newFilters); + // Call teamListCall with the new sort parameters + if (accessToken) { + v2TeamListCall( + accessToken, + filters.organization_id || null, + null, + filters.team_id || null, + filters.team_alias || null, + ) + .then((response) => { + if (response && response.teams) { + setTeams(response.teams); + } + }) + .catch((error) => { + console.error("Error fetching teams:", error); + }); + } + }; + + const handleFilterReset = () => { + setFilters({ + team_id: "", + team_alias: "", + organization_id: "", + sort_by: "created_at", + sort_order: "desc", + }); + // Reset teams list + if (accessToken) { + v2TeamListCall(accessToken, null, userID || null, null, null) + .then((response) => { + if (response && response.teams) { + setTeams(response.teams); + } + }) + .catch((error) => { + console.error("Error fetching teams:", error); + }); + } + }; + + return ( +
+ + + {(userRole == "Admin" || userRole == "Org Admin") && ( + + )} + {selectedTeamId ? ( + { + setTeams((teams) => { + if (teams == null) { + return teams; + } + const updated = teams.map((team) => { + if (data.team_id === team.team_id) { + return updateExistingKeys(team, data); + } + return team; + }); + // Minimal fix: refresh the full team list after an update + if (accessToken) { + fetchTeams(accessToken, userID, userRole, currentOrg, setTeams); + } + return updated; + }); + }} + onClose={() => { + setSelectedTeamId(null); + setEditTeam(false); + }} + accessToken={accessToken} + is_team_admin={is_team_admin(teams?.find((team) => team.team_id === selectedTeamId))} + is_proxy_admin={userRole == "Admin"} + userModels={userModels} + editTeam={editTeam} + /> + ) : ( + + +
+ Your Teams + Available Teams + {isAdminRole(userRole || "") && Default Team Settings} +
+
+ {lastRefreshed && Last Refreshed: {lastRefreshed}} + +
+
+ + + + Click on “Team ID” to view team details and manage team members. + + + + +
+
+ {/* Search and Filter Controls */} +
+ {/* Team Alias Search */} +
+ handleFilterChange("team_alias", e.target.value)} + /> + + + +
+ + {/* Filter Button */} + + + {/* Reset Filters Button */} + +
+ + {/* Additional Filters */} + {showFilters && ( +
+ {/* Team ID Search */} +
+ handleFilterChange("team_id", e.target.value)} + /> + + + +
+ + {/* Organization Dropdown */} +
+ +
+
+ )} +
+
+ + + + Team Name + Team ID + Created + Spend (USD) + Budget (USD) + Models + Organization + Info + + + + + {teams && teams.length > 0 + ? teams + .filter((team) => { + if (!currentOrg) return true; + return team.organization_id === currentOrg.organization_id; + }) + .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) + .map((team: any) => ( + + + {team["team_alias"]} + + +
+ + + +
+
+ + {team.created_at ? new Date(team.created_at).toLocaleDateString() : "N/A"} + + + {formatNumberWithCommas(team["spend"], 4)} + + + {team["max_budget"] !== null && team["max_budget"] !== undefined + ? team["max_budget"] + : "No limit"} + + 3 ? "px-0" : ""} + > +
+ {Array.isArray(team.models) ? ( +
+ {team.models.length === 0 ? ( + + All Proxy Models + + ) : ( + <> +
+ {team.models.length > 3 && ( +
+ { + setExpandedAccordions((prev) => ({ + ...prev, + [team.team_id]: !prev[team.team_id], + })); + }} + /> +
+ )} +
+ {team.models.slice(0, 3).map((model: string, index: number) => + model === "all-proxy-models" ? ( + + All Proxy Models + + ) : ( + + + {model.length > 30 + ? `${getModelDisplayName(model).slice(0, 30)}...` + : getModelDisplayName(model)} + + + ), + )} + {team.models.length > 3 && !expandedAccordions[team.team_id] && ( + + + +{team.models.length - 3}{" "} + {team.models.length - 3 === 1 + ? "more model" + : "more models"} + + + )} + {expandedAccordions[team.team_id] && ( +
+ {team.models.slice(3).map((model: string, index: number) => + model === "all-proxy-models" ? ( + + All Proxy Models + + ) : ( + + + {model.length > 30 + ? `${getModelDisplayName(model).slice(0, 30)}...` + : getModelDisplayName(model)} + + + ), + )} +
+ )} +
+
+ + )} +
+ ) : null} +
+
+ + {team.organization_id} + + + {perTeamInfo && + team.team_id && + perTeamInfo[team.team_id] && + perTeamInfo[team.team_id].keys && + perTeamInfo[team.team_id].keys.length}{" "} + Keys + + + {perTeamInfo && + team.team_id && + perTeamInfo[team.team_id] && + perTeamInfo[team.team_id].team_info && + perTeamInfo[team.team_id].team_info.members_with_roles && + perTeamInfo[team.team_id].team_info.members_with_roles.length}{" "} + Members + + + + {userRole == "Admin" ? ( + <> + { + setSelectedTeamId(team.team_id); + setEditTeam(true); + }} + /> + handleDelete(team.team_id)} + icon={TrashIcon} + size="sm" + /> + + ) : null} + +
+ )) + : null} +
+
+ {isDeleteModalOpen && + (() => { + const team = teams?.find((t) => t.team_id === teamToDelete); + const teamName = team?.team_alias || ""; + const keyCount = team?.keys?.length || 0; + const isValid = deleteConfirmInput === teamName; + return ( +
+
+
+
+

Delete Team

+ +
+
+ {keyCount > 0 && ( +
+
+ +
+
+

+ Warning: This team has {keyCount} associated key{keyCount > 1 ? "s" : ""}. +

+

+ Deleting the team will also delete all associated keys. This action is + irreversible. +

+
+
+ )} +

+ Are you sure you want to force delete this team and all its keys? +

+
+ + setDeleteConfirmInput(e.target.value)} + placeholder="Enter team name exactly" + className="w-full px-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-base" + autoFocus + /> +
+
+
+
+ + +
+
+
+ ); + })()} +
+ +
+
+ + + + {isAdminRole(userRole || "") && ( + + + + )} +
+
+ )} + {(userRole == "Admin" || userRole == "Org Admin") && ( + +
+ <> + + + + + Organization{" "} + + Organizations can have multiple teams. Learn more about{" "} + e.stopPropagation()} + > + user management hierarchy + + + } + > + + + + } + name="organization_id" + initialValue={currentOrg ? currentOrg.organization_id : null} + className="mt-8" + > + { + form.setFieldValue("organization_id", value); + setCurrentOrgForCreateTeam(organizations?.find((org) => org.organization_id === value) || null); + }} + filterOption={(input, option) => { + if (!option) return false; + const optionValue = option.children?.toString() || ""; + return optionValue.toLowerCase().includes(input.toLowerCase()); + }} + optionFilterProp="children" + > + {organizations?.map((org) => ( + + {org.organization_alias}{" "} + ({org.organization_id}) + + ))} + + + + Models{" "} + + + + + } + name="models" + > + + + All Proxy Models + + {modelsToPick.map((model) => ( + + {getModelDisplayName(model)} + + ))} + + + + + + + + + daily + weekly + monthly + + + + + + + + + + { + if (!mcpAccessGroupsLoaded) { + fetchMcpAccessGroups(); + setMcpAccessGroupsLoaded(true); + } + }} + > + + Additional Settings + + + + { + e.target.value = e.target.value.trim(); + }} + /> + + (value ? Number(value) : undefined)} + tooltip="This is the individual budget for a user in the team." + > + + + + + + + + + + + + + + + + Guardrails{" "} + + e.stopPropagation()} + > + + + + + } + name="guardrails" + className="mt-8" + help="Select existing guardrails or enter new ones" + > + ({ + value: name, + label: name, + }))} + /> + + + Allowed Vector Stores{" "} + + + + + } + name="allowed_vector_store_ids" + className="mt-8" + help="Select vector stores this team can access. Leave empty for access to all vector stores" + > + form.setFieldValue("allowed_vector_store_ids", values)} + value={form.getFieldValue("allowed_vector_store_ids")} + accessToken={accessToken || ""} + placeholder="Select vector stores (optional)" + /> + + + + + + + MCP Settings + + + + Allowed MCP Servers{" "} + + + + + } + name="allowed_mcp_servers_and_groups" + className="mt-4" + help="Select MCP servers or access groups this team can access" + > + form.setFieldValue("allowed_mcp_servers_and_groups", val)} + value={form.getFieldValue("allowed_mcp_servers_and_groups")} + accessToken={accessToken || ""} + placeholder="Select MCP servers or access groups (optional)" + /> + + + {/* Hidden field to register mcp_tool_permissions with the form */} + + + + prevValues.allowed_mcp_servers_and_groups !== currentValues.allowed_mcp_servers_and_groups || + prevValues.mcp_tool_permissions !== currentValues.mcp_tool_permissions + } + > + {() => ( +
+ form.setFieldsValue({ mcp_tool_permissions: toolPerms })} + /> +
+ )} +
+
+
+ + + + Logging Settings + + +
+ +
+
+
+ + + + Model Aliases + + +
+ + Create custom aliases for models that can be used by team members in API calls. This allows + you to create shortcuts for specific models. + + +
+
+
+ +
+ Create Team +
+
+
+ )} + +
+
+ ); +}; + +export default Teams;