diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 1bd3fceb6ff..6a50f4aa99e 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1143,25 +1143,6 @@ "count": 1 } }, - "src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.test.tsx": { - "max-params": { - "count": 1 - }, - "unused-imports/no-unused-imports": { - "count": 1 - } - }, - "src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx": { - "local/no-complex-jsx-arrow": { - "count": 2 - }, - "no-restricted-imports": { - "count": 2 - }, - "react-hooks/set-state-in-effect": { - "count": 3 - } - }, "src/app/(dashboard)/models-and-endpoints/components/ModelRetrySettingsTab.test.tsx": { "react/display-name": { "count": 1 @@ -3483,17 +3464,6 @@ "count": 1 } }, - "src/components/model_dashboard/all_models_table.tsx": { - "local/filename-pascal-case": { - "count": 1 - }, - "no-nested-ternary": { - "count": 1 - }, - "no-restricted-imports": { - "count": 1 - } - }, "src/components/model_filters.tsx": { "local/filename-pascal-case": { "count": 1 @@ -3560,28 +3530,6 @@ "count": 2 } }, - "src/components/molecules/models/columns.test.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "react/display-name": { - "count": 1 - } - }, - "src/components/molecules/models/columns.tsx": { - "local/filename-pascal-case": { - "count": 1 - }, - "max-params": { - "count": 1 - }, - "no-nested-ternary": { - "count": 2 - }, - "no-restricted-imports": { - "count": 2 - } - }, "src/components/molecules/notifications_manager.test.tsx": { "no-restricted-imports": { "count": 1 @@ -4179,6 +4127,11 @@ "count": 1 } }, + "src/components/ui/hover-card.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, "src/components/ui/input-group.tsx": { "local/filename-pascal-case": { "count": 1 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 045bf0a5f44..659a618c8f6 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,662 +1,364 @@ import * as useAuthorizedModule from "@/app/(dashboard)/hooks/useAuthorized"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { renderWithProviders } from "../../../../../tests/test-utils"; +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import AllModelsTab from "./AllModelsTab"; -// Mock modelDeleteCall +import AllModelsTab from "./AllModelsTab"; +import { STATUS_COLUMN_ID, toServerSortField } from "./ModelsTableColumns"; + const mockModelDeleteCall = vi.fn().mockResolvedValue({}); +const mockModelPatchUpdateCall = vi.fn().mockResolvedValue({}); vi.mock("@/components/networking", () => ({ - modelDeleteCall: (...args: any[]) => mockModelDeleteCall(...args), + modelDeleteCall: (...args: unknown[]) => mockModelDeleteCall(...args), + modelPatchUpdateCall: (...args: unknown[]) => mockModelPatchUpdateCall(...args), })); -// Mock NotificationsManager vi.mock("@/components/molecules/notifications_manager", () => ({ - default: { - success: vi.fn(), - fromBackend: vi.fn(), + default: { success: vi.fn(), fromBackend: vi.fn() }, +})); + +vi.mock("@/components/model_dashboard/ModelSettingsModal/ModelSettingsModal", () => ({ + default: function ModelSettingsModalMock({ isVisible }: { isVisible: boolean }) { + return isVisible ?
: null; }, })); -// Mock react-query const mockInvalidateQueries = vi.fn(); vi.mock("@tanstack/react-query", async (importOriginal) => { - const actual = (await importOriginal()) as any; - return { - ...actual, - useQueryClient: () => ({ - invalidateQueries: mockInvalidateQueries, - }), - }; + const actual = await importOriginal(); + return { ...actual, useQueryClient: () => ({ invalidateQueries: mockInvalidateQueries }) }; }); -// Mock the useModelsInfo hook -const mockUseModelsInfo = vi.fn(() => ({ - data: { data: [], total_count: 0, current_page: 1, total_pages: 1, size: 50 }, - isLoading: false, - error: null, -})) as any; +interface ModelsInfoArgs { + page?: number; + size?: number; + search?: string; + teamId?: string; + sortBy?: string; + sortOrder?: string; +} + +const modelsInfoCalls: ModelsInfoArgs[] = []; +const mockRefetch = vi.fn(); +let modelsInfoResult: Record = {}; + +type UseModelsInfoArgs = [ + page?: number, + size?: number, + search?: string, + modelId?: string, + teamId?: string, + sortBy?: string, + sortOrder?: string, +]; vi.mock("../../hooks/models/useModels", () => ({ - useModelsInfo: (page?: number, size?: number, search?: string) => mockUseModelsInfo(page, size, search), -})); - -// Mock the useModelCostMap hook -const mockUseModelCostMap = vi.fn(() => ({ - data: { - "gpt-4": { litellm_provider: "openai" }, - "gpt-3.5-turbo": { litellm_provider: "openai" }, - "gpt-4-accessible": { litellm_provider: "openai" }, - "gpt-3.5-turbo-blocked": { litellm_provider: "openai" }, - "gpt-4-sales": { litellm_provider: "openai" }, - "gpt-4-engineering": { litellm_provider: "openai" }, - "gpt-4-personal": { litellm_provider: "openai" }, - "gpt-4-team-only": { litellm_provider: "openai" }, - "gpt-4-config": { litellm_provider: "openai" }, - "gpt-4-db": { litellm_provider: "openai" }, + useModelsInfo: (...args: UseModelsInfoArgs) => { + const [page, size, search, , teamId, sortBy, sortOrder] = args; + const call: ModelsInfoArgs = { page, size, search, teamId, sortBy, sortOrder }; + modelsInfoCalls.push(call); + return { ...modelsInfoResult, refetch: mockRefetch }; }, - isLoading: false, - error: null, -})) as any; +})); vi.mock("../../hooks/models/useModelCostMap", () => ({ - useModelCostMap: () => mockUseModelCostMap(), + useModelCostMap: () => ({ data: { "gpt-4": { litellm_provider: "openai" } }, isLoading: false, error: null }), })); -// Mock the useTeams hook (react-query implementation) -const mockUseTeams = vi.fn(() => ({ - data: [], - isLoading: false, - error: null, - refetch: vi.fn(), -})) as any; - +const mockTeams = [{ team_id: "team-1", team_alias: "Engineering" }]; vi.mock("../../hooks/teams/useTeams", () => ({ - useTeams: () => mockUseTeams(), + useTeams: () => ({ data: mockTeams, isLoading: false, error: null, refetch: vi.fn() }), })); -// Helper function to create model cost map mock return value -const createModelCostMapMock = (data: Record) => ({ - data, - isLoading: false, - error: null, +const BASE_MODEL_INFO = { + id: "model-1", + db_model: true, + created_by: "user-123", + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-02T00:00:00Z", + team_id: "team-1", + access_groups: [], +}; + +const makeRow = (overrides: Record = {}) => ({ + model_name: "gpt-4", + litellm_params: { model: "openai/gpt-4", custom_llm_provider: "openai" }, + model_info: { ...BASE_MODEL_INFO, ...((overrides.model_info as Record) ?? {}) }, }); -// Helper function to create paginated model data mock -const createPaginatedModelData = ( - models: any[], - totalCount: number = models.length, - currentPage: number = 1, - totalPages: number = 1, - size: number = 50, -) => ({ - data: models, - total_count: totalCount, - current_page: currentPage, - total_pages: totalPages, - size: size, -}); +const setModelsInfo = (rows: Record[], totalCount = rows.length, isLoading = false) => { + modelsInfoResult = { + data: { data: rows, total_count: totalCount, current_page: 1, total_pages: 1, size: 50 }, + isLoading, + isFetching: false, + error: null, + }; +}; + +const lastModelsInfoCall = (): ModelsInfoArgs => modelsInfoCalls[modelsInfoCalls.length - 1]; + +const SEARCH_SETTLE_MS = 400; + +const MOCK_AUTHORIZED = { + isLoading: false, + isAuthorized: true, + token: "mock-token", + accessToken: "mock-access-token", + userId: "user-123", + userEmail: "test@example.com", + userRole: "Admin", + premiumUser: true, + disabledPersonalKeyCreation: false, + showSSOBanner: false, +}; + +const mockSetSelectedModelGroup = vi.fn(); +const mockSetSelectedModelId = vi.fn(); +const mockSetSelectedTeamId = vi.fn(); + +const defaultProps = { + selectedModelGroup: "all", + setSelectedModelGroup: mockSetSelectedModelGroup, + availableModelGroups: ["gpt-4", "gpt-3.5-turbo"], + availableModelAccessGroups: ["sales-team"], + setSelectedModelId: mockSetSelectedModelId, + setSelectedTeamId: mockSetSelectedTeamId, +}; describe("AllModelsTab", () => { - const mockSetSelectedModelGroup = vi.fn(); - const mockSetSelectedModelId = vi.fn(); - const mockSetSelectedTeamId = vi.fn(); - - const defaultProps = { - selectedModelGroup: "all", - setSelectedModelGroup: mockSetSelectedModelGroup, - availableModelGroups: ["gpt-4", "gpt-3.5-turbo"], - availableModelAccessGroups: ["sales-team", "engineering-team"], - setSelectedModelId: mockSetSelectedModelId, - setSelectedTeamId: mockSetSelectedTeamId, - }; - - const mockUseAuthorized = { - token: "mock-token", - accessToken: "mock-access-token", - userId: "user-123", - userEmail: "test@example.com", - userRole: "Admin", - premiumUser: true, - disabledPersonalKeyCreation: false, - showSSOBanner: false, - }; - beforeEach(() => { vi.clearAllMocks(); - vi.spyOn(useAuthorizedModule, "default").mockReturnValue(mockUseAuthorized); + modelsInfoCalls.length = 0; + setModelsInfo([makeRow()]); + vi.spyOn(useAuthorizedModule, "default").mockReturnValue(MOCK_AUTHORIZED); }); - it("should render with empty data", () => { - mockUseModelsInfo.mockReturnValueOnce({ - data: createPaginatedModelData([], 0, 1, 1, 50), - isLoading: false, - error: null, - }); + it("renders the fetched models and the server row count", async () => { + setModelsInfo([makeRow()], 137); + render(); - mockUseTeams.mockReturnValueOnce({ - data: [], - isLoading: false, - error: null, - refetch: vi.fn(), - }); - - mockUseModelCostMap.mockReturnValueOnce(createModelCostMapMock({})); - - renderWithProviders(); - expect(screen.getByText("Current Team:")).toBeInTheDocument(); + expect(await screen.findByText("gpt-4")).toBeInTheDocument(); + expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-50 of 137"); }); - it("should filter models by direct team access when current team is selected", async () => { - const mockTeams = [ - { - team_id: "team-456", - team_alias: "Engineering Team", - models: ["gpt-4"], - max_budget: null, - budget_duration: null, - tpm_limit: null, - rpm_limit: null, - organization_id: "org-123", - created_at: "2024-01-01", - keys: [], - members_with_roles: [], - }, + it("does not re-query after the mount-time debounced search settles unchanged", async () => { + render(); + const callsAfterMount = modelsInfoCalls.length; + + await new Promise((resolve) => setTimeout(resolve, SEARCH_SETTLE_MS)); + + expect(modelsInfoCalls.length).toBe(callsAfterMount); + }); + + it("shows the empty state when the proxy returns no models", () => { + setModelsInfo([], 0); + render(); + + expect(screen.getByText("No models found")).toBeInTheDocument(); + }); + + it("shows the loading skeleton while the first page is in flight", () => { + setModelsInfo([], 0, true); + render(); + + expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0); + expect(screen.queryByText("No models found")).not.toBeInTheDocument(); + }); + + describe("server sort contract", () => { + const sortHeader = (columnId: string): HTMLElement => screen.getByTestId(`sort-header-${columnId}`); + + const expectIndicator = async (columnId: string, state: "asc" | "desc" | "none") => { + await waitFor(() => { + expect(sortHeader(columnId).querySelector(`[data-sort-indicator="${state}"]`)).not.toBeNull(); + }); + }; + + const cases: [string, string, string, "asc" | "desc"][] = [ + ["Model Information", "model_name", "model_name", "asc"], + ["Created By", "model_info_created_by", "created_at", "asc"], + ["Updated At", "model_info_updated_at", "updated_at", "asc"], + ["Costs", "input_cost", "costs", "desc"], ]; - mockUseTeams.mockReturnValueOnce({ - data: mockTeams, - isLoading: false, - error: null, - refetch: vi.fn(), + it.each(cases)("sorts %s using the server field %s", async (_label, columnId, serverField, firstDirection) => { + const user = userEvent.setup(); + render(); + + await user.click(sortHeader(columnId)); + await expectIndicator(columnId, firstDirection); + + expect(lastModelsInfoCall().sortBy).toBe(serverField); + expect(lastModelsInfoCall().sortOrder).toBe(firstDirection); }); - mockUseModelCostMap.mockReturnValueOnce( - createModelCostMapMock({ - "gpt-4-accessible": { litellm_provider: "openai" }, - "gpt-3.5-turbo-blocked": { litellm_provider: "openai" }, - }), - ); + it("maps the hidden Status column to the server field status", () => { + expect(toServerSortField(STATUS_COLUMN_ID)).toBe("status"); + }); - const modelData = createPaginatedModelData( - [ - { - model_name: "gpt-4-accessible", - model_info: { - id: "model-1", - access_via_team_ids: ["team-456"], - access_groups: [], - }, - }, - { - model_name: "gpt-3.5-turbo-blocked", - model_info: { - id: "model-2", - access_via_team_ids: ["team-789"], - access_groups: [], - }, - }, - ], - 2, - 1, - 1, - 50, - ); + it("cycles a sorted column back to unsorted", async () => { + const user = userEvent.setup(); + render(); - mockUseModelsInfo.mockReturnValue({ data: modelData, isLoading: false, error: null }); + await user.click(sortHeader("model_info_updated_at")); + await expectIndicator("model_info_updated_at", "asc"); + expect(lastModelsInfoCall().sortOrder).toBe("asc"); - renderWithProviders(); + await user.click(sortHeader("model_info_updated_at")); + await expectIndicator("model_info_updated_at", "desc"); + expect(lastModelsInfoCall().sortOrder).toBe("desc"); - // Component shows API total_count (2), not filtered count - // Since default is "personal" team and models don't have direct_access, they're filtered out - await waitFor(() => { - expect(screen.getByText("Showing 1 - 2 of 2 results")).toBeInTheDocument(); + await user.click(sortHeader("model_info_updated_at")); + await expectIndicator("model_info_updated_at", "none"); + expect(lastModelsInfoCall().sortBy).toBeUndefined(); }); }); - it("should filter models by access group matching when team models match model access groups", async () => { - const mockTeams = [ - { - team_id: "team-sales", - team_alias: "Sales Team", - models: ["sales-model-group"], - max_budget: null, - budget_duration: null, - tpm_limit: null, - rpm_limit: null, - organization_id: "org-123", - created_at: "2024-01-01", - keys: [], - members_with_roles: [], - }, - ]; + it("queries the selected team and resets to the first page", async () => { + const user = userEvent.setup(); + render(); - mockUseTeams.mockReturnValue({ - data: mockTeams, - isLoading: false, - error: null, - refetch: vi.fn(), - }); + expect(lastModelsInfoCall().teamId).toBeUndefined(); - mockUseModelCostMap.mockReturnValueOnce( - createModelCostMapMock({ - "gpt-4-sales": { litellm_provider: "openai" }, - "gpt-4-engineering": { litellm_provider: "openai" }, - }), - ); + await user.click(screen.getByTestId("models-team-select")); + await user.click(await screen.findByRole("option", { name: "Engineering" })); - const modelData = createPaginatedModelData( - [ - { - model_name: "gpt-4-sales", - model_info: { - id: "model-sales-1", - access_via_team_ids: [], - access_groups: ["sales-model-group"], - }, - }, - { - model_name: "gpt-4-engineering", - model_info: { - id: "model-eng-1", - access_via_team_ids: [], - access_groups: ["engineering-model-group"], - }, - }, - ], - 2, - 1, - 1, - 50, - ); - - mockUseModelsInfo.mockReturnValue({ data: modelData, isLoading: false, error: null }); - - renderWithProviders(); - - // Component shows API total_count (2), not filtered count - // Since default is "personal" team and models don't have direct_access, they're filtered out await waitFor(() => { - expect(screen.getByText("Showing 1 - 2 of 2 results")).toBeInTheDocument(); + expect(lastModelsInfoCall().teamId).toBe("team-1"); + }); + expect(lastModelsInfoCall().page).toBe(1); + }); + + it("debounces the model name search into the server query", async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByTestId("datatable-search"), "claude"); + + await waitFor(() => { + expect(lastModelsInfoCall().search).toBe("claude"); }); }); - it("should filter models by direct_access for personal team", async () => { - mockUseTeams.mockReturnValue({ - data: [], - isLoading: false, - error: null, - refetch: vi.fn(), - }); + it("applies a public model name filter through the drawer", async () => { + const user = userEvent.setup(); + render(); - mockUseModelCostMap.mockReturnValueOnce( - createModelCostMapMock({ - "gpt-4-personal": { litellm_provider: "openai" }, - "gpt-4-team-only": { litellm_provider: "openai" }, - }), - ); + await user.click(screen.getByTestId("datatable-filters-trigger")); + await user.click(await screen.findByPlaceholderText("Filter by Public Model Name")); + await user.click(await screen.findByRole("option", { name: "gpt-3.5-turbo" })); + await user.click(screen.getByTestId("filter-drawer-apply")); - const modelData = createPaginatedModelData( - [ - { - model_name: "gpt-4-personal", - model_info: { - id: "model-personal-1", - direct_access: true, - access_via_team_ids: [], - access_groups: [], - }, - }, - { - model_name: "gpt-4-team-only", - model_info: { - id: "model-team-1", - direct_access: false, - access_via_team_ids: ["team-123"], - access_groups: [], - }, - }, - ], - 2, - 1, - 1, - 50, - ); - - mockUseModelsInfo.mockReturnValue({ data: modelData, isLoading: false, error: null }); - - renderWithProviders(); - - // Component shows API total_count (2), but only 1 model has direct_access await waitFor(() => { - expect(screen.getByText("Showing 1 - 2 of 2 results")).toBeInTheDocument(); + expect(mockSetSelectedModelGroup).toHaveBeenCalledWith("gpt-3.5-turbo"); }); }); - it("should show config model status for models defined in configs", async () => { - mockUseTeams.mockReturnValue({ - data: [], - isLoading: false, - error: null, - refetch: vi.fn(), - }); + it("filters the fetched page down to the selected model group", () => { + setModelsInfo([makeRow(), { ...makeRow(), model_name: "claude-opus" }], 2); + render(); - mockUseModelCostMap.mockReturnValueOnce( - createModelCostMapMock({ - "gpt-4-config": { litellm_provider: "openai" }, - "gpt-4-db": { litellm_provider: "openai" }, - }), - ); + const table = screen.getByRole("table"); + expect(within(table).getByText("claude-opus")).toBeInTheDocument(); + expect(within(table).queryByText("gpt-4")).not.toBeInTheDocument(); + }); - const modelData = createPaginatedModelData( - [ - { - model_name: "gpt-4-config", - litellm_model_name: "gpt-4-config", - provider: "openai", - model_info: { - id: "model-config-1", - db_model: false, - direct_access: true, - access_via_team_ids: [], - access_groups: [], - created_by: "user-123", - created_at: "2024-01-01", - updated_at: "2024-01-01", - }, - }, - { - model_name: "gpt-4-db", - litellm_model_name: "gpt-4-db", - provider: "openai", - model_info: { - id: "model-db-1", - 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", - }, - }, - ], - 2, - 1, - 1, - 50, - ); + it("resets search, filters, team and sorting from the drawer reset button", async () => { + const user = userEvent.setup(); + render(); - mockUseModelsInfo.mockReturnValue({ data: modelData, isLoading: false, error: null }); + await user.click(screen.getByTestId("models-team-select")); + await user.click(await screen.findByRole("option", { name: "Engineering" })); + await waitFor(() => expect(lastModelsInfoCall().teamId).toBe("team-1")); - renderWithProviders(); + await user.click(screen.getByTestId("datatable-filters-trigger")); + await user.click(await screen.findByTestId("filter-drawer-reset")); + expect(mockSetSelectedModelGroup).toHaveBeenCalledWith("all"); await waitFor(() => { - expect(screen.getByText("Config Model")).toBeInTheDocument(); - expect(screen.getByText("DB Model")).toBeInTheDocument(); + expect(lastModelsInfoCall().teamId).toBeUndefined(); }); }); - it("should show 'Defined in config' for models defined in configs", async () => { - mockUseTeams.mockReturnValue({ - data: [], - isLoading: false, - error: null, - refetch: vi.fn(), - }); + it("opens the delete modal from the row and deletes the model", async () => { + const user = userEvent.setup(); + render(); - mockUseModelCostMap.mockReturnValueOnce( - createModelCostMapMock({ - "gpt-4-config": { litellm_provider: "openai" }, - }), - ); + await user.click(await screen.findByTestId("model-delete-model-1")); + expect(await screen.findByText("Delete Model")).toBeInTheDocument(); - const modelData = createPaginatedModelData( - [ - { - model_name: "gpt-4-config", - litellm_model_name: "gpt-4-config", - provider: "openai", - model_info: { - id: "model-config-1", - db_model: false, - 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 }); - - renderWithProviders(); + await user.click(screen.getByRole("button", { name: /^delete$/i })); await waitFor(() => { - expect(screen.getByText("Defined in config")).toBeInTheDocument(); + expect(mockModelDeleteCall).toHaveBeenCalledWith("mock-access-token", "model-1"); }); }); - it("should handle pagination: Previous button is disabled on first page and Next button works", async () => { - mockUseTeams.mockReturnValue({ - data: [], - isLoading: false, - error: null, - refetch: vi.fn(), - }); + it("pauses a model through the row toggle", async () => { + const user = userEvent.setup(); + render(); - mockUseModelCostMap.mockReturnValue( - createModelCostMapMock({ - "gpt-4-page1": { litellm_provider: "openai" }, - "gpt-4-page2": { litellm_provider: "openai" }, - }), - ); - - // Mock first page response (page 1 of 2) - const page1Data = createPaginatedModelData( - [ - { - model_name: "gpt-4-page1", - model_info: { - id: "model-page1-1", - direct_access: true, - access_via_team_ids: [], - access_groups: [], - }, - }, - ], - 2, // total_count - 1, // current_page - 2, // total_pages - 50, // size - ); - - // Set up mock to return page1Data for page 1 - mockUseModelsInfo.mockImplementation((page: number = 1, size?: number, search?: string) => { - return { data: page1Data, isLoading: false, error: null }; - }); - - renderWithProviders(); + await user.click(await screen.findByTestId("model-pause-toggle-model-1")); await waitFor(() => { - // Component calculates: ((1-1)*50)+1 = 1, Math.min(1*50, 2) = 2 - expect(screen.getByText("Showing 1 - 2 of 2 results")).toBeInTheDocument(); + expect(mockModelPatchUpdateCall).toHaveBeenCalledWith("mock-access-token", { blocked: true }, "model-1"); }); - - // Check that Previous button is disabled on first page - const previousButton = screen.getByRole("button", { name: /previous/i }); - expect(previousButton).toBeDisabled(); - - // Check that Next button is enabled (since we're on page 1 of 2) - const nextButton = screen.getByRole("button", { name: /next/i }); - expect(nextButton).not.toBeDisabled(); }); - it("should handle pagination: Next button is disabled on last page", async () => { - mockUseTeams.mockReturnValue({ - data: [], - isLoading: false, - error: null, - refetch: vi.fn(), - }); + it("opens the model settings modal from the toolbar", async () => { + const user = userEvent.setup(); + render(); - mockUseModelCostMap.mockReturnValue( - createModelCostMapMock({ - "gpt-4-page2": { litellm_provider: "openai" }, - }), - ); - - // Mock single page response (page 1 of 1 - last page) - const singlePageData = createPaginatedModelData( - [ - { - model_name: "gpt-4-page2", - model_info: { - id: "model-page2-1", - direct_access: true, - access_via_team_ids: [], - access_groups: [], - }, - }, - ], - 1, // total_count - 1, // current_page - 1, // total_pages (only 1 page, so this is the last page) - 50, // size - ); - - mockUseModelsInfo.mockImplementation((page?: number, size?: number, search?: string) => { - return { data: singlePageData, isLoading: false, error: null }; - }); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByText("Showing 1 - 1 of 1 results")).toBeInTheDocument(); - }); - - // When there's only 1 page (last page), Next should be disabled - const nextButton = screen.getByRole("button", { name: /next/i }); - expect(nextButton).toBeDisabled(); - - // Previous should also be disabled on the first (and only) page - const previousButton = screen.getByRole("button", { name: /previous/i }); - expect(previousButton).toBeDisabled(); + expect(screen.queryByTestId("model-settings-modal")).not.toBeInTheDocument(); + await user.click(screen.getByTestId("models-settings-trigger")); + expect(screen.getByTestId("model-settings-modal")).toBeInTheDocument(); }); - 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(), - }); + it("opens the model detail view from the model ID cell", async () => { + const user = userEvent.setup(); + render(); - mockUseModelCostMap.mockReturnValue( - createModelCostMapMock({ - "gpt-4-delete-test": { litellm_provider: "openai" }, - }), - ); + await user.click(await screen.findByTestId("model-id-model-1")); - 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() }); - - renderWithProviders(); - - 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(); + expect(mockSetSelectedModelId).toHaveBeenCalledWith("model-1"); }); - it("should render clickable model ID that calls setSelectedModelId", async () => { - mockUseTeams.mockReturnValue({ - data: [], - isLoading: false, - error: null, - refetch: vi.fn(), + it("opens the team detail view from the team ID cell", async () => { + const user = userEvent.setup(); + render(); + + await user.click(await screen.findByTestId("model-team-id-model-1")); + + expect(mockSetSelectedTeamId).toHaveBeenCalledWith("team-1"); + }); + + describe("virtual key hint", () => { + it("explains personal key creation while viewing current team models", () => { + render(); + + expect(screen.getByText(/create a Virtual Key without selecting a team/i)).toBeInTheDocument(); }); - mockUseModelCostMap.mockReturnValue( - createModelCostMapMock({ - "gpt-4-clickable": { litellm_provider: "openai" }, - }), - ); + it("names the selected team in the hint", async () => { + const user = userEvent.setup(); + render(); - 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, - ); + await user.click(screen.getByTestId("models-team-select")); + await user.click(await screen.findByRole("option", { name: "Engineering" })); - mockUseModelsInfo.mockReturnValue({ data: modelData, isLoading: false, error: null, refetch: vi.fn() }); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByText("gpt-4-clickable")).toBeInTheDocument(); + expect(await screen.findByText(/select Team as "Engineering"/i)).toBeInTheDocument(); }); - // Click on the Model ID cell which should call setSelectedModelId - const modelIdCell = screen.getByText("clickable-model-id"); - expect(modelIdCell).toBeInTheDocument(); + it("hides the hint when viewing all available models", async () => { + const user = userEvent.setup(); + render(); - fireEvent.click(modelIdCell); + await user.click(screen.getByTestId("models-view-select")); + await user.click(await screen.findByRole("option", { name: "All Available Models" })); - await waitFor(() => { - expect(mockSetSelectedModelId).toHaveBeenCalledWith("clickable-model-id"); + await waitFor(() => { + expect(screen.queryByText(/create a Virtual Key/i)).not.toBeInTheDocument(); + }); }); }); }); 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 6efc85c019b..1dc7736d5ac 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 @@ -1,27 +1,33 @@ +"use client"; + import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap"; import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; -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 ModelSettingsModal from "@/components/model_dashboard/ModelSettingsModal/ModelSettingsModal"; +import { ModelData } from "@/components/model_dashboard/types"; import NotificationsManager from "@/components/molecules/notifications_manager"; import { modelDeleteCall, modelPatchUpdateCall } 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 } from "@tremor/react"; -import { Badge, Button, Select, Skeleton, Space, Typography } from "antd"; -import ModelSettingsModal from "@/components/model_dashboard/ModelSettingsModal/ModelSettingsModal"; import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer"; -import { useEffect, useMemo, useState } from "react"; +import { ColumnFiltersState, OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table"; +import { Info } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; + import { useModelsInfo } from "../../hooks/models/useModels"; import { transformModelData } from "../utils/modelDataTransformer"; -type ModelViewMode = "all" | "current_team"; +import { + ALL_MODEL_GROUPS_VALUE, + AllModelsTable, + ModelViewMode, + PERSONAL_TEAM_VALUE, + WILDCARD_MODEL_GROUP_VALUE, +} from "./AllModelsTable"; +import { ACCESS_GROUPS_COLUMN_ID, MODEL_NAME_COLUMN_ID, toServerSortField } from "./ModelsTableColumns"; const SEARCH_DEBOUNCE_WAIT_MS = 200; -const { Text } = Typography; +const DEFAULT_PAGE_SIZE = 50; +const DEFAULT_PAGINATION: PaginationState = { pageIndex: 0, pageSize: DEFAULT_PAGE_SIZE }; interface AllModelsTabProps { selectedModelGroup: string | null; @@ -41,31 +47,30 @@ const AllModelsTab = ({ setSelectedTeamId, }: AllModelsTabProps) => { const { data: modelCostMapData, isLoading: isLoadingModelCostMap } = useModelCostMap(); - const { accessToken, userId, userRole, premiumUser } = useAuthorized(); + const { accessToken, userId, userRole } = useAuthorized(); const { data: teams, isLoading: isLoadingTeams } = useTeams(); const queryClient = useQueryClient(); const [modelNameSearch, setModelNameSearch] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState(""); const [modelViewMode, setModelViewMode] = useState("current_team"); - const [currentTeam, setCurrentTeam] = useState("personal"); - const [showFilters, setShowFilters] = useState(false); + const [selectedTeamValue, setSelectedTeamValue] = useState(PERSONAL_TEAM_VALUE); const [selectedModelAccessGroupFilter, setSelectedModelAccessGroupFilter] = useState(null); - const [expandedRows, setExpandedRows] = useState>(new Set()); - const [currentPage, setCurrentPage] = useState(1); - const [pageSize] = useState(50); - const [pagination, setPagination] = useState({ - pageIndex: 0, - pageSize: 50, - }); + const [pagination, setPagination] = useState(DEFAULT_PAGINATION); const [sorting, setSorting] = useState([]); const [isModelSettingsModalVisible, setIsModelSettingsModalVisible] = useState(false); + const [deleteModalModelId, setDeleteModalModelId] = useState(null); + const [deleteLoading, setDeleteLoading] = useState(false); + const [pausingModelId, setPausingModelId] = useState(null); + + const resetToFirstPage = useCallback(() => { + setPagination((previous) => (previous.pageIndex === 0 ? previous : { ...previous, pageIndex: 0 })); + }, []); const debouncedUpdateSearch = useDebouncedCallback( (value: string) => { setDebouncedSearch(value); - setCurrentPage(1); - setPagination((prev: PaginationState) => ({ ...prev, pageIndex: 0 })); + resetToFirstPage(); }, { wait: SEARCH_DEBOUNCE_WAIT_MS }, ); @@ -74,125 +79,130 @@ const AllModelsTab = ({ debouncedUpdateSearch(modelNameSearch); }, [modelNameSearch, debouncedUpdateSearch]); - // Determine teamId to pass to the query - only pass if not "personal" - const teamIdForQuery = currentTeam === "personal" ? undefined : currentTeam.team_id; + const teamIdForQuery = selectedTeamValue === PERSONAL_TEAM_VALUE ? undefined : selectedTeamValue; - // Convert sorting state to sortBy and sortOrder for API const sortBy = useMemo(() => { if (sorting.length === 0) return undefined; - const sort = sorting[0]; - const columnIdToServerField: Record = { - input_cost: "costs", // Map input_cost column to "costs" for server-side sorting - model_info_db_model: "status", // Map model_info.db_model column to "status" for server-side sorting - model_info_created_by: "created_at", // Map model_info.created_by column to "created_at" for server-side sorting - model_info_updated_at: "updated_at", // Map model_info.updated_at column to "updated_at" for server-side sorting - }; - return columnIdToServerField[sort.id] || sort.id; + return toServerSortField(sorting[0].id); }, [sorting]); const sortOrder = useMemo(() => { if (sorting.length === 0) return undefined; - const sort = sorting[0]; - return sort.desc ? "desc" : "asc"; + return sorting[0].desc ? "desc" : "asc"; }, [sorting]); const { data: rawModelData, isLoading: isLoadingModelsInfo, + isFetching: isFetchingModelsInfo, refetch: refetchModels, - } = useModelsInfo(currentPage, pageSize, debouncedSearch || undefined, undefined, teamIdForQuery, sortBy, sortOrder); + } = useModelsInfo( + pagination.pageIndex + 1, + pagination.pageSize, + debouncedSearch || undefined, + undefined, + teamIdForQuery, + sortBy, + sortOrder, + ); const isLoading = isLoadingModelsInfo || isLoadingModelCostMap; - const getProviderFromModel = (model: string) => { - if (modelCostMapData !== null && modelCostMapData !== undefined) { - if (typeof modelCostMapData == "object" && model in modelCostMapData) { - return modelCostMapData[model]["litellm_provider"]; + const getProviderFromModel = useCallback( + (model: string) => { + if (modelCostMapData !== null && modelCostMapData !== undefined) { + if (typeof modelCostMapData == "object" && model in modelCostMapData) { + return modelCostMapData[model]["litellm_provider"]; + } } - } - return "openai"; - }; + return "openai"; + }, + [modelCostMapData], + ); const modelData = useMemo(() => { if (!rawModelData) return { data: [] }; return transformModelData(rawModelData, getProviderFromModel); - }, [rawModelData, modelCostMapData]); + }, [rawModelData, getProviderFromModel]); - const [deleteModalModelId, setDeleteModalModelId] = useState(null); - const [deleteLoading, setDeleteLoading] = useState(false); - - // Get pagination metadata from the response - const paginationMeta = useMemo(() => { - if (!rawModelData) { - return { - total_count: 0, - current_page: 1, - total_pages: 1, - size: pageSize, - }; - } - return { - total_count: rawModelData.total_count ?? 0, - current_page: rawModelData.current_page ?? 1, - total_pages: rawModelData.total_pages ?? 1, - size: rawModelData.size ?? pageSize, - }; - }, [rawModelData, pageSize]); - - const filteredData = useMemo(() => { + const filteredData = useMemo(() => { if (!modelData || !modelData.data || modelData.data.length === 0) { return []; } - // Server-side search is now handled by the API, so we only filter by other criteria - return modelData.data.filter((model: any) => { + return modelData.data.filter((model: ModelData) => { const modelNameMatch = - selectedModelGroup === "all" || + selectedModelGroup === ALL_MODEL_GROUPS_VALUE || model.model_name === selectedModelGroup || !selectedModelGroup || - (selectedModelGroup === "wildcard" && model.model_name?.includes("*")); + (selectedModelGroup === WILDCARD_MODEL_GROUP_VALUE && model.model_name?.includes("*")); const accessGroupMatch = - selectedModelAccessGroupFilter === "all" || - model.model_info["access_groups"]?.includes(selectedModelAccessGroupFilter) || + selectedModelAccessGroupFilter === ALL_MODEL_GROUPS_VALUE || + model.model_info["access_groups"]?.includes(selectedModelAccessGroupFilter ?? "") || !selectedModelAccessGroupFilter; - // Team filtering is now handled server-side via teamId query parameter - // Only apply client-side filtering for model groups and access groups return modelNameMatch && accessGroupMatch; }); }, [modelData, selectedModelGroup, selectedModelAccessGroupFilter]); - useEffect(() => { - setPagination((prev: PaginationState) => ({ ...prev, pageIndex: 0 })); - setCurrentPage(1); - }, [selectedModelGroup, selectedModelAccessGroupFilter]); + const columnFilters = useMemo( + () => + [ + selectedModelGroup && selectedModelGroup !== ALL_MODEL_GROUPS_VALUE + ? { id: MODEL_NAME_COLUMN_ID, value: selectedModelGroup } + : null, + selectedModelAccessGroupFilter ? { id: ACCESS_GROUPS_COLUMN_ID, value: selectedModelAccessGroupFilter } : null, + ].filter((entry) => entry !== null), + [selectedModelGroup, selectedModelAccessGroupFilter], + ); - // Reset pagination when team changes - useEffect(() => { - setCurrentPage(1); - setPagination((prev: PaginationState) => ({ ...prev, pageIndex: 0 })); - }, [teamIdForQuery]); + const handleColumnFiltersChange: OnChangeFn = (updater) => { + const next = typeof updater === "function" ? updater(columnFilters) : updater; + const modelGroup = next.find((entry) => entry.id === MODEL_NAME_COLUMN_ID)?.value; + const accessGroup = next.find((entry) => entry.id === ACCESS_GROUPS_COLUMN_ID)?.value; + setSelectedModelGroup(typeof modelGroup === "string" ? modelGroup : ALL_MODEL_GROUPS_VALUE); + setSelectedModelAccessGroupFilter(typeof accessGroup === "string" ? accessGroup : null); + resetToFirstPage(); + }; - // Reset pagination when sorting changes - useEffect(() => { - setCurrentPage(1); - setPagination((prev: PaginationState) => ({ ...prev, pageIndex: 0 })); - }, [sorting]); + const handleSortingChange: OnChangeFn = (updater) => { + setSorting(typeof updater === "function" ? updater(sorting) : updater); + resetToFirstPage(); + }; + + const handleTeamChange = (value: string) => { + setSelectedTeamValue(value); + resetToFirstPage(); + }; const resetFilters = () => { setModelNameSearch(""); - setSelectedModelGroup("all"); + setSelectedModelGroup(ALL_MODEL_GROUPS_VALUE); setSelectedModelAccessGroupFilter(null); - setCurrentTeam("personal"); + setSelectedTeamValue(PERSONAL_TEAM_VALUE); setModelViewMode("current_team"); - setCurrentPage(1); - setPagination({ pageIndex: 0, pageSize: 50 }); + setPagination(DEFAULT_PAGINATION); setSorting([]); }; + const teamOptions = useMemo( + () => [ + { value: PERSONAL_TEAM_VALUE, label: "Personal" }, + ...(teams ?? []) + .filter((team) => team.team_id) + .map((team) => ({ value: team.team_id, label: team.team_alias ? team.team_alias : team.team_id })), + ], + [teams], + ); + + const selectedTeam = useMemo( + () => (teams ?? []).find((team) => team.team_id === selectedTeamValue) ?? null, + [teams, selectedTeamValue], + ); + const modelToDelete = useMemo(() => { if (!deleteModalModelId || !modelData?.data) return null; - return modelData.data.find((model: any) => model.model_info.id === deleteModalModelId); + return modelData.data.find((model: ModelData) => model.model_info.id === deleteModalModelId); }, [deleteModalModelId, modelData]); const handleDeleteModel = async () => { @@ -212,356 +222,99 @@ const AllModelsTab = ({ } }; - const [pausingModelId, setPausingModelId] = useState(null); + const handleTogglePause = useCallback( + async (modelId: string, blocked: boolean) => { + if (!accessToken) return; + try { + setPausingModelId(modelId); + await modelPatchUpdateCall(accessToken, { blocked }, modelId); + NotificationsManager.success(blocked ? "Model paused" : "Model resumed"); + // invalidateQueries already schedules a refetch for active observers + // on this key — no need to also call refetchModels() (would double-fetch). + queryClient.invalidateQueries({ queryKey: ["models", "list"] }); + } catch (error) { + console.error("Error toggling model pause state:", error); + NotificationsManager.fromBackend(error); + } finally { + setPausingModelId(null); + } + }, + [accessToken, queryClient], + ); - const handleTogglePause = async (modelId: string, blocked: boolean) => { - if (!accessToken) return; - try { - setPausingModelId(modelId); - await modelPatchUpdateCall(accessToken, { blocked }, modelId); - NotificationsManager.success(blocked ? "Model paused" : "Model resumed"); - // invalidateQueries already schedules a refetch for active observers - // on this key — no need to also call refetchModels() (would double-fetch). - queryClient.invalidateQueries({ queryKey: ["models", "list"] }); - } catch (error) { - console.error("Error toggling model pause state:", error); - NotificationsManager.fromBackend(error); - } finally { - setPausingModelId(null); - } - }; + const handleRefresh = useCallback(() => { + void refetchModels(); + }, [refetchModels]); + + const handleDeleteClick = useCallback((modelId: string) => { + setDeleteModalModelId(modelId); + }, []); + + const handleOpenModelSettings = useCallback(() => { + setIsModelSettingsModalVisible(true); + }, []); + + const teamAccessLabel = selectedTeam?.team_alias || selectedTeam?.team_id || ""; return (
- -
-
- {/* Current Team and View Mode Selector - Prominent Section */} -
-
-
- Current Team: -
- {isLoading ? ( - - ) : ( - setModelViewMode(value as "current_team" | "all")} - options={[ - { - value: "current_team", - label: ( - - - Current Team Models - - ), - }, - { - value: "all", - label: ( - - - All Available Models - - ), - }, - ]} - /> - )} -
-
-
+
+ - {modelViewMode === "current_team" && ( -
- -
- {currentTeam === "personal" ? ( - - To access these models: Create a Virtual Key without selecting a team on the{" "} - - Virtual Keys page - - - ) : ( - - To access these models: Create a Virtual Key and select Team as " - {typeof currentTeam !== "string" ? currentTeam.team_alias || currentTeam.team_id : ""}" on - the{" "} - - Virtual Keys page - - - )} -
-
- )} -
- - {/* Search and Filter Controls */} -
-
- {/* Search and Filter Controls */} -
-
- {/* Model Name Search */} -
- setModelNameSearch(e.target.value)} - /> - - - -
- - {/* Filter Button */} - - - {/* Reset Filters Button */} - -
- - {/* Model Settings Button */} -
- - {/* Additional Filters */} - {showFilters && ( -
- {/* Model Name Filter */} -
- setSelectedModelAccessGroupFilter(value === "all" ? null : value)} - placeholder="Filter by Model Access Group" - showSearch - options={[ - { value: "all", label: "All Model Access Groups" }, - ...availableModelAccessGroups.map((accessGroup, idx) => ({ - value: accessGroup, - label: accessGroup, - })), - ]} - /> -
-
- )} - - {/* Results Count and Pagination Controls */} -
- {isLoading ? ( - - ) : ( - - {paginationMeta.total_count > 0 - ? `Showing ${(currentPage - 1) * pageSize + 1} - ${Math.min(currentPage * pageSize, paginationMeta.total_count)} of ${paginationMeta.total_count} results` - : "Showing 0 results"} - - )} - -
- {isLoading ? ( - - ) : ( - - )} - - {isLoading ? ( - - ) : ( - - )} -
-
-
-
- - {}, - () => {}, - expandedRows, - setExpandedRows, - setDeleteModalModelId, - handleTogglePause, - pausingModelId, - )} - data={filteredData} - isLoading={isLoadingModelsInfo} - sorting={sorting} - onSortingChange={setSorting} - pagination={pagination} - onPaginationChange={setPagination} - enablePagination={true} - onRowClick={(model: any) => setSelectedModelId(model.model_info.id)} - /> + {modelViewMode === "current_team" && ( +
+ + {selectedTeamValue === PERSONAL_TEAM_VALUE ? ( + + To access these models, create a Virtual Key without selecting a team on the{" "} + + Virtual Keys page + + . + + ) : ( + + To access these models, create a Virtual Key and select Team as "{teamAccessLabel}" on the{" "} + + Virtual Keys page + + . + + )}
-
- + )} +
({ + default: { success: vi.fn(), fromBackend: vi.fn() }, +})); + +const makeModel = (overrides: Partial = {}): ModelData => + ({ + model_name: "gpt-4-public", + litellm_model_name: "openai/gpt-4", + provider: "openai", + input_cost: 30 as unknown as number, + output_cost: 60 as unknown as number, + max_tokens: 8192, + max_input_tokens: 8192, + litellm_params: { model: "openai/gpt-4" }, + cleanedLitellmParams: {}, + ...overrides, + model_info: { + id: "model-1", + created_at: "2024-01-02T00:00:00Z", + updated_at: "2024-03-04T00:00:00Z", + created_by: "alice", + team_id: "team-1", + db_model: true, + access_groups: null, + ...(overrides.model_info ?? {}), + }, + }) as ModelData; + +const baseProps = { + data: [makeModel()], + rowCount: 1, + isLoading: false, + isRefreshing: false, + onRefresh: vi.fn(), + sorting: [], + onSortingChange: vi.fn(), + pagination: { pageIndex: 0, pageSize: 50 }, + onPaginationChange: vi.fn(), + columnFilters: [], + onColumnFiltersChange: vi.fn(), + onResetFilters: vi.fn(), + searchValue: "", + onSearchChange: vi.fn(), + teamOptions: [ + { value: "personal", label: "Personal" }, + { value: "team-1", label: "Engineering" }, + ], + selectedTeamValue: "personal", + onTeamChange: vi.fn(), + isLoadingTeams: false, + viewMode: "current_team" as const, + onViewModeChange: vi.fn(), + onOpenModelSettings: vi.fn(), + availableModelGroups: ["gpt-4", "gpt-3.5-turbo"], + availableModelAccessGroups: ["sales-team"], + userRole: "Admin", + userID: "alice", + onModelIdClick: vi.fn(), + onTeamIdClick: vi.fn(), + onDeleteClick: vi.fn(), + onTogglePauseClick: vi.fn(), + pausingModelId: null, +}; + +const row = (modelId: string): HTMLElement => { + const element = document.querySelector(`[data-row-id="${modelId}"]`); + if (!(element instanceof HTMLElement)) { + throw new Error(`row ${modelId} not rendered`); + } + return element; +}; + +describe("AllModelsTable", () => { + it("renders the nine design columns and hides Status behind the Columns menu", async () => { + const user = userEvent.setup(); + render(); + + for (const header of [ + "Model ID", + "Model Information", + "Credentials", + "Created By", + "Updated At", + "Costs", + "Team ID", + "Model Access Group", + "Actions", + ]) { + expect(screen.getByRole("columnheader", { name: new RegExp(header, "i") })).toBeInTheDocument(); + } + + expect(screen.queryByRole("columnheader", { name: /^status$/i })).not.toBeInTheDocument(); + expect(screen.queryByText("DB Model")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /columns/i })); + await user.click(await screen.findByRole("menuitemcheckbox", { name: /status/i })); + + expect(await screen.findByText("DB Model")).toBeInTheDocument(); + }); + + it("opens the model detail from the model ID cell", async () => { + const user = userEvent.setup(); + const onModelIdClick = vi.fn(); + render(); + + await user.click(screen.getByTestId("model-id-model-1")); + + expect(onModelIdClick).toHaveBeenCalledWith("model-1"); + }); + + it("opens the team detail from the team ID cell", async () => { + const user = userEvent.setup(); + const onTeamIdClick = vi.fn(); + render(); + + await user.click(screen.getByTestId("model-team-id-model-1")); + + expect(onTeamIdClick).toHaveBeenCalledWith("team-1"); + }); + + it("shows a dash when the model has no team", () => { + render( + , + ); + + expect(within(row("model-1")).getAllByText("-").length).toBeGreaterThan(0); + expect(screen.queryByTestId("model-team-id-model-1")).not.toBeInTheDocument(); + }); + + it("renders the model name over the litellm model name", () => { + render(); + + const cell = screen.getByTestId("model-information-model-1"); + expect(within(cell).getByText("gpt-4-public")).toBeInTheDocument(); + expect(within(cell).getByText("openai/gpt-4")).toBeInTheDocument(); + }); + + it("renders a reusable credential by name and falls back to Manual", () => { + const { rerender } = render( + , + ); + expect(screen.getByText("openai-prod")).toBeInTheDocument(); + expect(screen.queryByText("Manual")).not.toBeInTheDocument(); + + rerender(); + expect(screen.getByText("Manual")).toBeInTheDocument(); + }); + + it("shows 'Defined in config' for a config model and the creator for a DB model", () => { + const { rerender } = render(); + expect(screen.getByText("alice")).toBeInTheDocument(); + + rerender( + , + ); + expect(screen.getByText("Defined in config")).toBeInTheDocument(); + }); + + it("renders input and output costs and a dash when both are missing", () => { + const { rerender } = render(); + expect(screen.getByText("$30")).toBeInTheDocument(); + expect(screen.getByText("$60")).toBeInTheDocument(); + + rerender( + , + ); + expect(screen.queryByText(/^\$/)).not.toBeInTheDocument(); + }); + + it("collapses extra access groups behind a +N more badge", () => { + render( + , + ); + + expect(screen.getByText("sales-team")).toBeInTheDocument(); + expect(screen.getByText("+2 more")).toBeInTheDocument(); + }); + + describe("pause / resume", () => { + it("renders the toggle on for an active DB model and off for a blocked one", () => { + const { rerender } = render(); + expect(screen.getByTestId("model-pause-toggle-model-1")).toBeChecked(); + + rerender( + , + ); + expect(screen.getByTestId("model-pause-toggle-model-1")).not.toBeChecked(); + }); + + it("pauses an active model and resumes a blocked one", async () => { + const user = userEvent.setup(); + const onTogglePauseClick = vi.fn(); + const { rerender } = render(); + + await user.click(screen.getByTestId("model-pause-toggle-model-1")); + expect(onTogglePauseClick).toHaveBeenCalledWith("model-1", true); + + onTogglePauseClick.mockClear(); + rerender( + , + ); + + await user.click(screen.getByTestId("model-pause-toggle-model-1")); + expect(onTogglePauseClick).toHaveBeenCalledWith("model-1", false); + }); + + it("does not let a non-admin toggle a model", async () => { + const user = userEvent.setup(); + const onTogglePauseClick = vi.fn(); + render(); + + const toggle = screen.getByTestId("model-pause-toggle-model-1"); + expect(toggle).toHaveAttribute("data-disabled"); + await user.click(toggle); + expect(onTogglePauseClick).not.toHaveBeenCalled(); + }); + + it("does not let anyone toggle a config model", async () => { + const user = userEvent.setup(); + const onTogglePauseClick = vi.fn(); + render( + , + ); + + const toggle = screen.getByTestId("model-pause-toggle-model-1"); + expect(toggle).toHaveAttribute("data-disabled"); + await user.click(toggle); + expect(onTogglePauseClick).not.toHaveBeenCalled(); + }); + + it("replaces the toggle with a pending indicator while a PATCH is in flight", () => { + render(); + + expect(screen.getByTestId("model-pause-pending-model-1")).toBeInTheDocument(); + expect(screen.queryByTestId("model-pause-toggle-model-1")).not.toBeInTheDocument(); + }); + }); + + describe("delete", () => { + it("lets an admin delete a DB model", async () => { + const user = userEvent.setup(); + const onDeleteClick = vi.fn(); + render(); + + await user.click(screen.getByTestId("model-delete-model-1")); + expect(onDeleteClick).toHaveBeenCalledWith("model-1"); + }); + + it("lets the creator delete their own DB model", async () => { + const user = userEvent.setup(); + const onDeleteClick = vi.fn(); + render(); + + await user.click(screen.getByTestId("model-delete-model-1")); + expect(onDeleteClick).toHaveBeenCalledWith("model-1"); + }); + + it("blocks deleting a model the user did not create", async () => { + const user = userEvent.setup(); + const onDeleteClick = vi.fn(); + render(); + + const deleteButton = screen.getByTestId("model-delete-model-1"); + expect(deleteButton).toBeDisabled(); + await user.click(deleteButton); + expect(onDeleteClick).not.toHaveBeenCalled(); + }); + + it("blocks deleting a config model", async () => { + const user = userEvent.setup(); + const onDeleteClick = vi.fn(); + render( + , + ); + + const deleteButton = screen.getByTestId("model-delete-model-1"); + expect(deleteButton).toBeDisabled(); + await user.click(deleteButton); + expect(onDeleteClick).not.toHaveBeenCalled(); + }); + }); + + describe("toolbar", () => { + it("wires search, refresh, team, view and model settings", async () => { + const user = userEvent.setup(); + const onSearchChange = vi.fn(); + const onRefresh = vi.fn(); + const onOpenModelSettings = vi.fn(); + render( + , + ); + + await user.type(screen.getByTestId("datatable-search"), "gpt"); + expect(onSearchChange).toHaveBeenCalled(); + + await user.click(screen.getByTestId("datatable-refresh")); + expect(onRefresh).toHaveBeenCalled(); + + await user.click(screen.getByTestId("models-settings-trigger")); + expect(onOpenModelSettings).toHaveBeenCalled(); + + expect(screen.getByTestId("models-team-select")).toHaveTextContent("Personal"); + expect(screen.getByTestId("models-view-select")).toHaveTextContent("Current Team Models"); + }); + + it("switches the current team", async () => { + const user = userEvent.setup(); + const onTeamChange = vi.fn(); + render(); + + await user.click(screen.getByTestId("models-team-select")); + await user.click(await screen.findByRole("option", { name: "Engineering" })); + + expect(onTeamChange).toHaveBeenCalledWith("team-1"); + }); + + it("runs the full reset from the filter drawer", async () => { + const user = userEvent.setup(); + const onResetFilters = vi.fn(); + render(); + + await user.click(screen.getByTestId("datatable-filters-trigger")); + await user.click(await screen.findByTestId("filter-drawer-reset")); + + expect(onResetFilters).toHaveBeenCalled(); + }); + + it("renders active filters as removable chips", async () => { + const user = userEvent.setup(); + const onColumnFiltersChange = vi.fn(); + render( + , + ); + + const chip = screen.getByTestId("filter-chip-model_name"); + expect(chip).toHaveTextContent("Public Model Name"); + expect(chip).toHaveTextContent("Wildcard Models (*)"); + + await user.click(screen.getByTestId("filter-chip-remove-model_name")); + expect(onColumnFiltersChange).toHaveBeenCalled(); + }); + }); + + it("shows the server row count in the pagination footer", () => { + render(); + + expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-50 of 137"); + }); + + it("shows the empty state when there are no models", () => { + render(); + + expect(screen.getByText("No models found")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.tsx new file mode 100644 index 00000000000..d073519d162 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.tsx @@ -0,0 +1,298 @@ +"use client"; + +import { ColumnFiltersState, OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table"; +import { Search, Settings } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { ModelData } from "@/components/model_dashboard/types"; +import { + DataTable, + DataTableFilterDrawer, + DataTableFilterField, + DataTableToolbar, +} from "@/components/shared/DataTable"; +import { SearchSelect } from "@/components/shared/SearchSelect"; +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; +import { cn } from "@/lib/cva.config"; + +import { + ACCESS_GROUPS_COLUMN_ID, + getModelsTableColumns, + MODEL_NAME_COLUMN_ID, + STATUS_COLUMN_ID, +} from "./ModelsTableColumns"; + +export type ModelViewMode = "all" | "current_team"; + +export const PERSONAL_TEAM_VALUE = "personal"; +export const ALL_MODEL_GROUPS_VALUE = "all"; +export const WILDCARD_MODEL_GROUP_VALUE = "wildcard"; + +const MODEL_TABLE_BODY_HEIGHT = 600; + +const FILTER_LABELS: Record = { + [MODEL_NAME_COLUMN_ID]: "Public Model Name", + [ACCESS_GROUPS_COLUMN_ID]: "Model Access Group", +}; + +const VIEW_MODE_LABELS: Record = { + current_team: "Current Team Models", + all: "All Available Models", +}; + +export interface ModelsTableTeamOption { + value: string; + label: string; +} + +interface AllModelsTableProps { + data: ModelData[]; + rowCount: number; + isLoading: boolean; + isRefreshing: boolean; + onRefresh: () => void; + sorting: SortingState; + onSortingChange: OnChangeFn; + pagination: PaginationState; + onPaginationChange: OnChangeFn; + columnFilters: ColumnFiltersState; + onColumnFiltersChange: OnChangeFn; + onResetFilters: () => void; + searchValue: string; + onSearchChange: (value: string) => void; + teamOptions: ModelsTableTeamOption[]; + selectedTeamValue: string; + onTeamChange: (value: string) => void; + isLoadingTeams: boolean; + viewMode: ModelViewMode; + onViewModeChange: (viewMode: ModelViewMode) => void; + onOpenModelSettings: () => void; + availableModelGroups: string[]; + availableModelAccessGroups: string[]; + userRole: string; + userID: string; + onModelIdClick: (modelId: string) => void; + onTeamIdClick: (teamId: string) => void; + onDeleteClick: (modelId: string) => void; + onTogglePauseClick: (modelId: string, blocked: boolean) => void | Promise; + pausingModelId: string | null; +} + +function EmptyState() { + return ( +
+
+ +
+
No models found
+
+ No models match your search or filters. Try resetting them. +
+
+ ); +} + +export function AllModelsTable({ + data, + rowCount, + isLoading, + isRefreshing, + onRefresh, + sorting, + onSortingChange, + pagination, + onPaginationChange, + columnFilters, + onColumnFiltersChange, + onResetFilters, + searchValue, + onSearchChange, + teamOptions, + selectedTeamValue, + onTeamChange, + isLoadingTeams, + viewMode, + onViewModeChange, + onOpenModelSettings, + availableModelGroups, + availableModelAccessGroups, + userRole, + userID, + onModelIdClick, + onTeamIdClick, + onDeleteClick, + onTogglePauseClick, + pausingModelId, +}: AllModelsTableProps) { + const [filtersOpen, setFiltersOpen] = useState(false); + + const columns = useMemo(() => { + const columnDeps = { + userRole, + userID, + onModelIdClick, + onTeamIdClick, + onDeleteClick, + onTogglePauseClick, + pausingModelId, + }; + return getModelsTableColumns(columnDeps); + }, [userRole, userID, onModelIdClick, onTeamIdClick, onDeleteClick, onTogglePauseClick, pausingModelId]); + + const modelGroupOptions = useMemo( + () => [ + { label: "All Models", value: ALL_MODEL_GROUPS_VALUE }, + { label: "Wildcard Models (*)", value: WILDCARD_MODEL_GROUP_VALUE }, + ...availableModelGroups.map((group) => ({ label: group, value: group })), + ], + [availableModelGroups], + ); + + const accessGroupOptions = useMemo( + () => [ + { label: "All Model Access Groups", value: ALL_MODEL_GROUPS_VALUE }, + ...availableModelAccessGroups.map((accessGroup) => ({ label: accessGroup, value: accessGroup })), + ], + [availableModelAccessGroups], + ); + + const formatFilterValue = (columnId: string, value: unknown): string => { + const raw = String(value); + if (columnId === MODEL_NAME_COLUMN_ID && raw === WILDCARD_MODEL_GROUP_VALUE) { + return "Wildcard Models (*)"; + } + return raw; + }; + + const selectedTeamLabel = + teamOptions.find((option) => option.value === selectedTeamValue)?.label ?? teamOptions[0]?.label ?? ""; + + return ( + row.model_info?.id ?? String(index)} + sortingMode="server" + sorting={sorting} + onSortingChange={onSortingChange} + enableSortingRemoval + paginationMode="server" + pagination={pagination} + onPaginationChange={onPaginationChange} + rowCount={rowCount} + pageSizeOptions={[10, 25, 50]} + filterMode="server" + columnFilters={columnFilters} + onColumnFiltersChange={onColumnFiltersChange} + defaultColumnVisibility={{ [STATUS_COLUMN_ID]: false }} + enableColumnResizing + maxBodyHeight={MODEL_TABLE_BODY_HEIGHT} + isLoading={isLoading} + loadingMessage="Loading models…" + noDataMessage={} + size="compact" + toolbar={(table) => ( + <> + setFiltersOpen(true)} + onRefresh={onRefresh} + isRefreshing={isRefreshing} + filterLabels={FILTER_LABELS} + formatFilterValue={formatFilterValue} + > + + + + + + + + + + {({ get, set }) => ( + <> + + + set(MODEL_NAME_COLUMN_ID, value === ALL_MODEL_GROUPS_VALUE ? undefined : value) + } + placeholder="Filter by Public Model Name" + emptyText="No models found" + /> + + + + set(ACCESS_GROUPS_COLUMN_ID, value === ALL_MODEL_GROUPS_VALUE ? undefined : value) + } + placeholder="Filter by Model Access Group" + emptyText="No model access groups found" + /> + + + )} + + + )} + /> + ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelsTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelsTableColumns.tsx new file mode 100644 index 00000000000..93ee6d9f0ab --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelsTableColumns.tsx @@ -0,0 +1,488 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { Copy, Info, Loader2, Pencil, RefreshCw, Trash2 } from "lucide-react"; + +import { ProviderLogo } from "@/components/molecules/models/ProviderLogo"; +import { ModelData } from "@/components/model_dashboard/types"; +import { DataTableSortHeader } from "@/components/shared/DataTable"; +import { CellTooltip, DateCell, formatCellDate, IdCell, StatusBadge } from "@/components/shared/table_cells"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"; +import { Switch } from "@/components/ui/switch"; +import { getDisplayModelName } from "@/components/view_model/model_name_display"; +import { copyToClipboard } from "@/utils/dataUtils"; + +export const MODEL_ID_COLUMN_ID = "model_info_id"; +export const MODEL_NAME_COLUMN_ID = "model_name"; +export const CREDENTIALS_COLUMN_ID = "litellm_credential_name"; +export const CREATED_BY_COLUMN_ID = "model_info_created_by"; +export const UPDATED_AT_COLUMN_ID = "model_info_updated_at"; +export const COSTS_COLUMN_ID = "input_cost"; +export const TEAM_ID_COLUMN_ID = "model_info_team_id"; +export const ACCESS_GROUPS_COLUMN_ID = "model_info_access_groups"; +export const STATUS_COLUMN_ID = "model_info_db_model"; + +const COLUMN_ID_TO_SERVER_SORT_FIELD: Record = { + [COSTS_COLUMN_ID]: "costs", + [STATUS_COLUMN_ID]: "status", + [CREATED_BY_COLUMN_ID]: "created_at", + [UPDATED_AT_COLUMN_ID]: "updated_at", +}; + +export const toServerSortField = (columnId: string): string => COLUMN_ID_TO_SERVER_SORT_FIELD[columnId] ?? columnId; + +const formatShortDate = (value: string | null | undefined): string | null => { + if (!value) { + return null; + } + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : formatCellDate(date, "date"); +}; + +function ModelInformationCell({ model, displayName }: { model: ModelData; displayName: string }) { + const litellmModelName = model.litellm_model_name || "-"; + + return ( + + + } + > + {model.provider ? ( + + ) : ( + + - + + )} + + + {displayName} + + + {litellmModelName} + + + + +
+
+ {model.provider ? : null} + {model.provider || "Unknown provider"} +
+
+ Public Model Name + + {displayName} + +
+
+ LiteLLM Model Name + + + {litellmModelName} + + + +
+
+
+
+ ); +} + +function CredentialsHeader() { + return ( + + Credentials + + + } + > + + + +
+ Credential types +
+ + + Reusable + + + Credentials saved in LiteLLM that can be added to models repeatedly. + +
+
+ + + Manual + + + Credentials added directly during model creation or defined in the config file. + +
+
+
+
+
+ ); +} + +function CredentialsCell({ credentialName }: { credentialName: string | undefined }) { + if (!credentialName) { + return ( + + + Manual + + ); + } + + return ( + + + {credentialName} + + ); +} + +function CreatedByCell({ model }: { model: ModelData }) { + const isConfigModel = !model.model_info?.db_model; + const createdAt = formatShortDate(model.model_info.created_at); + const primary = isConfigModel ? "Defined in config" : model.model_info.created_by || "Unknown"; + const secondaryForDbModel = createdAt ?? "Unknown date"; + + return ( +
+ + {primary} + + {isConfigModel ? "-" : secondaryForDbModel} +
+ ); +} + +function CostsCell({ model }: { model: ModelData }) { + const { input_cost: inputCost, output_cost: outputCost } = model; + + if (inputCost == null && outputCost == null) { + return -; + } + + return ( + + {inputCost != null && ( + + IN + ${inputCost} + + )} + {outputCost != null && ( + + OUT + ${outputCost} + + )} +
+ } + /> + ); +} + +function AccessGroupsCell({ accessGroups }: { accessGroups: string[] | null }) { + if (!accessGroups || accessGroups.length === 0) { + return -; + } + + const [first, ...overflow] = accessGroups; + + return ( +
+ + {first} + + {overflow.length > 0 && ( + + {overflow.map((group) => ( + {group} + ))} +
+ } + trigger={ + + +{overflow.length} more + + } + /> + )} +
+ ); +} + +interface ModelRowActionsProps { + model: ModelData; + userRole: string; + userID: string; + isPausing: boolean; + onDeleteClick?: (modelId: string) => void; + onTogglePauseClick?: (modelId: string, blocked: boolean) => void | Promise; +} + +function ModelRowActions({ + model, + userRole, + userID, + isPausing, + onDeleteClick, + onTogglePauseClick, +}: ModelRowActionsProps) { + const modelId = model.model_info?.id; + const isConfigModel = !model.model_info?.db_model; + const isAdmin = userRole === "Admin"; + const canEditModel = isAdmin || model.model_info?.created_by === userID; + const isBlocked = model.model_info?.blocked === true; + const isPauseToggleable = !isConfigModel && isAdmin && Boolean(onTogglePauseClick); + + const resolvePauseTooltip = (): string => { + if (isConfigModel) { + return "Config models cannot be paused from the dashboard. Pause is DB-backed."; + } + if (!isAdmin) { + return "Only proxy admins can pause or resume a model."; + } + return isBlocked ? "Resume model — restore normal routing." : "Pause model — stop routing requests until resumed."; + }; + + const deleteTooltip = isConfigModel + ? "Config model cannot be deleted on the dashboard. Please delete it from the config file." + : "Delete model"; + + return ( +
+ + {isPausing ? ( + + ) : ( + + { + if (isPauseToggleable && onTogglePauseClick && modelId) { + void onTogglePauseClick(modelId, !nextChecked); + } + }} + /> + + } + /> + )} + + + + + } + /> +
+ ); +} + +export interface ModelsTableColumnDeps { + userRole: string; + userID: string; + onModelIdClick: (modelId: string) => void; + onTeamIdClick: (teamId: string) => void; + onDeleteClick?: (modelId: string) => void; + onTogglePauseClick?: (modelId: string, blocked: boolean) => void | Promise; + pausingModelId?: string | null; +} + +export const getModelsTableColumns = ({ + userRole, + userID, + onModelIdClick, + onTeamIdClick, + onDeleteClick, + onTogglePauseClick, + pausingModelId, +}: ModelsTableColumnDeps): ColumnDef[] => [ + { + id: MODEL_ID_COLUMN_ID, + accessorFn: (row) => row.model_info.id, + meta: { title: "Model ID" }, + header: "Model ID", + enableSorting: false, + size: 140, + minSize: 90, + cell: ({ row }) => ( + + ), + }, + { + id: MODEL_NAME_COLUMN_ID, + accessorFn: (row) => row.model_name ?? "", + meta: { title: "Model Information", skeleton: "twoLine" }, + header: ({ column }) => , + enableSorting: true, + size: 280, + minSize: 160, + cell: ({ row }) => ( + + ), + }, + { + id: CREDENTIALS_COLUMN_ID, + accessorFn: (row) => row.litellm_params?.litellm_credential_name ?? "", + meta: { title: "Credentials" }, + header: () => , + enableSorting: false, + size: 180, + minSize: 110, + cell: ({ row }) => , + }, + { + id: CREATED_BY_COLUMN_ID, + accessorFn: (row) => row.model_info.created_by ?? "", + meta: { title: "Created By", skeleton: "twoLine" }, + header: ({ column }) => , + enableSorting: true, + size: 180, + minSize: 110, + cell: ({ row }) => , + }, + { + id: UPDATED_AT_COLUMN_ID, + accessorFn: (row) => row.model_info.updated_at ?? "", + meta: { title: "Updated At" }, + header: ({ column }) => , + enableSorting: true, + size: 140, + minSize: 100, + cell: ({ row }) => , + }, + { + id: COSTS_COLUMN_ID, + accessorFn: (row) => row.input_cost, + meta: { title: "Costs" }, + header: ({ column }) => , + enableSorting: true, + size: 130, + minSize: 90, + cell: ({ row }) => , + }, + { + id: TEAM_ID_COLUMN_ID, + accessorFn: (row) => row.model_info.team_id ?? "", + meta: { title: "Team ID" }, + header: "Team ID", + enableSorting: false, + size: 140, + minSize: 90, + cell: ({ row }) => ( + + ), + }, + { + id: ACCESS_GROUPS_COLUMN_ID, + accessorFn: (row) => row.model_info.access_groups ?? [], + meta: { title: "Model Access Group", skeleton: "chips" }, + header: "Model Access Group", + enableSorting: false, + size: 200, + minSize: 120, + cell: ({ row }) => , + }, + { + id: STATUS_COLUMN_ID, + accessorFn: (row) => row.model_info.db_model, + meta: { title: "Status", skeleton: "badge" }, + header: ({ column }) => , + enableSorting: true, + size: 140, + minSize: 100, + cell: ({ row }) => + row.original.model_info.db_model ? ( + + ) : ( + + ), + }, + { + id: "actions", + meta: { title: "Actions", className: "text-right", headerClassName: "text-right" }, + header: "Actions", + enableSorting: false, + enableHiding: false, + enableResizing: false, + size: 110, + minSize: 110, + cell: ({ row }) => ( + + ), + }, +]; 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 deleted file mode 100644 index 4372c6e0efc..00000000000 --- a/ui/litellm-dashboard/src/components/model_dashboard/all_models_table.tsx +++ /dev/null @@ -1,221 +0,0 @@ -import { - ColumnDef, - flexRender, - getCoreRowModel, - getPaginationRowModel, - SortingState, - useReactTable, - ColumnResizeMode, - VisibilityState, - PaginationState, - OnChangeFn, -} from "@tanstack/react-table"; -import React from "react"; -import { Table, TableHead, TableHeaderCell, TableBody, TableRow, TableCell } from "@tremor/react"; -import { - TableHeaderSortDropdown, - SortState, -} from "../common_components/TableHeaderSortDropdown/TableHeaderSortDropdown"; - -// Extend the column meta type to include className -declare module "@tanstack/react-table" { - interface ColumnMeta { - className?: string; - } -} - -interface AllModelsDataTableProps { - data: TData[]; - columns: ColumnDef[]; - isLoading?: boolean; - sorting?: SortingState; - onSortingChange?: OnChangeFn; - pagination?: PaginationState; - onPaginationChange?: OnChangeFn; - enablePagination?: boolean; - onRowClick?: (row: TData) => void; -} - -export function AllModelsDataTable({ - data = [], - columns, - isLoading = false, - sorting = [], - onSortingChange, - pagination, - onPaginationChange, - enablePagination = false, - onRowClick, -}: AllModelsDataTableProps) { - const [columnResizeMode] = React.useState("onChange"); - const [columnSizing, setColumnSizing] = React.useState({}); - const [columnVisibility, setColumnVisibility] = React.useState({}); - - const tableInstance = useReactTable({ - data, - columns, - state: { - sorting, - columnSizing, - columnVisibility, - ...(enablePagination && pagination ? { pagination } : {}), - }, - columnResizeMode, - onSortingChange: onSortingChange, - onColumnSizingChange: setColumnSizing, - onColumnVisibilityChange: setColumnVisibility, - ...(enablePagination && onPaginationChange ? { onPaginationChange } : {}), - getCoreRowModel: getCoreRowModel(), - // NO getSortedRowModel - sorting is handled server-side - ...(enablePagination ? { getPaginationRowModel: getPaginationRowModel() } : {}), - enableSorting: true, - enableColumnResizing: true, - manualSorting: true, // Enable manual sorting for server-side sorting - defaultColumn: { - minSize: 40, - maxSize: 500, - }, - }); - - const getHeaderText = (header: any): string => { - if (typeof header === "string") { - return header; - } - if (typeof header === "function") { - const headerElement = header(); - if (headerElement && headerElement.props && headerElement.props.children) { - const children = headerElement.props.children; - if (typeof children === "string") { - return children; - } - if (children.props && children.props.children) { - return children.props.children; - } - } - } - return ""; - }; - - return ( -
-
-
- - - {tableInstance.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - -
-
- {header.isPlaceholder - ? null - : flexRender(header.column.columnDef.header, header.getContext())} -
- {header.id !== "actions" && header.column.getCanSort() && onSortingChange && ( - { - // Convert SortState to TanStack SortingState - // Only allow one column to be sorted at a time - if (newState === false) { - onSortingChange([]); - } else { - onSortingChange([ - { - id: header.column.id, - desc: newState === "desc", - }, - ]); - } - }} - columnId={header.column.id} - /> - )} -
- {header.column.getCanResize() && ( -
- )} - - ))} - - ))} - - - {isLoading ? ( - - -
-

🚅 Loading models...

-
-
-
- ) : tableInstance.getRowModel().rows.length > 0 ? ( - tableInstance.getRowModel().rows.map((row) => ( - onRowClick?.(row.original)} - > - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - )) - ) : ( - - -
-

No models found

-
-
-
- )} -
-
-
-
-
- ); -} diff --git a/ui/litellm-dashboard/src/components/model_dashboard/types.ts b/ui/litellm-dashboard/src/components/model_dashboard/types.ts index 77a03d2c039..e58204995dd 100644 --- a/ui/litellm-dashboard/src/components/model_dashboard/types.ts +++ b/ui/litellm-dashboard/src/components/model_dashboard/types.ts @@ -7,6 +7,7 @@ export interface ModelInfo { db_model: boolean; access_groups: string[] | null; blocked?: boolean; + team_public_model_name?: string; } export interface LiteLLMParams { diff --git a/ui/litellm-dashboard/src/components/molecules/models/columns.test.tsx b/ui/litellm-dashboard/src/components/molecules/models/columns.test.tsx deleted file mode 100644 index 9628c706c83..00000000000 --- a/ui/litellm-dashboard/src/components/molecules/models/columns.test.tsx +++ /dev/null @@ -1,1099 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi, beforeEach } from "vitest"; -import { useReactTable, getCoreRowModel, flexRender } from "@tanstack/react-table"; -import { columns } from "./columns"; -import { ModelData } from "../../model_dashboard/types"; -import { Table, TableHead, TableHeaderCell, TableBody, TableRow, TableCell } from "@tremor/react"; -import * as providerInfoHelpers from "../../provider_info_helpers"; - -vi.mock("../../provider_info_helpers"); - -vi.mock("@tremor/react", async (importOriginal) => { - const React = await import("react"); - const actual = await importOriginal(); - const IconComponent = React.forwardRef( - ({ icon: IconComp, onClick, className, ...props }, ref) => { - const ariaLabel = className?.includes("cursor-not-allowed") - ? "Config model cannot be deleted on the dashboard. Please delete it from the config file." - : "Delete model"; - return React.createElement( - "button", - { ...props, onClick, className, ref, "aria-label": ariaLabel }, - IconComp && React.createElement(IconComp, { className: "w-4 h-4" }), - ); - }, - ); - IconComponent.displayName = "Icon"; - // Re-apply the global Button/Tooltip overrides from tests/setupTests.ts. A file-level - // vi.mock fully replaces the setup-level mock, so without this the real Tremor Button - // leaks through and its useTooltip(300) schedules a native setTimeout that can fire - // post-teardown -> "window is not defined". - const Button = React.forwardRef(({ children, ...props }, ref) => - React.createElement("button", { ...props, ref }, children), - ); - const Tooltip = ({ children }: any) => React.createElement(React.Fragment, null, children); - return { - ...actual, - Icon: IconComponent, - Button, - Tooltip, - }; -}); - -const createMockModel = (overrides: Partial = {}): ModelData => ({ - model_info: { - id: "test-model-id", - created_at: "2024-01-01T00:00:00Z", - updated_at: "2024-01-02T00:00:00Z", - created_by: "test-user", - team_id: "test-team-id", - db_model: true, - access_groups: ["group1"], - }, - model_name: "test-model", - provider: "openai", - litellm_model_name: "gpt-4", - input_cost: 0.01, - output_cost: 0.03, - max_tokens: 4096, - max_input_tokens: 8192, - litellm_params: { - model: "gpt-4", - litellm_credential_name: "test-credential", - }, - cleanedLitellmParams: {}, - ...overrides, -}); - -const TestTable = ({ data, columns: cols }: { data: ModelData[]; columns: ReturnType }) => { - const table = useReactTable({ - data, - columns: cols, - getCoreRowModel: getCoreRowModel(), - }); - - return ( - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} - - ))} - - ))} - - - {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - {flexRender(cell.column.columnDef.cell, cell.getContext())} - ))} - - ))} - -
- ); -}; - -describe("columns", () => { - beforeEach(() => { - vi.mocked(providerInfoHelpers.getProviderLogoAndName).mockImplementation((provider: string) => { - const providerMap: Record = { - openai: { displayName: "OpenAI", logo: "/openai-logo.png" }, - anthropic: { displayName: "Anthropic", logo: "/anthropic-logo.png" }, - }; - return providerMap[provider] || { displayName: provider || "Unknown provider", logo: "" }; - }); - }); - - const defaultProps = { - userRole: "Admin", - userID: "test-user", - premiumUser: false, - setSelectedModelId: vi.fn(), - setSelectedTeamId: vi.fn(), - getDisplayModelName: vi.fn((model: ModelData) => model.model_name || "-"), - handleEditClick: vi.fn(), - handleRefreshClick: vi.fn(), - expandedRows: new Set(), - setExpandedRows: vi.fn(), - }; - - it("should render columns with table structure", () => { - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel(); - render(); - - expect(screen.getByText("Model ID")).toBeInTheDocument(); - expect(screen.getByText("Model Information")).toBeInTheDocument(); - expect(screen.getByText("Credentials")).toBeInTheDocument(); - expect(screen.getByText("Created By")).toBeInTheDocument(); - expect(screen.getByText("Updated At")).toBeInTheDocument(); - expect(screen.getByText("Costs")).toBeInTheDocument(); - expect(screen.getByText("Team ID")).toBeInTheDocument(); - expect(screen.getByText("Model Access Group")).toBeInTheDocument(); - expect(screen.getByText("Status")).toBeInTheDocument(); - expect(screen.getByText("Actions")).toBeInTheDocument(); - }); - - it("should display model information with provider logo", () => { - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel({ - model_name: "GPT-4", - provider: "openai", - litellm_model_name: "gpt-4", - }); - render(); - - expect(screen.getByText("GPT-4")).toBeInTheDocument(); - expect(screen.getByText("gpt-4")).toBeInTheDocument(); - }); - - it("should display credential name when available", () => { - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel({ - litellm_params: { - model: "gpt-4", - litellm_credential_name: "my-credential", - }, - }); - render(); - - expect(screen.getByText("my-credential")).toBeInTheDocument(); - }); - - it("should display 'Manual' when credential name is missing", () => { - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel({ - litellm_params: { - model: "gpt-4", - }, - }); - render(); - - expect(screen.getByText("Manual")).toBeInTheDocument(); - }); - - describe("credentials column", () => { - it("should display Credentials header with info icon", () => { - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel(); - render(); - - expect(screen.getByText("Credentials")).toBeInTheDocument(); - // Info icon is in a flex container with Credentials - ant icons render as span with role="img" - const credentialsHeader = screen.getByText("Credentials").closest("span"); - expect(credentialsHeader?.parentElement?.querySelector('[role="img"]')).toBeInTheDocument(); - }); - - it("should display reusable credential with SyncOutlined icon and credential name", () => { - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel({ - litellm_params: { - model: "gpt-4", - litellm_credential_name: "my-reusable-credential", - }, - }); - render(); - - expect(screen.getByText("my-reusable-credential")).toBeInTheDocument(); - const credentialCell = screen.getByText("my-reusable-credential").closest("div"); - expect(credentialCell).toHaveClass("flex"); - expect(screen.getByText("my-reusable-credential")).toHaveClass("text-blue-600"); - }); - - it("should display Manual with EditOutlined when no credential name", () => { - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel({ - litellm_params: { - model: "gpt-4", - }, - }); - render(); - - expect(screen.getByText("Manual")).toBeInTheDocument(); - expect(screen.getByText("Manual")).toHaveClass("text-gray-500"); - }); - - it("should display Manual when litellm_params is undefined", () => { - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel({ - litellm_params: undefined as any, - }); - render(); - - expect(screen.getByText("Manual")).toBeInTheDocument(); - }); - - it("should display Manual when litellm_credential_name is empty string", () => { - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel({ - litellm_params: { - model: "gpt-4", - litellm_credential_name: "", - }, - }); - render(); - - expect(screen.getByText("Manual")).toBeInTheDocument(); - }); - }); - - it("should display created by information for DB models", () => { - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel({ - model_info: { - ...createMockModel().model_info, - db_model: true, - created_by: "admin-user", - created_at: "2024-01-15T10:30:00Z", - }, - }); - render(); - - expect(screen.getByText("admin-user")).toBeInTheDocument(); - }); - - it("should display 'Defined in config' for config models", () => { - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel({ - model_info: { - ...createMockModel().model_info, - db_model: false, - }, - }); - render(); - - expect(screen.getByText("Defined in config")).toBeInTheDocument(); - }); - - it("should display costs when available", () => { - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel({ - input_cost: 0.01, - output_cost: 0.03, - }); - render(); - - expect(screen.getByText("In: $0.01")).toBeInTheDocument(); - expect(screen.getByText("Out: $0.03")).toBeInTheDocument(); - }); - - it("should display '-' when costs are missing", () => { - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel({ - input_cost: undefined as any, - output_cost: undefined as any, - }); - render(); - - const costCells = screen.getAllByText("-"); - expect(costCells.length).toBeGreaterThan(0); - }); - - it("should call setSelectedModelId without triggering the row click when the model ID pill is clicked", async () => { - const user = userEvent.setup(); - const setSelectedModelId = vi.fn(); - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel(); - const rowClick = vi.fn(); - render( -
- -
, - ); - - await user.click(screen.getByText("test-model-id")); - expect(setSelectedModelId).toHaveBeenCalledWith("test-model-id"); - expect(rowClick).not.toHaveBeenCalled(); - }); - - it("should call setSelectedTeamId without triggering the row click when the team ID pill is clicked", async () => { - const user = userEvent.setup(); - const setSelectedTeamId = vi.fn(); - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel(); - const rowClick = vi.fn(); - render( -
- -
, - ); - - await user.click(screen.getByText("test-team-id")); - expect(setSelectedTeamId).toHaveBeenCalledWith("test-team-id"); - expect(rowClick).not.toHaveBeenCalled(); - }); - - it("should display '-' when team ID is missing", () => { - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel({ - model_info: { - ...createMockModel().model_info, - team_id: "", - }, - }); - render(); - - const teamIdCells = screen.getAllByText("-"); - expect(teamIdCells.length).toBeGreaterThan(0); - }); - - it("should display access groups", () => { - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel({ - model_info: { - ...createMockModel().model_info, - access_groups: ["group1", "group2"], - }, - }); - render(); - - expect(screen.getByText("group1")).toBeInTheDocument(); - expect(screen.getByText("+1")).toBeInTheDocument(); - }); - - it("should expand access groups when expand button is clicked", async () => { - const user = userEvent.setup(); - const setExpandedRows = vi.fn(); - const expandedRows = new Set(); - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - expandedRows, - setExpandedRows, - ); - - const model = createMockModel({ - model_info: { - ...createMockModel().model_info, - id: "model-with-groups", - access_groups: ["group1", "group2", "group3"], - }, - }); - render(); - - const expandButton = screen.getByText("+2"); - expect(expandButton).toBeInTheDocument(); - - await user.click(expandButton); - expect(setExpandedRows).toHaveBeenCalled(); - }); - - it("should display '-' when access groups are empty", () => { - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel({ - model_info: { - ...createMockModel().model_info, - access_groups: null, - }, - }); - render(); - - const emptyCells = screen.getAllByText("-"); - expect(emptyCells.length).toBeGreaterThan(0); - }); - - it("should display 'DB Model' status for DB models", () => { - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel({ - model_info: { - ...createMockModel().model_info, - db_model: true, - }, - }); - render(); - - expect(screen.getByText("DB Model")).toBeInTheDocument(); - }); - - it("should display 'Config Model' status for config models", () => { - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel({ - model_info: { - ...createMockModel().model_info, - db_model: false, - }, - }); - render(); - - expect(screen.getByText("Config Model")).toBeInTheDocument(); - }); - - it("should allow Admin to delete DB models", async () => { - const user = userEvent.setup(); - const onDeleteClick = vi.fn(); - const cols = columns( - "Admin", - "admin-user", - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - onDeleteClick, - ); - - const model = createMockModel({ - model_info: { - ...createMockModel().model_info, - db_model: true, - id: "deletable-model", - }, - }); - render(); - - const deleteButton = screen.getByRole("button", { name: "Delete model" }); - expect(deleteButton).toBeInTheDocument(); - - await user.click(deleteButton); - expect(onDeleteClick).toHaveBeenCalledWith("deletable-model"); - }); - - it("should allow model creator to delete their own DB models", async () => { - const user = userEvent.setup(); - const onDeleteClick = vi.fn(); - const cols = columns( - "User", - "model-creator", - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - onDeleteClick, - ); - - const model = createMockModel({ - model_info: { - ...createMockModel().model_info, - db_model: true, - created_by: "model-creator", - id: "user-model", - }, - }); - render(); - - const deleteButton = screen.getByRole("button", { name: "Delete model" }); - expect(deleteButton).toBeInTheDocument(); - - await user.click(deleteButton); - expect(onDeleteClick).toHaveBeenCalledWith("user-model"); - }); - - it("should disable delete for config models", () => { - const cols = columns( - "Admin", - "admin-user", - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel({ - model_info: { - ...createMockModel().model_info, - db_model: false, - }, - }); - render(); - - const deleteButton = screen.getByRole("button", { name: /config model cannot be deleted/i }); - expect(deleteButton).toBeInTheDocument(); - expect(deleteButton).toHaveClass("cursor-not-allowed"); - }); - - it("should display collapsed access groups with expand button", () => { - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - new Set(), - defaultProps.setExpandedRows, - ); - - const model = createMockModel({ - model_info: { - ...createMockModel().model_info, - access_groups: ["group1", "group2", "group3"], - }, - }); - render(); - - expect(screen.getByText("group1")).toBeInTheDocument(); - expect(screen.getByText("+2")).toBeInTheDocument(); - expect(screen.queryByText("group2")).not.toBeInTheDocument(); - expect(screen.queryByText("group3")).not.toBeInTheDocument(); - }); - - it("should display expanded access groups when expanded", () => { - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - new Set(["test-model-id"]), - defaultProps.setExpandedRows, - ); - - const model = createMockModel({ - model_info: { - ...createMockModel().model_info, - id: "test-model-id", - access_groups: ["group1", "group2", "group3"], - }, - }); - render(); - - expect(screen.getByText("group1")).toBeInTheDocument(); - expect(screen.getByText("group2")).toBeInTheDocument(); - expect(screen.getByText("group3")).toBeInTheDocument(); - expect(screen.getByText("−")).toBeInTheDocument(); - }); - - it("should display single access group without expand button", () => { - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel({ - model_info: { - ...createMockModel().model_info, - access_groups: ["group1"], - }, - }); - render(); - - expect(screen.getByText("group1")).toBeInTheDocument(); - expect(screen.queryByText(/\+/)).not.toBeInTheDocument(); - }); - - it("should handle missing display name gracefully", () => { - const getDisplayModelName = vi.fn(() => ""); - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel(); - render(); - - expect(screen.getByText("-")).toBeInTheDocument(); - }); - - it("should handle missing created_at date", () => { - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel({ - model_info: { - ...createMockModel().model_info, - created_at: "", - }, - }); - render(); - - expect(screen.getByText("Unknown date")).toBeInTheDocument(); - }); - - it("should handle missing updated_at date", () => { - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel({ - model_info: { - ...createMockModel().model_info, - updated_at: "", - }, - }); - render(); - - const updatedAtCells = screen.getAllByText("-"); - expect(updatedAtCells.length).toBeGreaterThan(0); - }); - - it("should handle missing created_by for DB models", () => { - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel({ - model_info: { - ...createMockModel().model_info, - db_model: true, - created_by: "", - }, - }); - render(); - - expect(screen.getByText("Unknown")).toBeInTheDocument(); - }); - - it("should display only input cost when output cost is missing", () => { - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel({ - input_cost: 0.01, - output_cost: undefined as any, - }); - render(); - - expect(screen.getByText("In: $0.01")).toBeInTheDocument(); - expect(screen.queryByText(/Out:/)).not.toBeInTheDocument(); - }); - - it("should display only output cost when input cost is missing", () => { - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel({ - input_cost: undefined as any, - output_cost: 0.03, - }); - render(); - - expect(screen.getByText("Out: $0.03")).toBeInTheDocument(); - expect(screen.queryByText(/In:/)).not.toBeInTheDocument(); - }); - - describe("pause/resume toggle", () => { - const renderWithToggle = ( - overrides: Partial["model_info"]> = {}, - togglePauseHandler?: ReturnType, - userRole: string = "Admin", - ) => { - const handler = togglePauseHandler ?? vi.fn(); - const cols = columns( - userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - vi.fn(), - handler, - ); - const model = createMockModel({ - model_info: { ...createMockModel().model_info, ...overrides }, - }); - render(); - return { handler }; - }; - - it("renders the toggle ON for a db_model that is not blocked", () => { - renderWithToggle({ db_model: true, blocked: false }); - const toggle = screen.getByRole("switch", { name: /pause model/i }); - expect(toggle).toBeEnabled(); - expect(toggle).toHaveAttribute("aria-checked", "true"); - }); - - it("renders the toggle OFF for a db_model that is blocked", () => { - renderWithToggle({ db_model: true, blocked: true }); - const toggle = screen.getByRole("switch", { name: /resume model/i }); - expect(toggle).toBeEnabled(); - expect(toggle).toHaveAttribute("aria-checked", "false"); - }); - - it("calls the handler with blocked=true when an admin flips an active toggle off", async () => { - const handler = vi.fn(); - renderWithToggle({ db_model: true, blocked: false }, handler); - await userEvent.click(screen.getByRole("switch", { name: /pause model/i })); - expect(handler).toHaveBeenCalledWith("test-model-id", true); - }); - - it("calls the handler with blocked=false when an admin flips a paused toggle on", async () => { - const handler = vi.fn(); - renderWithToggle({ db_model: true, blocked: true }, handler); - await userEvent.click(screen.getByRole("switch", { name: /resume model/i })); - expect(handler).toHaveBeenCalledWith("test-model-id", false); - }); - - it("disables the toggle for non-admin users", () => { - const handler = vi.fn(); - renderWithToggle({ db_model: true, blocked: false }, handler, "User"); - const toggle = screen.getByRole("switch", { name: /pause model/i }); - expect(toggle).toBeDisabled(); - }); - - it("disables the toggle for config models", () => { - const handler = vi.fn(); - renderWithToggle({ db_model: false, blocked: false }, handler, "Admin"); - const toggle = screen.getByRole("switch", { name: /pause model/i }); - expect(toggle).toBeDisabled(); - }); - - it("disables the toggle while a PATCH for the same row is in-flight", () => { - // Regression for Greptile P1 on PR #28151 — antd's `loading` prop is - // visual only and does not prevent click events, so the row needs to - // be explicitly disabled while its PATCH is pending to avoid - // racing/conflicting PATCH calls on double-click. - const handler = vi.fn(); - const model = createMockModel({ - model_info: { - ...createMockModel().model_info, - db_model: true, - blocked: false, - }, - }); - const cols = columns( - "Admin", - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - vi.fn(), - handler, - model.model_info.id, // pausingModelId matches this row - ); - render(); - const toggle = screen.getByRole("switch", { name: /pause model/i }); - expect(toggle).toBeDisabled(); - }); - }); -}); diff --git a/ui/litellm-dashboard/src/components/molecules/models/columns.tsx b/ui/litellm-dashboard/src/components/molecules/models/columns.tsx deleted file mode 100644 index d043d0820f8..00000000000 --- a/ui/litellm-dashboard/src/components/molecules/models/columns.tsx +++ /dev/null @@ -1,423 +0,0 @@ -import { EditOutlined, InfoCircleOutlined, SyncOutlined } from "@ant-design/icons"; -import { TrashIcon } from "@heroicons/react/outline"; -import { ColumnDef } from "@tanstack/react-table"; -import { Badge, Icon } from "@tremor/react"; -import { Divider, Flex, Popover, Space, Switch, Tooltip, Typography } from "antd"; -import { DateCell, IdCell, StatusBadge } from "@/components/shared/table_cells"; -import { ModelData } from "../../model_dashboard/types"; -import { ProviderLogo } from "./ProviderLogo"; - -const { Text, Title } = Typography; - -const credentialsInfoPopoverContent = ( - - - Credential types - - - - - - - - Reusable - - - Credentials saved in LiteLLM that can be added to models repeatedly. - - - - - - - - - Manual - - - Credentials added directly during model creation or defined in the config file. - - - - -); - -export const columns = ( - userRole: string, - userID: string, - premiumUser: boolean, - setSelectedModelId: (id: string) => void, - setSelectedTeamId: (id: string) => void, - getDisplayModelName: (model: any) => string, - handleEditClick: (model: any) => void, - handleRefreshClick: () => void, - expandedRows: Set, - setExpandedRows: (expandedRows: Set) => void, - onDeleteClick?: (modelId: string) => void, - onTogglePauseClick?: (modelId: string, blocked: boolean) => void | Promise, - pausingModelId?: string | null, -): ColumnDef[] => [ - { - header: () => Model ID, - accessorKey: "model_info.id", - enableSorting: false, - size: 130, - minSize: 80, - cell: ({ row }) => { - const model = row.original; - return ( -
e.stopPropagation()}> - -
- ); - }, - }, - { - header: () => Model Information, - accessorKey: "model_name", - size: 250, - minSize: 120, - cell: ({ row }) => { - const model = row.original; - const displayName = getDisplayModelName(row.original) || "-"; - const popoverContent = ( - - - - - {model.provider || "Unknown provider"} - - - - - - - Public Model Name - - - {displayName} - - - - - - LiteLLM Model Name - - - {model.litellm_model_name || "-"} - - - - - ); - - return ( - -
-
- {model.provider ? ( - - ) : ( -
-
- )} -
- -
- - {displayName} - - - {model.litellm_model_name || "-"} - -
-
-
- ); - }, - }, - { - header: () => ( - - Credentials - - - - - ), - accessorKey: "litellm_credential_name", - enableSorting: false, - size: 180, - minSize: 100, - cell: ({ row }) => { - const model = row.original; - const credentialName = model.litellm_params?.litellm_credential_name; - const isReusable = !!credentialName; - - return ( -
- {isReusable ? ( - <> - - - {credentialName} - - - ) : ( - <> - - Manual - - )} -
- ); - }, - }, - { - header: () => Created By, - accessorKey: "model_info.created_by", - sortingFn: "datetime", - size: 160, - minSize: 100, - cell: ({ row }) => { - const model = row.original; - const isConfigModel = !model.model_info?.db_model; - const createdBy = model.model_info.created_by; - const createdAt = model.model_info.created_at ? new Date(model.model_info.created_at).toLocaleDateString() : null; - - return ( -
- {/* Created By - Primary */} -
- {isConfigModel ? "Defined in config" : createdBy || "Unknown"} -
- {/* Created At - Secondary */} -
- {isConfigModel ? "-" : createdAt || "Unknown date"} -
-
- ); - }, - }, - { - header: () => Updated At, - accessorKey: "model_info.updated_at", - sortingFn: "datetime", - size: 120, - minSize: 80, - cell: ({ row }) => { - const model = row.original; - return ; - }, - }, - { - header: () => Costs, - accessorKey: "input_cost", - size: 120, - minSize: 80, - cell: ({ row }) => { - const model = row.original; - const inputCost = model.input_cost; - const outputCost = model.output_cost; - - // If both costs are missing or undefined, show "-" - if (inputCost == null && outputCost == null) { - return ( -
- - -
- ); - } - - return ( - -
- {/* Input Cost - Primary */} - {inputCost != null &&
In: ${inputCost}
} - {/* Output Cost - Secondary */} - {outputCost != null &&
Out: ${outputCost}
} -
-
- ); - }, - }, - { - header: () => Team ID, - accessorKey: "model_info.team_id", - enableSorting: false, - size: 130, - minSize: 80, - cell: ({ row }) => { - const model = row.original; - return model.model_info.team_id ? ( -
e.stopPropagation()}> - -
- ) : ( - "-" - ); - }, - }, - { - header: () => Model Access Group, - accessorKey: "model_info.model_access_group", - enableSorting: false, - size: 180, - minSize: 100, - cell: ({ row }) => { - const model = row.original; - const accessGroups = model.model_info.access_groups; - - if (!accessGroups || accessGroups.length === 0) { - return "-"; - } - - const modelId = model.model_info.id; - const isExpanded = expandedRows.has(modelId); - const shouldShowExpandButton = accessGroups.length > 1; - - const toggleExpanded = () => { - const newExpanded = new Set(expandedRows); - if (isExpanded) { - newExpanded.delete(modelId); - } else { - newExpanded.add(modelId); - } - setExpandedRows(newExpanded); - }; - - return ( -
- - {accessGroups[0]} - - - {(isExpanded || (!shouldShowExpandButton && accessGroups.length === 2)) && - accessGroups.slice(1).map((group: string, index: number) => ( - - {group} - - ))} - - {shouldShowExpandButton && ( - - )} -
- ); - }, - }, - { - header: () => Status, - accessorKey: "model_info.db_model", - size: 120, - minSize: 80, - cell: ({ row }) => { - const model = row.original; - return model.model_info.db_model ? ( - - ) : ( - - ); - }, - }, - { - id: "actions", - header: () => Actions, - size: 100, - minSize: 80, - enableResizing: false, - cell: ({ row }) => { - const model = row.original; - const canEditModel = userRole === "Admin" || model.model_info?.created_by === userID; - const isConfigModel = !model.model_info?.db_model; - const isAdmin = userRole === "Admin"; - const isBlocked = model.model_info?.blocked === true; - const isPauseToggleable = !isConfigModel && isAdmin && Boolean(onTogglePauseClick); - const pauseTooltip = isConfigModel - ? "Config models cannot be paused from the dashboard. Pause is DB-backed." - : !isAdmin - ? "Only proxy admins can pause or resume a model." - : isBlocked - ? "Resume model — restore normal routing." - : "Pause model — stop routing requests until resumed."; - // antd's `loading` prop on Switch is purely cosmetic — it does not block - // clicks. Pair `loading` with `disabled` derived from the same condition - // so a double-click during a pending PATCH cannot send a second, - // conflicting `blocked` value. - const isPausing = pausingModelId === model.model_info?.id; - return ( -
- - { - e.stopPropagation(); - }} - onChange={(nextChecked) => { - const modelId = model.model_info?.id; - if (isPauseToggleable && onTogglePauseClick && modelId) { - void onTogglePauseClick(modelId, !nextChecked); - } - }} - /> - - {isConfigModel ? ( - - - - ) : ( - - { - e.stopPropagation(); - if (canEditModel && onDeleteClick) { - onDeleteClick(model.model_info.id); - } - }} - className={!canEditModel ? "opacity-50 cursor-not-allowed" : "cursor-pointer hover:text-red-600"} - /> - - )} -
- ); - }, - }, -]; diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTableFilterDrawer.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTableFilterDrawer.tsx index 8aaec3f13a0..ea986a84cc3 100644 --- a/ui/litellm-dashboard/src/components/shared/DataTable/DataTableFilterDrawer.tsx +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTableFilterDrawer.tsx @@ -20,6 +20,8 @@ interface DataTableFilterDrawerProps { description?: React.ReactNode; applyLabel?: string; resetLabel?: string; + /** Runs instead of the default "clear this table's column filters" when the reset button is pressed. */ + onReset?: () => void; children: (draft: FilterDraft) => React.ReactNode; } @@ -48,6 +50,7 @@ export function DataTableFilterDrawer({ description, applyLabel = "Apply Filters", resetLabel = "Reset", + onReset, children, }: DataTableFilterDrawerProps) { const [draft, setDraft] = React.useState>(() => toDraft(table.getState().columnFilters)); @@ -72,6 +75,10 @@ export function DataTableFilterDrawer({ const reset = () => { setDraft({}); + if (onReset !== undefined) { + onReset(); + return; + } table.setColumnFilters([]); }; diff --git a/ui/litellm-dashboard/src/components/ui/hover-card.tsx b/ui/litellm-dashboard/src/components/ui/hover-card.tsx new file mode 100644 index 00000000000..586a77b7cb6 --- /dev/null +++ b/ui/litellm-dashboard/src/components/ui/hover-card.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { PreviewCard as PreviewCardPrimitive } from "@base-ui/react/preview-card"; + +import { cn } from "@/lib/cva.config"; + +function HoverCard({ ...props }: PreviewCardPrimitive.Root.Props) { + return ; +} + +function HoverCardTrigger({ ...props }: PreviewCardPrimitive.Trigger.Props) { + return ; +} + +function HoverCardContent({ + className, + side = "bottom", + sideOffset = 4, + align = "center", + alignOffset = 4, + ...props +}: PreviewCardPrimitive.Popup.Props & + Pick) { + return ( + + + + + + ); +} + +export { HoverCard, HoverCardTrigger, HoverCardContent };