From 7cf80a928335207c00b96c40adb7de99ee9fbcb0 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Wed, 21 Jan 2026 11:53:15 -0800 Subject: [PATCH 1/2] Paginate All Models Tab --- .../hooks/models/useModels.test.ts | 627 ++++++++++++++++++ .../app/(dashboard)/hooks/models/useModels.ts | 16 +- .../ModelsAndEndpointsView.tsx | 3 +- .../components/AllModelsTab.test.tsx | 332 +++++++--- .../components/AllModelsTab.tsx | 94 +-- .../src/components/networking.tsx | 6 +- 6 files changed, 929 insertions(+), 149 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.test.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.test.ts new file mode 100644 index 00000000000..a97c309ca91 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.test.ts @@ -0,0 +1,627 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import React, { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + useModelsInfo, + useModelHub, + useAllProxyModels, + useSelectedTeamModels, + type ProxyModel, + type AllProxyModelsResponse, + type PaginatedModelInfoResponse, +} from "./useModels"; + +vi.mock("@/components/networking", () => ({ + modelInfoCall: vi.fn(), + modelHubCall: vi.fn(), + modelAvailableCall: vi.fn(), +})); + +const mockUseAuthorized = vi.fn(); +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: () => mockUseAuthorized(), +})); + +import { modelInfoCall, modelHubCall, modelAvailableCall } from "@/components/networking"; + +const mockProxyModel: ProxyModel = { + id: "model-1", + object: "model", + created: 1234567890, + owned_by: "openai", +}; + +const mockPaginatedModelInfoResponse: PaginatedModelInfoResponse = { + data: [{ id: "model-1", name: "Test Model" }], + total_count: 1, + current_page: 1, + total_pages: 1, + size: 50, +}; + +const mockAllProxyModelsResponse: AllProxyModelsResponse = { + data: [mockProxyModel], +}; + +describe("useModelsInfo", () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + vi.clearAllMocks(); + + mockUseAuthorized.mockReturnValue({ + accessToken: "test-access-token", + userId: "test-user-id", + userRole: "Admin", + token: "test-token", + userEmail: "test@example.com", + premiumUser: false, + disabledPersonalKeyCreation: null, + showSSOBanner: false, + }); + }); + + const wrapper = ({ children }: { children: ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + + it("should render without crashing", () => { + (modelInfoCall as any).mockResolvedValue(mockPaginatedModelInfoResponse); + + const { result } = renderHook(() => useModelsInfo(), { wrapper }); + + expect(result.current).toBeDefined(); + }); + + it("should return models data when query is successful", async () => { + (modelInfoCall as any).mockResolvedValue(mockPaginatedModelInfoResponse); + + const { result } = renderHook(() => useModelsInfo(), { wrapper }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.data).toBeUndefined(); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data).toEqual(mockPaginatedModelInfoResponse); + expect(result.current.error).toBeNull(); + expect(modelInfoCall).toHaveBeenCalledWith( + "test-access-token", + "test-user-id", + "Admin", + 1, + 50 + ); + expect(modelInfoCall).toHaveBeenCalledTimes(1); + }); + + it("should use custom page and size parameters", async () => { + (modelInfoCall as any).mockResolvedValue(mockPaginatedModelInfoResponse); + + const { result } = renderHook(() => useModelsInfo(2, 25), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(modelInfoCall).toHaveBeenCalledWith( + "test-access-token", + "test-user-id", + "Admin", + 2, + 25 + ); + }); + + it("should handle error when modelInfoCall fails", async () => { + const errorMessage = "Failed to fetch models"; + const testError = new Error(errorMessage); + + (modelInfoCall as any).mockRejectedValue(testError); + + const { result } = renderHook(() => useModelsInfo(), { wrapper }); + + expect(result.current.isLoading).toBe(true); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toEqual(testError); + expect(result.current.data).toBeUndefined(); + expect(modelInfoCall).toHaveBeenCalledTimes(1); + }); + + it("should not execute query when accessToken is missing", () => { + mockUseAuthorized.mockReturnValue({ + accessToken: null, + userId: "test-user-id", + userRole: "Admin", + token: null, + userEmail: "test@example.com", + premiumUser: false, + disabledPersonalKeyCreation: null, + showSSOBanner: false, + }); + + const { result } = renderHook(() => useModelsInfo(), { wrapper }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toBeUndefined(); + expect(result.current.isFetched).toBe(false); + expect(modelInfoCall).not.toHaveBeenCalled(); + }); + + it("should not execute query when userId is missing", () => { + mockUseAuthorized.mockReturnValue({ + accessToken: "test-access-token", + userId: null, + userRole: "Admin", + token: "test-token", + userEmail: "test@example.com", + premiumUser: false, + disabledPersonalKeyCreation: null, + showSSOBanner: false, + }); + + const { result } = renderHook(() => useModelsInfo(), { wrapper }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toBeUndefined(); + expect(result.current.isFetched).toBe(false); + expect(modelInfoCall).not.toHaveBeenCalled(); + }); + + it("should not execute query when userRole is missing", () => { + mockUseAuthorized.mockReturnValue({ + accessToken: "test-access-token", + userId: "test-user-id", + userRole: null, + token: "test-token", + userEmail: "test@example.com", + premiumUser: false, + disabledPersonalKeyCreation: null, + showSSOBanner: false, + }); + + const { result } = renderHook(() => useModelsInfo(), { wrapper }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toBeUndefined(); + expect(result.current.isFetched).toBe(false); + expect(modelInfoCall).not.toHaveBeenCalled(); + }); + + it("should not execute query when all required auth values are missing", () => { + mockUseAuthorized.mockReturnValue({ + accessToken: null, + userId: null, + userRole: null, + token: null, + userEmail: "test@example.com", + premiumUser: false, + disabledPersonalKeyCreation: null, + showSSOBanner: false, + }); + + const { result } = renderHook(() => useModelsInfo(), { wrapper }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toBeUndefined(); + expect(result.current.isFetched).toBe(false); + expect(modelInfoCall).not.toHaveBeenCalled(); + }); +}); + +describe("useModelHub", () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + vi.clearAllMocks(); + + mockUseAuthorized.mockReturnValue({ + accessToken: "test-access-token", + userId: "test-user-id", + userRole: "Admin", + token: "test-token", + userEmail: "test@example.com", + premiumUser: false, + disabledPersonalKeyCreation: null, + showSSOBanner: false, + }); + }); + + const wrapper = ({ children }: { children: ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + + it("should render without crashing", () => { + (modelHubCall as any).mockResolvedValue({ data: [] }); + + const { result } = renderHook(() => useModelHub(), { wrapper }); + + expect(result.current).toBeDefined(); + }); + + it("should return model hub data when query is successful", async () => { + const mockHubData = { data: [{ id: "hub-1", name: "Test Hub" }] }; + (modelHubCall as any).mockResolvedValue(mockHubData); + + const { result } = renderHook(() => useModelHub(), { wrapper }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.data).toBeUndefined(); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data).toEqual(mockHubData); + expect(result.current.error).toBeNull(); + expect(modelHubCall).toHaveBeenCalledWith("test-access-token"); + expect(modelHubCall).toHaveBeenCalledTimes(1); + }); + + it("should handle error when modelHubCall fails", async () => { + const errorMessage = "Failed to fetch model hub"; + const testError = new Error(errorMessage); + + (modelHubCall as any).mockRejectedValue(testError); + + const { result } = renderHook(() => useModelHub(), { wrapper }); + + expect(result.current.isLoading).toBe(true); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toEqual(testError); + expect(result.current.data).toBeUndefined(); + expect(modelHubCall).toHaveBeenCalledTimes(1); + }); + + it("should not execute query when accessToken is missing", () => { + mockUseAuthorized.mockReturnValue({ + accessToken: null, + userId: "test-user-id", + userRole: "Admin", + token: null, + userEmail: "test@example.com", + premiumUser: false, + disabledPersonalKeyCreation: null, + showSSOBanner: false, + }); + + const { result } = renderHook(() => useModelHub(), { wrapper }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toBeUndefined(); + expect(result.current.isFetched).toBe(false); + expect(modelHubCall).not.toHaveBeenCalled(); + }); +}); + +describe("useAllProxyModels", () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + vi.clearAllMocks(); + + mockUseAuthorized.mockReturnValue({ + accessToken: "test-access-token", + userId: "test-user-id", + userRole: "Admin", + token: "test-token", + userEmail: "test@example.com", + premiumUser: false, + disabledPersonalKeyCreation: null, + showSSOBanner: false, + }); + }); + + const wrapper = ({ children }: { children: ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + + it("should render without crashing", () => { + (modelAvailableCall as any).mockResolvedValue(mockAllProxyModelsResponse); + + const { result } = renderHook(() => useAllProxyModels(), { wrapper }); + + expect(result.current).toBeDefined(); + }); + + it("should return all proxy models data when query is successful", async () => { + (modelAvailableCall as any).mockResolvedValue(mockAllProxyModelsResponse); + + const { result } = renderHook(() => useAllProxyModels(), { wrapper }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.data).toBeUndefined(); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data).toEqual(mockAllProxyModelsResponse); + expect(result.current.error).toBeNull(); + expect(modelAvailableCall).toHaveBeenCalledWith( + "test-access-token", + "test-user-id", + "Admin", + true + ); + expect(modelAvailableCall).toHaveBeenCalledTimes(1); + }); + + it("should handle error when modelAvailableCall fails", async () => { + const errorMessage = "Failed to fetch proxy models"; + const testError = new Error(errorMessage); + + (modelAvailableCall as any).mockRejectedValue(testError); + + const { result } = renderHook(() => useAllProxyModels(), { wrapper }); + + expect(result.current.isLoading).toBe(true); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toEqual(testError); + expect(result.current.data).toBeUndefined(); + expect(modelAvailableCall).toHaveBeenCalledTimes(1); + }); + + it("should not execute query when accessToken is missing", () => { + mockUseAuthorized.mockReturnValue({ + accessToken: null, + userId: "test-user-id", + userRole: "Admin", + token: null, + userEmail: "test@example.com", + premiumUser: false, + disabledPersonalKeyCreation: null, + showSSOBanner: false, + }); + + const { result } = renderHook(() => useAllProxyModels(), { wrapper }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toBeUndefined(); + expect(result.current.isFetched).toBe(false); + expect(modelAvailableCall).not.toHaveBeenCalled(); + }); + + it("should not execute query when userId is missing", () => { + mockUseAuthorized.mockReturnValue({ + accessToken: "test-access-token", + userId: null, + userRole: "Admin", + token: "test-token", + userEmail: "test@example.com", + premiumUser: false, + disabledPersonalKeyCreation: null, + showSSOBanner: false, + }); + + const { result } = renderHook(() => useAllProxyModels(), { wrapper }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toBeUndefined(); + expect(result.current.isFetched).toBe(false); + expect(modelAvailableCall).not.toHaveBeenCalled(); + }); + + it("should not execute query when userRole is missing", () => { + mockUseAuthorized.mockReturnValue({ + accessToken: "test-access-token", + userId: "test-user-id", + userRole: null, + token: "test-token", + userEmail: "test@example.com", + premiumUser: false, + disabledPersonalKeyCreation: null, + showSSOBanner: false, + }); + + const { result } = renderHook(() => useAllProxyModels(), { wrapper }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toBeUndefined(); + expect(result.current.isFetched).toBe(false); + expect(modelAvailableCall).not.toHaveBeenCalled(); + }); +}); + +describe("useSelectedTeamModels", () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + vi.clearAllMocks(); + + mockUseAuthorized.mockReturnValue({ + accessToken: "test-access-token", + userId: "test-user-id", + userRole: "Admin", + token: "test-token", + userEmail: "test@example.com", + premiumUser: false, + disabledPersonalKeyCreation: null, + showSSOBanner: false, + }); + }); + + const wrapper = ({ children }: { children: ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + + it("should render without crashing", () => { + (modelAvailableCall as any).mockResolvedValue(mockAllProxyModelsResponse); + + const { result } = renderHook(() => useSelectedTeamModels("team-1"), { wrapper }); + + expect(result.current).toBeDefined(); + }); + + it("should return team models data when query is successful", async () => { + (modelAvailableCall as any).mockResolvedValue(mockAllProxyModelsResponse); + + const { result } = renderHook(() => useSelectedTeamModels("team-1"), { wrapper }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.data).toBeUndefined(); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data).toEqual(mockAllProxyModelsResponse); + expect(result.current.error).toBeNull(); + expect(modelAvailableCall).toHaveBeenCalledWith( + "test-access-token", + "test-user-id", + "Admin", + true, + "team-1" + ); + expect(modelAvailableCall).toHaveBeenCalledTimes(1); + }); + + it("should handle error when modelAvailableCall fails", async () => { + const errorMessage = "Failed to fetch team models"; + const testError = new Error(errorMessage); + + (modelAvailableCall as any).mockRejectedValue(testError); + + const { result } = renderHook(() => useSelectedTeamModels("team-1"), { wrapper }); + + expect(result.current.isLoading).toBe(true); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toEqual(testError); + expect(result.current.data).toBeUndefined(); + expect(modelAvailableCall).toHaveBeenCalledTimes(1); + }); + + it("should not execute query when teamID is null", () => { + const { result } = renderHook(() => useSelectedTeamModels(null), { wrapper }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toBeUndefined(); + expect(result.current.isFetched).toBe(false); + expect(modelAvailableCall).not.toHaveBeenCalled(); + }); + + it("should not execute query when accessToken is missing", () => { + mockUseAuthorized.mockReturnValue({ + accessToken: null, + userId: "test-user-id", + userRole: "Admin", + token: null, + userEmail: "test@example.com", + premiumUser: false, + disabledPersonalKeyCreation: null, + showSSOBanner: false, + }); + + const { result } = renderHook(() => useSelectedTeamModels("team-1"), { wrapper }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toBeUndefined(); + expect(result.current.isFetched).toBe(false); + expect(modelAvailableCall).not.toHaveBeenCalled(); + }); + + it("should not execute query when userId is missing", () => { + mockUseAuthorized.mockReturnValue({ + accessToken: "test-access-token", + userId: null, + userRole: "Admin", + token: "test-token", + userEmail: "test@example.com", + premiumUser: false, + disabledPersonalKeyCreation: null, + showSSOBanner: false, + }); + + const { result } = renderHook(() => useSelectedTeamModels("team-1"), { wrapper }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toBeUndefined(); + expect(result.current.isFetched).toBe(false); + expect(modelAvailableCall).not.toHaveBeenCalled(); + }); + + it("should not execute query when userRole is missing", () => { + mockUseAuthorized.mockReturnValue({ + accessToken: "test-access-token", + userId: "test-user-id", + userRole: null, + token: "test-token", + userEmail: "test@example.com", + premiumUser: false, + disabledPersonalKeyCreation: null, + showSSOBanner: false, + }); + + const { result } = renderHook(() => useSelectedTeamModels("team-1"), { wrapper }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toBeUndefined(); + expect(result.current.isFetched).toBe(false); + expect(modelAvailableCall).not.toHaveBeenCalled(); + }); + + it("should not execute query when teamID is missing and other auth values are present", () => { + const { result } = renderHook(() => useSelectedTeamModels(null), { wrapper }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toBeUndefined(); + expect(result.current.isFetched).toBe(false); + expect(modelAvailableCall).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts index fa7ab911ecd..b9f67c1cac8 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts @@ -14,21 +14,31 @@ export interface AllProxyModelsResponse { data: ProxyModel[]; } +export interface PaginatedModelInfoResponse { + data: any[]; + total_count: number; + current_page: number; + total_pages: number; + size: number; +} + const modelKeys = createQueryKeys("models"); const modelHubKeys = createQueryKeys("modelHub"); const allProxyModelsKeys = createQueryKeys("allProxyModels"); const selectedTeamModelsKeys = createQueryKeys("selectedTeamModels"); -export const useModelsInfo = () => { +export const useModelsInfo = (page: number = 1, size: number = 50) => { const { accessToken, userId, userRole } = useAuthorized(); - return useQuery({ + return useQuery({ queryKey: modelKeys.list({ filters: { ...(userId && { userId }), ...(userRole && { userRole }), + page, + size, }, }), - queryFn: async () => await modelInfoCall(accessToken!, userId!, userRole!), + queryFn: async () => await modelInfoCall(accessToken!, userId!, userRole!, page, size), enabled: Boolean(accessToken && userId && userRole), }); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx index 1cce704467a..f707ef04ffb 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx @@ -94,7 +94,8 @@ const ModelsAndEndpointsView: React.FC = ({ premiumUser, te }, [modelDataResponse?.data]); const allModelsOnProxy = useMemo(() => { - return modelDataResponse?.data?.map((model: any) => model.model_name); + if (!modelDataResponse?.data) return []; + return modelDataResponse.data.map((model: any) => model.model_name); }, [modelDataResponse?.data]); const getProviderFromModel = (model: string) => { 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 ae376701a9a..f20c3563c83 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,13 +1,18 @@ import * as useAuthorizedModule from "@/app/(dashboard)/hooks/useAuthorized"; import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import AllModelsTab from "./AllModelsTab"; // Mock the useModelsInfo hook -const mockUseModelsInfo = vi.fn(() => ({ data: { data: [] } })) as any; +const mockUseModelsInfo = vi.fn(() => ({ + data: { data: [], total_count: 0, current_page: 1, total_pages: 1, size: 50 }, + isLoading: false, + error: null, +})) as any; vi.mock("../../hooks/models/useModels", () => ({ - useModelsInfo: () => mockUseModelsInfo(), + useModelsInfo: (page?: number, size?: number) => mockUseModelsInfo(page, size), })); // Mock the useModelCostMap hook @@ -51,6 +56,21 @@ const createModelCostMapMock = (data: Record) => ({ error: null, }); +// 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, +}); + describe("AllModelsTab", () => { const mockSetSelectedModelGroup = vi.fn(); const mockSetSelectedModelId = vi.fn(); @@ -84,7 +104,11 @@ describe("AllModelsTab", () => { }); it("should render with empty data", () => { - mockUseModelsInfo.mockReturnValueOnce({ data: { data: [] } }); + mockUseModelsInfo.mockReturnValueOnce({ + data: createPaginatedModelData([], 0, 1, 1, 50), + isLoading: false, + error: null, + }); mockUseTeams.mockReturnValueOnce({ data: [], @@ -130,28 +154,26 @@ describe("AllModelsTab", () => { }), ); - const modelData = { - data: [ - { - model_name: "gpt-4-accessible", - model_info: { - id: "model-1", - access_via_team_ids: ["team-456"], - access_groups: [], - }, + 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: [], - }, + }, + { + model_name: "gpt-3.5-turbo-blocked", + model_info: { + id: "model-2", + access_via_team_ids: ["team-789"], + access_groups: [], }, - ], - }; + }, + ], 2, 1, 1, 50); - mockUseModelsInfo.mockReturnValue({ data: modelData }); + mockUseModelsInfo.mockReturnValue({ data: modelData, isLoading: false, error: null }); render(); @@ -191,28 +213,26 @@ describe("AllModelsTab", () => { }), ); - const modelData = { - data: [ - { - model_name: "gpt-4-sales", - model_info: { - id: "model-sales-1", - access_via_team_ids: [], - access_groups: ["sales-model-group"], - }, + 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"], - }, + }, + { + 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 }); + mockUseModelsInfo.mockReturnValue({ data: modelData, isLoading: false, error: null }); render(); @@ -236,30 +256,28 @@ describe("AllModelsTab", () => { }), ); - const modelData = { - data: [ - { - model_name: "gpt-4-personal", - model_info: { - id: "model-personal-1", - direct_access: true, - access_via_team_ids: [], - access_groups: [], - }, + 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: [], - }, + }, + { + 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 }); + mockUseModelsInfo.mockReturnValue({ data: modelData, isLoading: false, error: null }); render(); @@ -283,52 +301,145 @@ describe("AllModelsTab", () => { }), ); - const modelData = { - data: [ - { - 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", - }, + 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); + + mockUseModelsInfo.mockReturnValue({ data: modelData, isLoading: false, error: null }); + + render(); + + await waitFor(() => { + expect(screen.getByText("Config Model")).toBeInTheDocument(); + expect(screen.getByText("DB Model")).toBeInTheDocument(); + }); + }); + + it("should show 'Defined in config' for models defined in configs", async () => { + mockUseTeams.mockReturnValue({ + data: [], + isLoading: false, + error: null, + refetch: vi.fn(), + }); + + mockUseModelCostMap.mockReturnValueOnce( + createModelCostMapMock({ + "gpt-4-config": { litellm_provider: "openai" }, + }), + ); + + 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 }); + + render(); + + await waitFor(() => { + expect(screen.getByText("Defined in config")).toBeInTheDocument(); + }); + }); + + 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(), + }); + + 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-db", - litellm_model_name: "gpt-4-db", - provider: "openai", + model_name: "gpt-4-page1", model_info: { - id: "model-db-1", - db_model: true, + id: "model-page1-1", direct_access: true, access_via_team_ids: [], access_groups: [], - created_by: "user-123", - created_at: "2024-01-01", - updated_at: "2024-01-01", }, }, ], - }; + 2, // total_count + 1, // current_page + 2, // total_pages + 50, // size + ); - mockUseModelsInfo.mockReturnValue({ data: modelData }); + // Set up mock to return page1Data for page 1 + mockUseModelsInfo.mockImplementation((page: number = 1) => { + return { data: page1Data, isLoading: false, error: null }; + }); render(); await waitFor(() => { - expect(screen.getByText("Config Model")).toBeInTheDocument(); - expect(screen.getByText("DB Model")).toBeInTheDocument(); + expect(screen.getByText("Showing 1 - 1 of 1 results")).toBeInTheDocument(); }); + + // 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 show 'Defined in config' for models defined in configs", async () => { + it("should handle pagination: Next button is disabled on last page", async () => { mockUseTeams.mockReturnValue({ data: [], isLoading: false, @@ -336,38 +447,47 @@ describe("AllModelsTab", () => { refetch: vi.fn(), }); - mockUseModelCostMap.mockReturnValueOnce( + mockUseModelCostMap.mockReturnValue( createModelCostMapMock({ - "gpt-4-config": { litellm_provider: "openai" }, + "gpt-4-page2": { litellm_provider: "openai" }, }), ); - const modelData = { - data: [ + // Mock single page response (page 1 of 1 - last page) + const singlePageData = createPaginatedModelData( + [ { - model_name: "gpt-4-config", - litellm_model_name: "gpt-4-config", - provider: "openai", + model_name: "gpt-4-page2", model_info: { - id: "model-config-1", - db_model: false, + id: "model-page2-1", direct_access: true, access_via_team_ids: [], access_groups: [], - created_by: "user-123", - created_at: "2024-01-01", - updated_at: "2024-01-01", }, }, ], - }; + 1, // total_count + 1, // current_page + 1, // total_pages (only 1 page, so this is the last page) + 50, // size + ); - mockUseModelsInfo.mockReturnValue({ data: modelData }); + mockUseModelsInfo.mockImplementation(() => { + return { data: singlePageData, isLoading: false, error: null }; + }); render(); await waitFor(() => { - expect(screen.getByText("Defined in config")).toBeInTheDocument(); + 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(); }); }); 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 a85e6516585..04300b7fd16 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 @@ -31,11 +31,26 @@ const AllModelsTab = ({ setSelectedModelId, setSelectedTeamId, }: AllModelsTabProps) => { - const { data: rawModelData, isLoading: isLoadingModelsInfo } = useModelsInfo(); const { data: modelCostMapData, isLoading: isLoadingModelCostMap } = useModelCostMap(); const { userId, userRole, premiumUser } = useAuthorized(); const { data: teams } = useTeams(); + const [modelNameSearch, setModelNameSearch] = useState(""); + const [modelViewMode, setModelViewMode] = useState("current_team"); + const [currentTeam, setCurrentTeam] = useState("personal"); + const [showFilters, setShowFilters] = useState(false); + 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 { data: rawModelData, isLoading: isLoadingModelsInfo } = useModelsInfo(currentPage, pageSize); + const isLoading = isLoadingModelsInfo || isLoadingModelCostMap; + const getProviderFromModel = (model: string) => { if (modelCostMapData !== null && modelCostMapData !== undefined) { if (typeof modelCostMapData == "object" && model in modelCostMapData) { @@ -50,18 +65,23 @@ const AllModelsTab = ({ return transformModelData(rawModelData, getProviderFromModel); }, [rawModelData, modelCostMapData]); - const [modelNameSearch, setModelNameSearch] = useState(""); - const [modelViewMode, setModelViewMode] = useState("current_team"); - const [currentTeam, setCurrentTeam] = useState("personal"); - const [showFilters, setShowFilters] = useState(false); - const [selectedModelAccessGroupFilter, setSelectedModelAccessGroupFilter] = useState(null); - const [expandedRows, setExpandedRows] = useState>(new Set()); - const [pagination, setPagination] = useState({ - pageIndex: 0, - pageSize: 50, - }); - - const isLoading = isLoadingModelsInfo || isLoadingModelCostMap; + // 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(() => { if (!modelData || !modelData.data || modelData.data.length === 0) { @@ -114,6 +134,7 @@ const AllModelsTab = ({ setSelectedModelAccessGroupFilter(null); setCurrentTeam("personal"); setModelViewMode("current_team"); + setCurrentPage(1); setPagination({ pageIndex: 0, pageSize: 50 }); }; @@ -334,10 +355,7 @@ const AllModelsTab = ({ ) : ( {filteredData.length > 0 - ? `Showing ${pagination.pageIndex * pagination.pageSize + 1} - ${Math.min( - (pagination.pageIndex + 1) * pagination.pageSize, - filteredData.length, - )} of ${filteredData.length} results` + ? `Showing 1 - ${filteredData.length} of ${filteredData.length} results` : "Showing 0 results"} )} @@ -347,15 +365,16 @@ const AllModelsTab = ({ ) : ( @@ -365,15 +384,16 @@ const AllModelsTab = ({ ) : ( @@ -391,8 +411,8 @@ const AllModelsTab = ({ setSelectedModelId, setSelectedTeamId, getDisplayModelName, - () => {}, - () => {}, + () => { }, + () => { }, expandedRows, setExpandedRows, )} diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 1fe72996cfd..46561bc9e1e 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -2007,15 +2007,17 @@ export const regenerateKeyCall = async (accessToken: string, keyToRegenerate: st let ModelListerrorShown = false; let errorTimer: NodeJS.Timeout | null = null; -export const modelInfoCall = async (accessToken: string, userID: string, userRole: string) => { +export const modelInfoCall = async (accessToken: string, userID: string, userRole: string, page: number = 1, size: number = 50) => { /** * Get all models on proxy */ try { - console.log("modelInfoCall:", accessToken, userID, userRole); + console.log("modelInfoCall:", accessToken, userID, userRole, page, size); let url = proxyBaseUrl ? `${proxyBaseUrl}/v2/model/info` : `/v2/model/info`; const params = new URLSearchParams(); params.append("include_team_models", "true"); + params.append("page", page.toString()); + params.append("size", size.toString()); if (params.toString()) { url += `?${params.toString()}`; } From 0c5f40fffeb70388f15f4e28176139ab063e40aa Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Wed, 21 Jan 2026 11:54:26 -0800 Subject: [PATCH 2/2] fixing build --- .../models-and-endpoints/components/AllModelsTab.test.tsx | 1 - 1 file changed, 1 deletion(-) 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 f20c3563c83..8a2298361c5 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,6 +1,5 @@ import * as useAuthorizedModule from "@/app/(dashboard)/hooks/useAuthorized"; import { render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import AllModelsTab from "./AllModelsTab";