diff --git a/ui/litellm-dashboard/package-lock.json b/ui/litellm-dashboard/package-lock.json index 648503abb27..b8f6265441b 100644 --- a/ui/litellm-dashboard/package-lock.json +++ b/ui/litellm-dashboard/package-lock.json @@ -29,6 +29,7 @@ "next": "16.2.6", "openai": "4.104.0", "openapi-fetch": "^0.17.0", + "openapi-react-query": "^0.5.4", "papaparse": "5.5.3", "react": "18.3.1", "react-copy-to-clipboard": "5.1.1", @@ -10544,6 +10545,19 @@ "openapi-typescript-helpers": "^0.1.0" } }, + "node_modules/openapi-react-query": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/openapi-react-query/-/openapi-react-query-0.5.4.tgz", + "integrity": "sha512-V9lRiozjHot19/BYSgXYoyznDxDJQhEBSdi26+SJ0UqjMANLQhkni4XG+Z7e3Ag7X46ZLMrL9VxYkghU3QvbWg==", + "license": "MIT", + "dependencies": { + "openapi-typescript-helpers": "^0.1.0" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.80.0", + "openapi-fetch": "^0.17.0" + } + }, "node_modules/openapi-typescript": { "version": "7.13.0", "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz", diff --git a/ui/litellm-dashboard/package.json b/ui/litellm-dashboard/package.json index 40c495cebf1..a14349c7239 100644 --- a/ui/litellm-dashboard/package.json +++ b/ui/litellm-dashboard/package.json @@ -44,6 +44,7 @@ "next": "16.2.6", "openai": "4.104.0", "openapi-fetch": "^0.17.0", + "openapi-react-query": "^0.5.4", "papaparse": "5.5.3", "react": "18.3.1", "react-copy-to-clipboard": "5.1.1", diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/customers/useCustomers.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/customers/useCustomers.test.ts index 1e614b709e2..b09ab4498f7 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/customers/useCustomers.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/customers/useCustomers.test.ts @@ -1,12 +1,10 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { renderHook, waitFor } from "@testing-library/react"; -import React, { ReactNode } from "react"; +import { renderHook } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { useCustomers, type EndUser } from "./useCustomers"; -const mockGet = vi.fn(); +const useQueryMock = vi.fn(); vi.mock("@/lib/http/api", () => ({ - fetchClient: { GET: (...args: unknown[]) => mockGet(...args) }, + $api: { useQuery: (...args: unknown[]) => useQueryMock(...args) }, })); const mockUseAuthorized = vi.fn(); @@ -14,91 +12,55 @@ vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: () => mockUseAuthorized(), })); -const mockCustomers: EndUser[] = [ - { user_id: "customer-1", alias: "Test Customer 1", spend: 150.5, blocked: false }, - { user_id: "customer-2", alias: null, spend: 0, blocked: true }, -]; +const authorized = { accessToken: "test-access-token", userRole: "Admin" }; -const authorized = { - accessToken: "test-access-token", - userRole: "Admin", - userId: "test-user-id", - token: "test-token", - userEmail: "test@example.com", - premiumUser: false, - disabledPersonalKeyCreation: null, - showSSOBanner: false, +type QueryOptions = { enabled: boolean; select: (data: EndUser[] | undefined) => EndUser[] }; + +const lastCallOptions = (): QueryOptions => { + const calls = useQueryMock.mock.calls; + return calls[calls.length - 1][3] as QueryOptions; }; describe("useCustomers", () => { - let queryClient: QueryClient; - beforeEach(() => { - queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); vi.clearAllMocks(); + useQueryMock.mockReturnValue({ data: [] }); mockUseAuthorized.mockReturnValue(authorized); }); - const wrapper = ({ children }: { children: ReactNode }) => - React.createElement(QueryClientProvider, { client: queryClient }, children); - - it("fetches /customer/list and returns the typed list on success", async () => { - mockGet.mockResolvedValue({ data: mockCustomers }); - - const { result } = renderHook(() => useCustomers(), { wrapper }); - - expect(result.current.isLoading).toBe(true); - - await waitFor(() => { - expect(result.current.isSuccess).toBe(true); - }); - - expect(result.current.data).toEqual(mockCustomers); - expect(mockGet).toHaveBeenCalledWith("/customer/list"); - expect(mockGet).toHaveBeenCalledTimes(1); + it("queries GET /customer/list with a derived key (no hand-written queryKey)", () => { + renderHook(() => useCustomers()); + expect(useQueryMock).toHaveBeenCalledWith("get", "/customer/list", {}, expect.any(Object)); }); - it("surfaces an error when the request rejects", async () => { - const testError = new Error("Failed to fetch customers"); - mockGet.mockRejectedValue(testError); - - const { result } = renderHook(() => useCustomers(), { wrapper }); - - await waitFor(() => { - expect(result.current.isError).toBe(true); - }); - - expect(result.current.error).toEqual(testError); - expect(result.current.data).toBeUndefined(); + it("enables the query only for an admin holding an access token", () => { + renderHook(() => useCustomers()); + expect(lastCallOptions().enabled).toBe(true); }); - it("falls back to an empty list when the response has no body", async () => { - mockGet.mockResolvedValue({ data: undefined }); - - const { result } = renderHook(() => useCustomers(), { wrapper }); - - await waitFor(() => { - expect(result.current.isSuccess).toBe(true); - }); - - expect(result.current.data).toEqual([]); + it("disables the query when the access token is missing", () => { + mockUseAuthorized.mockReturnValue({ ...authorized, accessToken: null }); + renderHook(() => useCustomers()); + expect(lastCallOptions().enabled).toBe(false); }); - it("does not fetch when the access token is missing", () => { - mockUseAuthorized.mockReturnValue({ ...authorized, accessToken: null, token: null }); - - const { result } = renderHook(() => useCustomers(), { wrapper }); - - expect(result.current.isFetched).toBe(false); - expect(mockGet).not.toHaveBeenCalled(); - }); - - it("does not fetch when the user is not an admin", () => { + it("disables the query for a non-admin role", () => { mockUseAuthorized.mockReturnValue({ ...authorized, userRole: "member" }); + renderHook(() => useCustomers()); + expect(lastCallOptions().enabled).toBe(false); + }); - const { result } = renderHook(() => useCustomers(), { wrapper }); + it("selects an empty list when the response body is missing", () => { + renderHook(() => useCustomers()); + expect(lastCallOptions().select(undefined)).toEqual([]); + }); - expect(result.current.isFetched).toBe(false); - expect(mockGet).not.toHaveBeenCalled(); + it("selects the customer list through unchanged", () => { + const customers: EndUser[] = [ + { user_id: "customer-1", alias: "Test Customer 1", spend: 150.5, blocked: false }, + { user_id: "customer-2", alias: null, spend: 0, blocked: true }, + ]; + renderHook(() => useCustomers()); + expect(lastCallOptions().select(customers)).toEqual(customers); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/customers/useCustomers.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/customers/useCustomers.ts index 25e2e3f5e90..ebea4618b7f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/customers/useCustomers.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/customers/useCustomers.ts @@ -1,19 +1,19 @@ -import { useQuery } from "@tanstack/react-query"; -import { createQueryKeys } from "../common/queryKeysFactory"; -import { fetchClient } from "@/lib/http/api"; +import { $api } from "@/lib/http/api"; import { all_admin_roles } from "@/utils/roles"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import type { components } from "@/lib/http/schema"; export type EndUser = components["schemas"]["CustomerResponse"]; -const customersKeys = createQueryKeys("customers"); - export const useCustomers = () => { const { accessToken, userRole } = useAuthorized(); - return useQuery({ - queryKey: customersKeys.list({}), - queryFn: async () => (await fetchClient.GET("/customer/list")).data ?? [], - enabled: Boolean(accessToken) && all_admin_roles.includes(userRole!), - }); + return $api.useQuery( + "get", + "/customer/list", + {}, + { + enabled: Boolean(accessToken) && all_admin_roles.includes(userRole!), + select: (data) => data ?? [], + }, + ); }; diff --git a/ui/litellm-dashboard/src/lib/http/api.ts b/ui/litellm-dashboard/src/lib/http/api.ts index 2e8e1d36c5b..aa6c2d6c0fd 100644 --- a/ui/litellm-dashboard/src/lib/http/api.ts +++ b/ui/litellm-dashboard/src/lib/http/api.ts @@ -1,4 +1,5 @@ import createFetchClient, { type Middleware } from "openapi-fetch"; +import createQueryClient from "openapi-react-query"; import type { paths } from "./schema"; import { ApiError, deriveErrorMessage } from "./client"; import { getAuthHeaderName, getAuthToken, getRequestBaseUrl, reportError } from "./runtime"; @@ -46,3 +47,11 @@ const middleware: Middleware = { */ export const fetchClient = createFetchClient({ baseUrl: globalThis.location?.origin ?? "" }); fetchClient.use(middleware); + +/** + * TanStack Query bound to the typed client. Callers write + * `$api.useQuery("get", "/path", init, options)`; the query key is derived from + * method + path + init (no hand-maintained key), the request signal is + * forwarded for cancellation, and the response type comes from schema.d.ts. + */ +export const $api = createQueryClient(fetchClient);