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 ? (
-
- ) : (
- {
- if (value === "personal") {
- setCurrentTeam("personal");
- // Reset to page 1 when team changes
- setCurrentPage(1);
- setPagination((prev: PaginationState) => ({ ...prev, pageIndex: 0 }));
- } else {
- const team = teams?.find((t) => t.team_id === value);
- if (team) {
- setCurrentTeam(team);
- // Reset to page 1 when team changes
- setCurrentPage(1);
- setPagination((prev: PaginationState) => ({ ...prev, pageIndex: 0 }));
- }
- }
- }}
- loading={isLoadingTeams}
- options={[
- {
- value: "personal",
- label: (
-
-
- Personal
-
- ),
- },
- ...(teams
- ?.filter((team) => team.team_id)
- .map((team) => ({
- value: team.team_id,
- label: (
-
-
-
- {team.team_alias ? team.team_alias : team.team_id}
-
-
- ),
- })) ?? []),
- ]}
- />
- )}
-
-
-
-
View:
-
- {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 */}
-
setShowFilters(!showFilters)}
- >
-
-
-
- Filters
-
-
- {/* Reset Filters Button */}
-
-
-
-
- Reset Filters
-
-
-
- {/* Model Settings Button */}
-
}
- onClick={() => setIsModelSettingsModalVisible(true)}
- title="Model Settings"
- />
-
-
- {/* Additional Filters */}
- {showFilters && (
-
- {/* Model Name Filter */}
-
- setSelectedModelGroup(value === "all" ? "all" : value)}
- placeholder="Filter by Public Model Name"
- showSearch
- options={[
- { value: "all", label: "All Models" },
- { value: "wildcard", label: "Wildcard Models (*)" },
- ...availableModelGroups.map((group, idx) => ({
- value: group,
- label: group,
- })),
- ]}
- />
-
-
- {/* Model Access Group 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 ? (
-
- ) : (
- {
- const newPage = currentPage - 1;
- setCurrentPage(newPage);
- setPagination((prev: PaginationState) => ({ ...prev, pageIndex: 0 }));
- }}
- disabled={currentPage === 1}
- className={`px-3 py-1 text-sm border rounded-md ${
- currentPage === 1 ? "bg-gray-100 text-gray-400 cursor-not-allowed" : "hover:bg-gray-50"
- }`}
- >
- Previous
-
- )}
-
- {isLoading ? (
-
- ) : (
- {
- const newPage = currentPage + 1;
- setCurrentPage(newPage);
- setPagination((prev: PaginationState) => ({ ...prev, pageIndex: 0 }));
- }}
- disabled={currentPage >= paginationMeta.total_pages}
- className={`px-3 py-1 text-sm border rounded-md ${
- currentPage >= paginationMeta.total_pages
- ? "bg-gray-100 text-gray-400 cursor-not-allowed"
- : "hover:bg-gray-50"
- }`}
- >
- Next
-
- )}
-
-
-
-
-
-
{},
- () => {},
- 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}
+ >
+ onTeamChange(String(value))}>
+
+
+ Team
+ {selectedTeamLabel}
+
+
+ {teamOptions.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+ onViewModeChange(value as ModelViewMode)}>
+
+ View
+ {VIEW_MODE_LABELS[viewMode]}
+
+
+ {VIEW_MODE_LABELS.current_team}
+ {VIEW_MODE_LABELS.all}
+
+
+
+
+
+
+
+
+
+
+ {({ 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}
+
+ void copyToClipboard(litellmModelName, "LiteLLM model name copied")}
+ >
+
+
+
+
+
+
+
+ );
+}
+
+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);
+ }
+ }}
+ />
+
+ }
+ />
+ )}
+
+
+ {
+ if (onDeleteClick && modelId) {
+ onDeleteClick(modelId);
+ }
+ }}
+ >
+
+
+
+ }
+ />
+
+ );
+}
+
+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 ? (
-
-
-
-
-
- ) : tableInstance.getRowModel().rows.length > 0 ? (
- tableInstance.getRowModel().rows.map((row) => (
- onRowClick?.(row.original)}
- >
- {row.getVisibleCells().map((cell) => (
-
- {flexRender(cell.column.columnDef.cell, cell.getContext())}
-
- ))}
-
- ))
- ) : (
-
-
-
-
-
- )}
-
-
-
-
-
- );
-}
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 && (
- {
- e.stopPropagation();
- toggleExpanded();
- }}
- className="text-xs text-blue-600 hover:text-blue-800 px-1 py-0.5 rounded-sm hover:bg-blue-50 h-5 leading-tight shrink-0 whitespace-nowrap"
- >
- {isExpanded ? "−" : `+${accessGroups.length - 1}`}
-
- )}
-
- );
- },
- },
- {
- 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 };