diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.test.tsx index b0df37ad6d..e1b3b35830 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.test.tsx @@ -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 = {}; + 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( + + {}} + premiumUser={false} + teams={[]} + /> + , + ); + 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( + + {}} + premiumUser={false} + teams={[]} + /> + , + ); + + // 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( + + {}} + premiumUser={false} + teams={[]} + /> + , + ); + + // 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 = { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx index b697a859dc..514ae673d0 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx @@ -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 = ({ premiumUser, te const [selectedModelId, setSelectedModelId] = useState(null); const [selectedTeamId, setSelectedTeamId] = useState(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 = ({ 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 = ({ premiumUser, te

Add and manage models for the proxy

)} + {!showMissingProviderBanner && ( + + + Request Provider + + )} {/* Missing Provider Banner */} -
-
- -
-
-

Missing a provider?

-

- The LiteLLM engineering team is constantly adding support for new LLM models, providers, endpoints. If - you don't see the one you need, let us know and we'll prioritize it. -

-
- - Request Provider - +
+ +
+
+

Missing a provider?

+

+ The LiteLLM engineering team is constantly adding support for new LLM models, providers, endpoints. If + you don't see the one you need, let us know and we'll prioritize it. +

+
+
- - - -
+ Request Provider + + + + + + + )} {selectedModelId && !isLoading ? ( = ({ premiumUser, te {all_admin_roles.includes(userRole) && Price Data Reload} -
- {lastRefreshed && Last Refreshed: {lastRefreshed}} +
+ {lastRefreshed && Last Refreshed: {lastRefreshed}}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.test.tsx index 34c1c3ca4b..b7d4db2618 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.test.tsx @@ -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(); + + 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(); + + 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"); + }); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx index 36948630d8..d7687def80 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx @@ -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(""); const [debouncedSearch, setDebouncedSearch] = useState(""); @@ -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(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 ( @@ -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)} />
+ + setDeleteModalModelId(null)} + onOk={handleDeleteModel} + confirmLoading={deleteLoading} + /> setIsModelSettingsModalVisible(false)} diff --git a/ui/litellm-dashboard/src/components/model_dashboard/all_models_table.tsx b/ui/litellm-dashboard/src/components/model_dashboard/all_models_table.tsx index 964bb5658f..0585d96087 100644 --- a/ui/litellm-dashboard/src/components/model_dashboard/all_models_table.tsx +++ b/ui/litellm-dashboard/src/components/model_dashboard/all_models_table.tsx @@ -30,6 +30,7 @@ interface AllModelsDataTableProps { pagination?: PaginationState; onPaginationChange?: OnChangeFn; enablePagination?: boolean; + onRowClick?: (row: TData) => void; } export function AllModelsDataTable({ @@ -41,6 +42,7 @@ export function AllModelsDataTable({ pagination, onPaginationChange, enablePagination = false, + onRowClick, }: AllModelsDataTableProps) { const [columnResizeMode] = React.useState("onChange"); const [columnSizing, setColumnSizing] = React.useState({}); @@ -174,7 +176,11 @@ export function AllModelsDataTable({ ) : tableInstance.getRowModel().rows.length > 0 ? ( tableInstance.getRowModel().rows.map((row) => ( - + onRowClick?.(row.original)} + > {row.getVisibleCells().map((cell) => ( { pagination?: PaginationState; onPaginationChange?: OnChangeFn; enablePagination?: boolean; + onRowClick?: (row: TData) => void; } export function ModelDataTable({ @@ -40,6 +41,7 @@ export function ModelDataTable({ pagination, onPaginationChange, enablePagination = false, + onRowClick, }: ModelDataTableProps) { const [sorting, setSorting] = React.useState(defaultSorting); const [columnResizeMode] = React.useState("onChange"); @@ -164,7 +166,11 @@ export function ModelDataTable({ ) : tableInstance.getRowModel().rows.length > 0 ? ( tableInstance.getRowModel().rows.map((row) => ( - + onRowClick?.(row.original)} + className={onRowClick ? "cursor-pointer hover:bg-gray-50" : ""} + > {row.getVisibleCells().map((cell) => ( void, expandedRows: Set, setExpandedRows: (expandedRows: Set) => void, + onDeleteClick?: (modelId: string) => void, ): ColumnDef[] => [ { header: () => Model ID, @@ -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} @@ -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)}... @@ -409,9 +416,10 @@ export const columns = ( { - 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"}