Merge pull request #19518 from Chesars/fix-model-management-ui

Fix model management page UI improvements
This commit is contained in:
Cesar Garcia
2026-03-04 18:02:55 -03:00
committed by GitHub
7 changed files with 385 additions and 50 deletions
@@ -1,9 +1,27 @@
/* @vitest-environment jsdom */
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, render } from "@testing-library/react";
import { act, fireEvent, render } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import ModelsAndEndpointsView from "./ModelsAndEndpointsView";
// Mock localStorage
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: (key: string) => store[key] || null,
setItem: (key: string, value: string) => {
store[key] = value;
},
removeItem: (key: string) => {
delete store[key];
},
clear: () => {
store = {};
},
};
})();
Object.defineProperty(window, "localStorage", { value: localStorageMock });
// Minimal stubs to avoid Next.js router and network usage during render
vi.mock("@/components/networking", () => ({
credentialListCall: vi.fn().mockResolvedValue({ credentials: [] }),
@@ -115,6 +133,84 @@ describe("ModelsAndEndpointsView", () => {
expect(await findByText("Model Management", {}, { timeout: 10000 })).toBeInTheDocument();
}, 15000);
it("should show Missing provider banner by default", async () => {
localStorageMock.clear();
const queryClient = createQueryClient();
const { findByText } = render(
<QueryClientProvider client={queryClient}>
<ModelsAndEndpointsView
token="123"
modelData={{ data: [] }}
keys={[]}
setModelData={() => {}}
premiumUser={false}
teams={[]}
/>
</QueryClientProvider>,
);
expect(await findByText("Missing a provider?", {}, { timeout: 10000 })).toBeInTheDocument();
}, 15000);
it("should hide Missing provider banner when dismiss button is clicked and persist to localStorage", async () => {
localStorageMock.clear();
const queryClient = createQueryClient();
const { findByText, queryByText, container } = render(
<QueryClientProvider client={queryClient}>
<ModelsAndEndpointsView
token="123"
modelData={{ data: [] }}
keys={[]}
setModelData={() => {}}
premiumUser={false}
teams={[]}
/>
</QueryClientProvider>,
);
// Wait for banner to appear
expect(await findByText("Missing a provider?", {}, { timeout: 10000 })).toBeInTheDocument();
// Find and click dismiss button (X button)
const dismissButton = container.querySelector('button[aria-label="Dismiss banner"]');
expect(dismissButton).not.toBeNull();
fireEvent.click(dismissButton!);
// Banner should be hidden
expect(queryByText("Missing a provider?")).not.toBeInTheDocument();
// LocalStorage should be updated
expect(localStorageMock.getItem("hideMissingProviderBanner")).toBe("true");
}, 15000);
it("should show compact Request Provider button when banner is dismissed", async () => {
// Set localStorage to hide banner
localStorageMock.setItem("hideMissingProviderBanner", "true");
const queryClient = createQueryClient();
const { findByText, queryByText } = render(
<QueryClientProvider client={queryClient}>
<ModelsAndEndpointsView
token="123"
modelData={{ data: [] }}
keys={[]}
setModelData={() => {}}
premiumUser={false}
teams={[]}
/>
</QueryClientProvider>,
);
// Wait for component to render
await findByText("Model Management", {}, { timeout: 10000 });
// Banner should not be visible
expect(queryByText("Missing a provider?")).not.toBeInTheDocument();
// Compact Request Provider button should be visible in header
const requestProviderLinks = document.querySelectorAll('a[href="https://models.litellm.ai/?request=true"]');
// There should be a compact button when banner is hidden
expect(requestProviderLinks.length).toBeGreaterThan(0);
}, 15000);
it("should pass model IDs (not model names) to HealthCheckComponent as all_models_on_proxy", async () => {
mockHealthCheckComponent.mockClear();
const modelDataWithIds = {
@@ -15,7 +15,7 @@ import { transformModelData } from "./utils/modelDataTransformer";
import { all_admin_roles, internalUserRoles, isProxyAdminRole, isUserTeamAdminForAnyTeam } from "@/utils/roles";
import { RefreshIcon } from "@heroicons/react/outline";
import { useQueryClient } from "@tanstack/react-query";
import { Col, Grid, Icon, Tab, TabGroup, TabList, TabPanel, TabPanels, Text } from "@tremor/react";
import { Col, Grid, Icon, Tab, TabGroup, TabList, TabPanel, TabPanels } from "@tremor/react";
import type { UploadProps } from "antd";
import { Form, Typography } from "antd";
import { PlusCircleOutlined } from "@ant-design/icons";
@@ -62,6 +62,12 @@ const ModelsAndEndpointsView: React.FC<ModelDashboardProps> = ({ premiumUser, te
const [selectedModelId, setSelectedModelId] = useState<string | null>(null);
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
const [showMissingProviderBanner, setShowMissingProviderBanner] = useState(() => {
if (typeof window !== "undefined") {
return localStorage.getItem("hideMissingProviderBanner") !== "true";
}
return true;
});
const queryClient = useQueryClient();
const { data: modelDataResponse, isLoading: isLoadingModels, refetch: refetchModels } = useModelsInfo();
@@ -160,7 +166,7 @@ const ModelsAndEndpointsView: React.FC<ModelDashboardProps> = ({ premiumUser, te
const handleRefreshClick = () => {
const currentDate = new Date();
setLastRefreshed(currentDate.toLocaleString());
setLastRefreshed(currentDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }));
queryClient.invalidateQueries({ queryKey: ["models", "list"] });
refetchModels();
};
@@ -282,43 +288,75 @@ const ModelsAndEndpointsView: React.FC<ModelDashboardProps> = ({ premiumUser, te
<p className="text-sm text-gray-600">Add and manage models for the proxy</p>
)}
</div>
{!showMissingProviderBanner && (
<a
href="https://models.litellm.ai/?request=true"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-[#6366f1] hover:text-[#5558e3] border border-[#6366f1] hover:border-[#5558e3] rounded-lg transition-colors"
>
<PlusCircleOutlined style={{ fontSize: "12px" }} />
Request Provider
</a>
)}
</div>
{/* Missing Provider Banner */}
<div className="mb-4 px-4 py-3 bg-blue-50 rounded-lg border border-blue-100 flex items-center gap-4">
<div className="flex-shrink-0 w-10 h-10 bg-white rounded-full flex items-center justify-center border border-blue-200">
<PlusCircleOutlined style={{ fontSize: "18px", color: "#6366f1" }} />
</div>
<div className="flex-1 min-w-0">
<h4 className="text-gray-900 font-semibold text-sm m-0">Missing a provider?</h4>
<p className="text-gray-500 text-xs m-0 mt-0.5">
The LiteLLM engineering team is constantly adding support for new LLM models, providers, endpoints. If
you don&apos;t see the one you need, let us know and we&apos;ll prioritize it.
</p>
</div>
<a
href="https://models.litellm.ai/?request=true"
target="_blank"
rel="noopener noreferrer"
className="flex-shrink-0 inline-flex items-center gap-2 px-4 py-2 bg-[#6366f1] hover:bg-[#5558e3] text-white text-sm font-medium rounded-lg transition-colors"
>
Request Provider
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
{showMissingProviderBanner && (
<div className="mb-4 px-4 py-3 bg-blue-50 rounded-lg border border-blue-100 flex items-center gap-4">
<div className="flex-shrink-0 w-10 h-10 bg-white rounded-full flex items-center justify-center border border-blue-200">
<PlusCircleOutlined style={{ fontSize: "18px", color: "#6366f1" }} />
</div>
<div className="flex-1 min-w-0">
<h4 className="text-gray-900 font-semibold text-sm m-0">Missing a provider?</h4>
<p className="text-gray-500 text-xs m-0 mt-0.5">
The LiteLLM engineering team is constantly adding support for new LLM models, providers, endpoints. If
you don&apos;t see the one you need, let us know and we&apos;ll prioritize it.
</p>
</div>
<a
href="https://models.litellm.ai/?request=true"
target="_blank"
rel="noopener noreferrer"
className="flex-shrink-0 inline-flex items-center gap-2 px-4 py-2 bg-[#6366f1] hover:bg-[#5558e3] text-white text-sm font-medium rounded-lg transition-colors"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
</a>
</div>
Request Provider
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
</a>
<button
onClick={() => {
setShowMissingProviderBanner(false);
localStorage.setItem("hideMissingProviderBanner", "true");
}}
className="flex-shrink-0 p-1 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-full transition-colors"
aria-label="Dismiss banner"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
)}
{selectedModelId && !isLoading ? (
<ModelInfoView
modelId={selectedModelId}
@@ -348,13 +386,13 @@ const ModelsAndEndpointsView: React.FC<ModelDashboardProps> = ({ premiumUser, te
{all_admin_roles.includes(userRole) && <Tab>Price Data Reload</Tab>}
</div>
<div className="flex items-center space-x-2">
{lastRefreshed && <Text>Last Refreshed: {lastRefreshed}</Text>}
<div className="flex items-center space-x-2 self-center">
{lastRefreshed && <span className="text-xs text-gray-500">Last Refreshed: {lastRefreshed}</span>}
<Icon
icon={RefreshIcon} // Modify as necessary for correct icon name
icon={RefreshIcon}
variant="shadow"
size="xs"
className="self-center"
className="cursor-pointer"
onClick={handleRefreshClick}
/>
</div>
@@ -1,8 +1,31 @@
import * as useAuthorizedModule from "@/app/(dashboard)/hooks/useAuthorized";
import { renderWithProviders, screen, waitFor } from "../../../../../tests/test-utils";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { renderWithProviders } from "../../../../../tests/test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
import AllModelsTab from "./AllModelsTab";
// Mock modelDeleteCall
const mockModelDeleteCall = vi.fn().mockResolvedValue({});
vi.mock("@/components/networking", () => ({
modelDeleteCall: (...args: any[]) => mockModelDeleteCall(...args),
}));
// Mock NotificationsManager
vi.mock("@/components/molecules/notifications_manager", () => ({
default: {
success: vi.fn(),
fromBackend: vi.fn(),
},
}));
// Mock react-query
const mockInvalidateQueries = vi.fn();
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({
invalidateQueries: mockInvalidateQueries,
}),
}));
// Mock the useModelsInfo hook
const mockUseModelsInfo = vi.fn(() => ({
data: { data: [], total_count: 0, current_page: 1, total_pages: 1, size: 50 },
@@ -493,4 +516,101 @@ describe("AllModelsTab", () => {
const previousButton = screen.getByRole("button", { name: /previous/i });
expect(previousButton).toBeDisabled();
});
it("should pass setDeleteModalModelId to columns for delete functionality", async () => {
// This test verifies that the delete modal setter is passed to columns
// The actual modal rendering is handled by DeleteResourceModal component
mockUseTeams.mockReturnValue({
data: [],
isLoading: false,
error: null,
refetch: vi.fn(),
});
mockUseModelCostMap.mockReturnValue(
createModelCostMapMock({
"gpt-4-delete-test": { litellm_provider: "openai" },
}),
);
const modelData = createPaginatedModelData([
{
model_name: "gpt-4-delete-test",
litellm_model_name: "gpt-4-delete-test",
provider: "openai",
model_info: {
id: "model-to-delete",
db_model: true,
direct_access: true,
access_via_team_ids: [],
access_groups: [],
created_by: "user-123",
created_at: "2024-01-01",
updated_at: "2024-01-01",
},
},
], 1, 1, 1, 50);
mockUseModelsInfo.mockReturnValue({ data: modelData, isLoading: false, error: null, refetch: vi.fn() });
render(<AllModelsTab {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("gpt-4-delete-test")).toBeInTheDocument();
});
// Verify the DB Model badge is shown (indicating it can be deleted)
expect(screen.getByText("DB Model")).toBeInTheDocument();
});
it("should render clickable model ID that calls setSelectedModelId", async () => {
mockUseTeams.mockReturnValue({
data: [],
isLoading: false,
error: null,
refetch: vi.fn(),
});
mockUseModelCostMap.mockReturnValue(
createModelCostMapMock({
"gpt-4-clickable": { litellm_provider: "openai" },
}),
);
const modelData = createPaginatedModelData([
{
model_name: "gpt-4-clickable",
litellm_model_name: "gpt-4-clickable",
provider: "openai",
model_info: {
id: "clickable-model-id",
db_model: true,
direct_access: true,
access_via_team_ids: [],
access_groups: [],
created_by: "user-123",
created_at: "2024-01-01",
updated_at: "2024-01-01",
},
},
], 1, 1, 1, 50);
mockUseModelsInfo.mockReturnValue({ data: modelData, isLoading: false, error: null, refetch: vi.fn() });
render(<AllModelsTab {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("gpt-4-clickable")).toBeInTheDocument();
});
// Click on the Model ID cell which should call setSelectedModelId
const modelIdCell = screen.getByText("clickable-model-id");
expect(modelIdCell).toBeInTheDocument();
fireEvent.click(modelIdCell);
await waitFor(() => {
expect(mockSetSelectedModelId).toHaveBeenCalledWith("clickable-model-id");
});
});
});
@@ -5,8 +5,12 @@ import { Team } from "@/components/key_team_helpers/key_list";
import { AllModelsDataTable } from "@/components/model_dashboard/all_models_table";
import { columns } from "@/components/molecules/models/columns";
import { getDisplayModelName } from "@/components/view_model/model_name_display";
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
import NotificationsManager from "@/components/molecules/notifications_manager";
import { modelDeleteCall } from "@/components/networking";
import { InfoCircleOutlined, SettingOutlined } from "@ant-design/icons";
import { PaginationState, SortingState } from "@tanstack/react-table";
import { useQueryClient } from "@tanstack/react-query";
import { Grid, TabPanel } from "@tremor/react";
import { Badge, Button, Select, Skeleton, Space, Typography } from "antd";
import ModelSettingsModal from "@/components/model_dashboard/ModelSettingsModal/ModelSettingsModal";
@@ -35,8 +39,9 @@ const AllModelsTab = ({
setSelectedTeamId,
}: AllModelsTabProps) => {
const { data: modelCostMapData, isLoading: isLoadingModelCostMap } = useModelCostMap();
const { userId, userRole, premiumUser } = useAuthorized();
const { accessToken, userId, userRole, premiumUser } = useAuthorized();
const { data: teams, isLoading: isLoadingTeams } = useTeams();
const queryClient = useQueryClient();
const [modelNameSearch, setModelNameSearch] = useState<string>("");
const [debouncedSearch, setDebouncedSearch] = useState<string>("");
@@ -95,7 +100,7 @@ const AllModelsTab = ({
return sort.desc ? "desc" : "asc";
}, [sorting]);
const { data: rawModelData, isLoading: isLoadingModelsInfo } = useModelsInfo(
const { data: rawModelData, isLoading: isLoadingModelsInfo, refetch: refetchModels } = useModelsInfo(
currentPage,
pageSize,
debouncedSearch || undefined,
@@ -120,6 +125,9 @@ const AllModelsTab = ({
return transformModelData(rawModelData, getProviderFromModel);
}, [rawModelData, modelCostMapData]);
const [deleteModalModelId, setDeleteModalModelId] = useState<string | null>(null);
const [deleteLoading, setDeleteLoading] = useState(false);
// Get pagination metadata from the response
const paginationMeta = useMemo(() => {
if (!rawModelData) {
@@ -190,6 +198,28 @@ const AllModelsTab = ({
setSorting([]);
};
const modelToDelete = useMemo(() => {
if (!deleteModalModelId || !modelData?.data) return null;
return modelData.data.find((model: any) => model.model_info.id === deleteModalModelId);
}, [deleteModalModelId, modelData]);
const handleDeleteModel = async () => {
if (!accessToken || !deleteModalModelId) return;
try {
setDeleteLoading(true);
await modelDeleteCall(accessToken, deleteModalModelId);
NotificationsManager.success("Model deleted successfully");
queryClient.invalidateQueries({ queryKey: ["models", "list"] });
refetchModels();
} catch (error) {
console.error("Error deleting model:", error);
NotificationsManager.fromBackend(error);
} finally {
setDeleteLoading(false);
setDeleteModalModelId(null);
}
};
return (
<TabPanel>
<Grid>
@@ -504,6 +534,7 @@ const AllModelsTab = ({
() => { },
expandedRows,
setExpandedRows,
setDeleteModalModelId,
)}
data={filteredData}
isLoading={isLoadingModelsInfo}
@@ -512,10 +543,40 @@ const AllModelsTab = ({
pagination={pagination}
onPaginationChange={setPagination}
enablePagination={true}
onRowClick={(model: any) => setSelectedModelId(model.model_info.id)}
/>
</div>
</div>
</Grid>
<DeleteResourceModal
isOpen={!!deleteModalModelId}
title="Delete Model"
alertMessage="This action cannot be undone."
message="Are you sure you want to delete this model?"
resourceInformationTitle="Model Information"
resourceInformation={modelToDelete ? [
{
label: "Model Name",
value: modelToDelete.model_name || "Not Set",
},
{
label: "LiteLLM Model Name",
value: modelToDelete.litellm_model_name || "Not Set",
},
{
label: "Provider",
value: modelToDelete.provider || "Not Set",
},
{
label: "Created By",
value: modelToDelete.model_info?.created_by || "Not Set",
},
] : []}
onCancel={() => setDeleteModalModelId(null)}
onOk={handleDeleteModel}
confirmLoading={deleteLoading}
/>
<ModelSettingsModal
isVisible={isModelSettingsModalVisible}
onCancel={() => setIsModelSettingsModalVisible(false)}
@@ -30,6 +30,7 @@ interface AllModelsDataTableProps<TData, TValue> {
pagination?: PaginationState;
onPaginationChange?: OnChangeFn<PaginationState>;
enablePagination?: boolean;
onRowClick?: (row: TData) => void;
}
export function AllModelsDataTable<TData, TValue>({
@@ -41,6 +42,7 @@ export function AllModelsDataTable<TData, TValue>({
pagination,
onPaginationChange,
enablePagination = false,
onRowClick,
}: AllModelsDataTableProps<TData, TValue>) {
const [columnResizeMode] = React.useState<ColumnResizeMode>("onChange");
const [columnSizing, setColumnSizing] = React.useState({});
@@ -174,7 +176,11 @@ export function AllModelsDataTable<TData, TValue>({
</TableRow>
) : tableInstance.getRowModel().rows.length > 0 ? (
tableInstance.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
<TableRow
key={row.id}
className={onRowClick ? "cursor-pointer hover:bg-gray-50" : ""}
onClick={() => onRowClick?.(row.original)}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
@@ -30,6 +30,7 @@ interface ModelDataTableProps<TData, TValue> {
pagination?: PaginationState;
onPaginationChange?: OnChangeFn<PaginationState>;
enablePagination?: boolean;
onRowClick?: (row: TData) => void;
}
export function ModelDataTable<TData, TValue>({
@@ -40,6 +41,7 @@ export function ModelDataTable<TData, TValue>({
pagination,
onPaginationChange,
enablePagination = false,
onRowClick,
}: ModelDataTableProps<TData, TValue>) {
const [sorting, setSorting] = React.useState<SortingState>(defaultSorting);
const [columnResizeMode] = React.useState<ColumnResizeMode>("onChange");
@@ -164,7 +166,11 @@ export function ModelDataTable<TData, TValue>({
</TableRow>
) : tableInstance.getRowModel().rows.length > 0 ? (
tableInstance.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
<TableRow
key={row.id}
onClick={() => onRowClick?.(row.original)}
className={onRowClick ? "cursor-pointer hover:bg-gray-50" : ""}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
@@ -52,6 +52,7 @@ export const columns = (
handleRefreshClick: () => void,
expandedRows: Set<string>,
setExpandedRows: (expandedRows: Set<string>) => void,
onDeleteClick?: (modelId: string) => void,
): ColumnDef<ModelData>[] => [
{
header: () => <span className="text-sm font-semibold">Model ID</span>,
@@ -67,7 +68,10 @@ export const columns = (
ellipsis
className="text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs cursor-pointer w-full block"
style={{ fontSize: 14, padding: '1px 8px' }}
onClick={() => setSelectedModelId(model.model_info.id)}
onClick={(e) => {
e.stopPropagation();
setSelectedModelId(model.model_info.id);
}}
>
{model.model_info.id}
</Text>
@@ -297,7 +301,10 @@ export const columns = (
size="xs"
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 w-full"
onClick={() => setSelectedTeamId(model.model_info.team_id)}
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
setSelectedTeamId(model.model_info.team_id);
}}
>
{model.model_info.team_id.slice(0, 7)}...
</Button>
@@ -409,9 +416,10 @@ export const columns = (
<Icon
icon={TrashIcon}
size="sm"
onClick={() => {
if (canEditModel) {
setSelectedModelId(model.model_info.id);
onClick={(e) => {
e.stopPropagation();
if (canEditModel && onDeleteClick) {
onDeleteClick(model.model_info.id);
}
}}
className={!canEditModel ? "opacity-50 cursor-not-allowed" : "cursor-pointer hover:text-red-600"}