From d8ae044ec59d7c57d81b30a5fcc56d3f33659f57 Mon Sep 17 00:00:00 2001 From: Krish Dholakia Date: Sat, 12 Jul 2025 14:12:52 -0700 Subject: [PATCH] UI - Model Hub - refactor 'Make Public' to have a select + confirm form (#12555) * fix(model_hub_table.tsx): fix null link * fix(model_hub_table.tsx): add tooltip on disabled make public tell user how to enable button * feat(model_hub_table.tsx): make Model Hub OSS allow more teams to share available models via litellm * fix(model_hub_table_columns.tsx): make table view only model hub view, rename 'model' column to 'public model name', make model hub url copyable * fix(page.tsx): fix logo re-rendering * fix(public_model_hub.tsx): fix theme * style(public_model_hub.tsx): move filters into same card as model table filters are for table * fix(page.tsx): fix ui linting error * refactor(model_hub_table.tsx): refactor public model selection to inside the 'make public' button - modal similar flow to adding guardrails consistent flow for doing stuff on the proxy * style(ui/): make 'make public' modal wider, to have all filters on same horizontal axis * style(model_hub_table.tsx): have already public models be pre-selected * fix(model_hub_table.tsx): ensure only admin can make models public via UI * fix: fix linting errors * fix(model_hub_table.tsx): make model hub purpose clearer * fix(model_hub_table_columns.tsx): fix linting error --- .../src/app/model_hub_table/page.tsx | 2 +- ui/litellm-dashboard/src/app/page.tsx | 1 + .../src/components/make_model_public_form.tsx | 317 ++++++++++++++++++ .../src/components/model_filters.tsx | 212 ++++++++++++ .../src/components/model_hub_table.tsx | 239 +++---------- .../components/model_hub_table_columns.tsx | 40 +-- 6 files changed, 582 insertions(+), 229 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/make_model_public_form.tsx create mode 100644 ui/litellm-dashboard/src/components/model_filters.tsx diff --git a/ui/litellm-dashboard/src/app/model_hub_table/page.tsx b/ui/litellm-dashboard/src/app/model_hub_table/page.tsx index 06c66dedff8..196279a7852 100644 --- a/ui/litellm-dashboard/src/app/model_hub_table/page.tsx +++ b/ui/litellm-dashboard/src/app/model_hub_table/page.tsx @@ -20,6 +20,6 @@ export default function PublicModelHubTable() { * */ return ( - + ); } \ No newline at end of file diff --git a/ui/litellm-dashboard/src/app/page.tsx b/ui/litellm-dashboard/src/app/page.tsx index 8d7fea88645..9d03d3eff0d 100644 --- a/ui/litellm-dashboard/src/app/page.tsx +++ b/ui/litellm-dashboard/src/app/page.tsx @@ -375,6 +375,7 @@ export default function CreateKeyPage() { accessToken={accessToken} publicPage={false} premiumUser={premiumUser} + userRole={userRole} /> ) : page == "caching" ? ( void; + accessToken: string; + modelHubData: ModelGroupInfo[]; + onSuccess: () => void; +} + +const MakeModelPublicForm: React.FC = ({ + visible, + onClose, + accessToken, + modelHubData, + onSuccess, +}) => { + const [currentStep, setCurrentStep] = useState(0); + const [selectedModels, setSelectedModels] = useState>(new Set()); + const [filteredData, setFilteredData] = useState([]); + const [loading, setLoading] = useState(false); + const [form] = Form.useForm(); + + const handleClose = () => { + setCurrentStep(0); + setSelectedModels(new Set()); + setFilteredData([]); + form.resetFields(); + onClose(); + }; + + const handleNext = () => { + if (currentStep === 0) { + if (selectedModels.size === 0) { + message.error("Please select at least one model to make public"); + return; + } + setCurrentStep(1); + } + }; + + const handlePrevious = () => { + if (currentStep === 1) { + setCurrentStep(0); + } + }; + + const handleModelSelection = (modelGroup: string, checked: boolean) => { + const newSelection = new Set(selectedModels); + if (checked) { + newSelection.add(modelGroup); + } else { + newSelection.delete(modelGroup); + } + setSelectedModels(newSelection); + }; + + const handleSelectAll = (checked: boolean) => { + if (checked) { + const allModelGroups = filteredData.map(model => model.model_group); + setSelectedModels(new Set(allModelGroups)); + } else { + setSelectedModels(new Set()); + } + }; + + const handleFilteredDataChange = useCallback((newFilteredData: ModelGroupInfo[]) => { + setFilteredData(newFilteredData); + // Keep existing selections when filters change - don't clear them + }, []); + + // Initialize filtered data and preselect already public models when modal opens + useEffect(() => { + if (visible && modelHubData.length > 0) { + setFilteredData(modelHubData); + + // Preselect models that are already public + const alreadyPublicModels = modelHubData + .filter(model => model.is_public_model_group === true) + .map(model => model.model_group); + + setSelectedModels(new Set(alreadyPublicModels)); + } + }, [visible, modelHubData]); + + const handleSubmit = async () => { + if (selectedModels.size === 0) { + message.error("Please select at least one model to make public"); + return; + } + + setLoading(true); + try { + const modelGroupsToMakePublic = Array.from(selectedModels); + await makeModelGroupPublic(accessToken, modelGroupsToMakePublic); + + message.success(`Successfully made ${modelGroupsToMakePublic.length} model group(s) public!`); + handleClose(); + onSuccess(); + } catch (error) { + console.error("Error making model groups public:", error); + message.error("Failed to make model groups public. Please try again."); + } finally { + setLoading(false); + } + }; + + const renderStep1Content = () => { + const allModelsSelected = filteredData.length > 0 && filteredData.every(model => selectedModels.has(model.model_group)); + const isIndeterminate = selectedModels.size > 0 && !allModelsSelected; + + return ( +
+
+ Select Models to Make Public +
+ handleSelectAll(e.target.checked)} + disabled={filteredData.length === 0} + > + Select All {filteredData.length > 0 && `(${filteredData.length})`} + +
+
+ + + Select the models you want to make publicly accessible. These models will be visible to anyone with access to the public model hub. + + + {/* Filters */} + + +
+
+ {filteredData.length === 0 ? ( +
+ No models match the current filters. +
+ ) : ( + filteredData.map((model) => ( +
+ handleModelSelection(model.model_group, e.target.checked)} + /> +
+
+ {model.model_group} + {model.mode && ( + {model.mode} + )} +
+
+ {model.providers.map(provider => ( + {provider} + ))} +
+
+
+ )) + )} +
+
+ + {selectedModels.size > 0 && ( +
+ + {selectedModels.size} model{selectedModels.size !== 1 ? 's' : ''} selected + +
+ )} +
+ ); + }; + + const renderStep2Content = () => { + return ( +
+ Confirm Making Models Public + +
+ + Warning: Once you make these models public, anyone who can go to the /ui/model_hub_table will be able to know they exist on the proxy. + +
+ +
+ Models to be made public: +
+
+ {Array.from(selectedModels).map((modelGroup) => { + const model = modelHubData.find(m => m.model_group === modelGroup); + return ( +
+
+ {modelGroup} + {model && ( +
+ {model.providers.map(provider => ( + {provider} + ))} +
+ )} +
+
+ ); + })} +
+
+
+ +
+ + Total: {selectedModels.size} model{selectedModels.size !== 1 ? 's' : ''} will be made public + +
+
+ ); + }; + + const renderStepContent = () => { + switch (currentStep) { + case 0: + return renderStep1Content(); + case 1: + return renderStep2Content(); + default: + return null; + } + }; + + const renderStepButtons = () => { + return ( +
+ + +
+ {currentStep === 0 && ( + + )} + + {currentStep === 1 && ( + + )} +
+
+ ); + }; + + return ( + +
+ + + + + + {renderStepContent()} + {renderStepButtons()} +
+
+ ); +}; + +export default MakeModelPublicForm; \ No newline at end of file diff --git a/ui/litellm-dashboard/src/components/model_filters.tsx b/ui/litellm-dashboard/src/components/model_filters.tsx new file mode 100644 index 00000000000..fd9e606e2e7 --- /dev/null +++ b/ui/litellm-dashboard/src/components/model_filters.tsx @@ -0,0 +1,212 @@ +import React, { useState, useEffect, useMemo, useRef } from "react"; +import { Card, Text } from "@tremor/react"; + +interface ModelGroupInfo { + model_group: string; + providers: string[]; + max_input_tokens?: number; + max_output_tokens?: number; + input_cost_per_token?: number; + output_cost_per_token?: number; + mode?: string; + tpm?: number; + rpm?: number; + supports_parallel_function_calling: boolean; + supports_vision: boolean; + supports_function_calling: boolean; + supported_openai_params?: string[]; + is_public_model_group: boolean; + [key: string]: any; +} + +interface ModelFiltersProps { + modelHubData: ModelGroupInfo[]; + onFilteredDataChange: (filteredData: ModelGroupInfo[]) => void; + showFiltersCard?: boolean; + className?: string; +} + +const ModelFilters: React.FC = ({ + modelHubData, + onFilteredDataChange, + showFiltersCard = true, + className = "", +}) => { + const [searchTerm, setSearchTerm] = useState(""); + const [selectedProvider, setSelectedProvider] = useState(""); + const [selectedMode, setSelectedMode] = useState(""); + const [selectedFeature, setSelectedFeature] = useState(""); + const previousFilteredDataRef = useRef([]); + + // Helper functions to get unique values + const getUniqueProviders = (data: ModelGroupInfo[]) => { + const providers = new Set(); + data.forEach(model => { + model.providers.forEach(provider => providers.add(provider)); + }); + return Array.from(providers); + }; + + const getUniqueModes = (data: ModelGroupInfo[]) => { + const modes = new Set(); + data.forEach(model => { + if (model.mode) modes.add(model.mode); + }); + return Array.from(modes); + }; + + const getUniqueFeatures = (data: ModelGroupInfo[]) => { + const features = new Set(); + data.forEach(model => { + Object.entries(model) + .filter(([key, value]) => key.startsWith('supports_') && value === true) + .forEach(([key]) => { + const featureName = key + .replace(/^supports_/, '') + .split('_') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + features.add(featureName); + }); + }); + return Array.from(features).sort(); + }; + + // Memoized filtered data + const filteredData = useMemo(() => { + return modelHubData?.filter(model => { + const matchesSearch = model.model_group.toLowerCase().includes(searchTerm.toLowerCase()); + const matchesProvider = selectedProvider === "" || model.providers.includes(selectedProvider); + const matchesMode = selectedMode === "" || model.mode === selectedMode; + + // Check if model has the selected feature + const matchesFeature = selectedFeature === "" || + Object.entries(model) + .filter(([key, value]) => key.startsWith('supports_') && value === true) + .some(([key]) => { + const featureName = key + .replace(/^supports_/, '') + .split('_') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + return featureName === selectedFeature; + }); + + return matchesSearch && matchesProvider && matchesMode && matchesFeature; + }) || []; + }, [modelHubData, searchTerm, selectedProvider, selectedMode, selectedFeature]); + + // Update parent component when filtered data changes + useEffect(() => { + // Only call the callback if the filtered data actually changed + const hasChanged = + filteredData.length !== previousFilteredDataRef.current.length || + filteredData.some((model, index) => + model.model_group !== previousFilteredDataRef.current[index]?.model_group + ); + + if (hasChanged) { + previousFilteredDataRef.current = filteredData; + onFilteredDataChange(filteredData); + } + }, [filteredData, onFilteredDataChange]); + + // Reset filters function + const resetFilters = () => { + setSearchTerm(""); + setSelectedProvider(""); + setSelectedMode(""); + setSelectedFeature(""); + }; + + // Expose filter values and reset function + const filterValues = { + searchTerm, + selectedProvider, + selectedMode, + selectedFeature, + resetFilters, + }; + + const filtersContent = ( +
+
+ Search Models: + setSearchTerm(e.target.value)} + className="border rounded px-3 py-2 w-64 h-10 text-sm" + /> +
+
+ Provider: + +
+
+ Mode: + +
+
+ Features: + +
+ + {/* Clear filters button */} + {(searchTerm || selectedProvider || selectedMode || selectedFeature) && ( +
+ +
+ )} +
+ ); + + if (showFiltersCard) { + return ( + + {filtersContent} + + ); + } + + return ( +
+ {filtersContent} +
+ ); +}; + +export default ModelFilters; \ No newline at end of file diff --git a/ui/litellm-dashboard/src/components/model_hub_table.tsx b/ui/litellm-dashboard/src/components/model_hub_table.tsx index b8952ecfff0..ab504304cef 100644 --- a/ui/litellm-dashboard/src/components/model_hub_table.tsx +++ b/ui/litellm-dashboard/src/components/model_hub_table.tsx @@ -1,10 +1,12 @@ -import React, { useEffect, useState, useRef } from "react"; +import React, { useEffect, useState, useRef, useCallback } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { modelHubCall, makeModelGroupPublic, modelHubPublicModelsCall, getProxyBaseUrl } from "./networking"; import { getConfigFieldSetting, updateConfigFieldSetting } from "./networking"; import { ModelDataTable } from "./model_dashboard/table"; import { modelHubColumns } from "./model_hub_table_columns"; import PublicModelHub from "./public_model_hub"; +import MakeModelPublicForm from "./make_model_public_form"; +import ModelFilters from "./model_filters"; import { Card, Text, @@ -17,11 +19,13 @@ import { Modal, message, Tooltip } from "antd"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { Table as TableInstance } from '@tanstack/react-table'; import { Copy } from "lucide-react"; +import { isAdminRole } from "../utils/roles"; interface ModelHubTableProps { accessToken: string | null; publicPage: boolean; premiumUser: boolean; + userRole: string | null; } interface ModelGroupInfo { @@ -38,7 +42,7 @@ interface ModelGroupInfo { supports_vision: boolean; supports_function_calling: boolean; supported_openai_params?: string[]; - public?: boolean; // Whether the model is public (defaults to false) + is_public_model_group: boolean; // Allow any additional properties for flexibility [key: string]: any; } @@ -47,6 +51,7 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage, premiumUser, + userRole, }) => { const [publicPageAllowed, setPublicPageAllowed] = useState(false); const [modelHubData, setModelHubData] = useState(null); @@ -54,11 +59,8 @@ const ModelHubTable: React.FC = ({ const [isModalVisible, setIsModalVisible] = useState(false); const [isPublicPageModalVisible, setIsPublicPageModalVisible] = useState(false); const [selectedModel, setSelectedModel] = useState(null); - const [searchTerm, setSearchTerm] = useState(""); - const [selectedProvider, setSelectedProvider] = useState(""); - const [selectedMode, setSelectedMode] = useState(""); - const [selectedFeature, setSelectedFeature] = useState(""); - const [selectedModels, setSelectedModels] = useState>(new Set()); + const [filteredData, setFilteredData] = useState([]); + const [isMakePublicModalVisible, setIsMakePublicModalVisible] = useState(false); const router = useRouter(); const tableRef = useRef>(null); @@ -120,34 +122,13 @@ const ModelHubTable: React.FC = ({ router.replace(`/model_hub_table?key=${accessToken}`); }; - const handleMakePublicPage = async () => { + const handleMakePublicPage = () => { if (!accessToken) { return; } - try { - // Get the selected model groups or use all if none are selected - const modelGroupsToMakePublic = selectedModels.size > 0 - ? Array.from(selectedModels) - : modelHubData?.map(model => model.model_group) || []; - - if (modelGroupsToMakePublic.length > 0) { - // Call the endpoint to make the selected model groups public - await makeModelGroupPublic(accessToken, modelGroupsToMakePublic); - - // Show success message - message.success(`Successfully made ${modelGroupsToMakePublic.length} model group(s) public!`); - - // Route to the model hub table - router.push(`/ui/model_hub_table`); - } else { - // Show the modal if no model groups available - setIsPublicPageModalVisible(true); - } - } catch (error) { - console.error("Error making model groups public:", error); - message.error("Failed to make model groups public. Please try again."); - } + // Show the modal for selecting models to make public + setIsMakePublicModalVisible(true); }; const handleOk = () => { @@ -187,90 +168,24 @@ const ModelHubTable: React.FC = ({ return `$${(cost * 1_000_000).toFixed(2)}`; }; - const getUniqueProviders = (data: ModelGroupInfo[]) => { - const providers = new Set(); - data.forEach(model => { - model.providers.forEach(provider => providers.add(provider)); - }); - return Array.from(providers); - }; - - const getUniqueModes = (data: ModelGroupInfo[]) => { - const modes = new Set(); - data.forEach(model => { - if (model.mode) modes.add(model.mode); - }); - return Array.from(modes); - }; - - const getUniqueFeatures = (data: ModelGroupInfo[]) => { - const features = new Set(); - data.forEach(model => { - // Find all properties that start with 'supports_' and are true - Object.entries(model) - .filter(([key, value]) => key.startsWith('supports_') && value === true) - .forEach(([key]) => { - // Format the feature name (remove 'supports_' prefix and convert to title case) - const featureName = key - .replace(/^supports_/, '') - .split('_') - .map(word => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' '); - features.add(featureName); - }); - }); - return Array.from(features).sort(); - }; - - const filteredData = modelHubData?.filter(model => { - const matchesSearch = model.model_group.toLowerCase().includes(searchTerm.toLowerCase()); - const matchesProvider = selectedProvider === "" || model.providers.includes(selectedProvider); - const matchesMode = selectedMode === "" || model.mode === selectedMode; - - // Check if model has the selected feature - const matchesFeature = selectedFeature === "" || - Object.entries(model) - .filter(([key, value]) => key.startsWith('supports_') && value === true) - .some(([key]) => { - const featureName = key - .replace(/^supports_/, '') - .split('_') - .map(word => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' '); - return featureName === selectedFeature; - }); - - return matchesSearch && matchesProvider && matchesMode && matchesFeature; - }) || []; - - const handleRowSelection = (modelGroup: string, isSelected: boolean) => { - const newSelection = new Set(selectedModels); - if (isSelected) { - newSelection.add(modelGroup); - } else { - newSelection.delete(modelGroup); - } - setSelectedModels(newSelection); - }; - - const handleSelectAll = (checked: boolean) => { - console.log("checked", checked); - if (checked) { - const allModelGroups = filteredData.map(model => model.model_group); - setSelectedModels(new Set(allModelGroups)); - } else { - setSelectedModels(new Set()); + const handleMakePublicSuccess = () => { + // Refresh the model hub data after successful public operation + if (accessToken) { + const fetchData = async () => { + try { + const _modelHubData = await modelHubCall(accessToken); + setModelHubData(_modelHubData.data); + } catch (error) { + console.error("Error refreshing model data:", error); + } + }; + fetchData(); } }; - // Use the same logic as health check columns - const allModelsSelected = filteredData.length > 0 && filteredData.every(model => selectedModels.has(model.model_group)); - const isIndeterminate = selectedModels.size > 0 && !allModelsSelected; - - // Clear selections when filters change to avoid confusion - useEffect(() => { - setSelectedModels(new Set()); - }, [searchTerm, selectedProvider, selectedMode, selectedFeature]); + const handleFilteredDataChange = useCallback((newFilteredData: ModelGroupInfo[]) => { + setFilteredData(newFilteredData); + }, []); console.log("publicPage: ", publicPage); console.log("publicPageAllowed: ", publicPageAllowed); @@ -287,9 +202,15 @@ const ModelHubTable: React.FC = ({
Model Hub + {isAdminRole(userRole || "") ? ( +

+ Make models public for developers to know what models are available on the proxy. +

+ ): (

A list of all public model names personally available to you.

+ )}
Model Hub URL: @@ -304,86 +225,26 @@ const ModelHubTable: React.FC = ({
- {publicPage == false && ( - + {publicPage == false && isAdminRole(userRole || "") && ( - )}
{/* Filters */} - -
-
- Search Models: - setSearchTerm(e.target.value)} - className="border rounded px-3 py-2 w-64 h-10 text-sm" - /> -
-
- Provider: - -
-
- Mode: - -
-
- Features: - -
-
-
+ {/* Model Table */} = ({ Showing {filteredData.length} of {modelHubData?.length || 0} models - {selectedModels.size > 0 && ( -
- - {selectedModels.size} model{selectedModels.size !== 1 ? 's' : ''} selected - - -
- )} ) : ( @@ -589,6 +435,15 @@ print(response.choices[0].message.content)`} )} + + {/* Make Model Public Form */} + setIsMakePublicModalVisible(false)} + accessToken={accessToken || ""} + modelHubData={modelHubData || []} + onSuccess={handleMakePublicSuccess} + /> ); }; diff --git a/ui/litellm-dashboard/src/components/model_hub_table_columns.tsx b/ui/litellm-dashboard/src/components/model_hub_table_columns.tsx index 04d45458d86..3393a7adc12 100644 --- a/ui/litellm-dashboard/src/components/model_hub_table_columns.tsx +++ b/ui/litellm-dashboard/src/components/model_hub_table_columns.tsx @@ -1,6 +1,6 @@ import { ColumnDef } from "@tanstack/react-table"; import { Button, Badge, Text } from "@tremor/react"; -import { Tooltip, Checkbox, Tag } from "antd"; +import { Tooltip, Tag } from "antd"; import { CopyOutlined, InfoCircleOutlined @@ -20,7 +20,7 @@ interface ModelHubData { supports_vision: boolean; supports_function_calling: boolean; supported_openai_params?: string[]; - public?: boolean; + is_public_model_group: boolean; [key: string]: any; } @@ -52,40 +52,11 @@ const formatTokens = (tokens: number) => { }; export const modelHubColumns = ( - selectedModels: Set, - allModelsSelected: boolean, - isIndeterminate: boolean, - handleModelSelection: (modelGroup: string, checked: boolean) => void, - handleSelectAll: (checked: boolean) => void, showModal: (model: ModelHubData) => void, copyToClipboard: (text: string) => void, publicPage: boolean = false, ): ColumnDef[] => { const allColumns: ColumnDef[] = [ - { - header: () => ( - handleSelectAll(e.target.checked)} - onClick={(e) => e.stopPropagation()} - /> - ), - id: "select", - enableSorting: false, - cell: ({ row }) => { - const model = row.original; - const isSelected = selectedModels.has(model.model_group); - - return ( - handleModelSelection(model.model_group, e.target.checked)} - onClick={(e) => e.stopPropagation()} - /> - ); - }, - }, { header: "Public Model Name", accessorKey: "model_group", @@ -240,7 +211,7 @@ export const modelHubColumns = ( }, { header: "Public", - accessorKey: "public", + accessorKey: "is_public_model_group", enableSorting: true, sortingFn: (rowA, rowB) => { const publicA = rowA.original.is_public_model_group === true ? 1 : 0; @@ -285,11 +256,8 @@ export const modelHubColumns = ( // Filter out columns based on publicPage setting if (publicPage) { return allColumns.filter(column => { - // Remove the select/checkbox column - if (column.id === "select") return false; - // Remove the public column - if ('accessorKey' in column && column.accessorKey === "public") return false; + if ('accessorKey' in column && column.accessorKey === "is_public_model_group") return false; return true; });