diff --git a/ui/litellm-dashboard/src/app/(dashboard)/README.md b/ui/litellm-dashboard/src/app/(dashboard)/README.md new file mode 100644 index 00000000000..c913431fc5b --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/README.md @@ -0,0 +1,53 @@ +# Contributing to the LiteLLM UI + +The LiteLLM UI is currently being refactored/rewritten to reduce development friction. Please read this document to understand what's expected for new contributions. + +The project follows strict NextJS file structure. All pages on the site (determined by the sidebar) are contained in their own folder, and routing is automatically handled by NextJS based on the file structure. + +For example, NextJS will automatically render the admin settings page when the user visits `/settings/admin-settings` + +``` +. +├── settings +    ├── admin-settings +       └── page.tsx +``` + +You can use parenthesis around directory names to hide them from the user route, for example `(dashboard)`, while still getting the benefits of `layout` and file structure. + +### File Structure +Every page must follow the following file structure pattern. +``` +├── teams +│   ├── TeamsView.tsx +│   ├── components +│   │   ├── TeamsFilters.tsx +│   │   ├── TeamsHeaderTabs.tsx +│   │   ├── TeamsTable +│   │   │   ├── ModelsCell.tsx +│   │   │   └── TeamsTable.tsx +│   │   └── modals +│   │   ├── CreateTeamModal.tsx +│   │   └── DeleteTeamModal.tsx +│   ├── hooks +│   │   └── useFetchTeams.ts +│   └── page.tsx +``` + +### Component Files + +All component files should ideally be as dumb as possible. Their only job should be to take the data they need from hooks or props and render them to the UI. If a component file becomes too large (over `300` lines or so), **please break it down** into smaller components. + +A component should only be placed where it will be used. For example, if a component will only be used by the `teams` page, it should belong in the `teams/components` folder. + +**Common components should be moved to the lowest common ancestor components folder.** + +### Hooks + +All hooks should be pure `.ts` files in a dedicated `hooks` folder unless they serve as context managers. + +**Common hooks should be moved to the lowest common ancestor hooks folder.** + +### Utils + +Any pure `.ts` functions that you need in order to process data should be placed in a local `utils.ts` file. 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;