diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUserInfo.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUserInfo.test.ts new file mode 100644 index 00000000000..47ee1a124a0 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUserInfo.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import React, { ReactNode } from "react"; +import { useUserInfo } from "./useUserInfo"; +import { userGetInfoV2 } from "@/components/networking"; +import type { UserInfoV2Response } from "@/components/networking"; + +vi.mock("@/components/networking", () => ({ + userGetInfoV2: vi.fn(), +})); + +vi.mock("../common/queryKeysFactory", () => ({ + createQueryKeys: vi.fn((resource: string) => ({ + all: [resource], + lists: () => [resource, "list"], + list: (params?: any) => [resource, "list", { params }], + details: () => [resource, "detail"], + detail: (uid: string) => [resource, "detail", uid], + })), +})); + +const mockUseAuthorized = vi.fn(); +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: () => mockUseAuthorized(), +})); + +const mockSelectedUser: UserInfoV2Response = { + user_id: "selected-user-id", + user_email: "selected@example.com", + user_alias: "Selected User", + user_role: "internal_user", + spend: 42.0, + max_budget: 600.0, + models: [], + budget_duration: "30d", + budget_reset_at: null, + metadata: null, + created_at: null, + updated_at: null, + sso_user_id: null, + teams: [], +}; + +describe("useUserInfo", () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + vi.clearAllMocks(); + mockUseAuthorized.mockReturnValue({ + accessToken: "test-access-token", + userId: "admin-user-id", + userRole: "Admin", + }); + }); + + const wrapper = ({ children }: { children: ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + + it("fetches the specified user's info by user_id (not the caller's)", async () => { + (userGetInfoV2 as any).mockResolvedValue(mockSelectedUser); + + const { result } = renderHook(() => useUserInfo("selected-user-id"), { wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data).toEqual(mockSelectedUser); + // The whole point of the fix: the target user_id is passed through. + expect(userGetInfoV2).toHaveBeenCalledWith("test-access-token", "selected-user-id"); + expect(userGetInfoV2).toHaveBeenCalledTimes(1); + }); + + it("does not execute when userId is null (global view has no single user)", async () => { + const { result } = renderHook(() => useUserInfo(null), { wrapper }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toBeUndefined(); + expect(result.current.isFetched).toBe(false); + expect(userGetInfoV2).not.toHaveBeenCalled(); + }); + + it("does not execute when accessToken is missing", async () => { + mockUseAuthorized.mockReturnValue({ accessToken: null, userId: "admin-user-id", userRole: "Admin" }); + + const { result } = renderHook(() => useUserInfo("selected-user-id"), { wrapper }); + + expect(result.current.isFetched).toBe(false); + expect(userGetInfoV2).not.toHaveBeenCalled(); + }); + + it("surfaces the error when the lookup fails", async () => { + const err = new Error("forbidden"); + (userGetInfoV2 as any).mockRejectedValue(err); + + const { result } = renderHook(() => useUserInfo("selected-user-id"), { wrapper }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.error).toEqual(err); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUserInfo.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUserInfo.ts new file mode 100644 index 00000000000..e17265e34a0 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUserInfo.ts @@ -0,0 +1,18 @@ +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { UserInfoV2Response, userGetInfoV2 } from "@/components/networking"; +import { useQuery, UseQueryResult } from "@tanstack/react-query"; +import { createQueryKeys } from "../common/queryKeysFactory"; + +const userKeys = createQueryKeys("users"); + +/** Fetch a specific user's info from `/v2/user/info?user_id=`; disabled when userId is null. */ +export const useUserInfo = (userId: string | null): UseQueryResult => { + const { accessToken } = useAuthorized(); + return useQuery({ + queryKey: userKeys.detail(userId ?? ""), + queryFn: async () => { + return await userGetInfoV2(accessToken!, userId!); + }, + enabled: Boolean(accessToken && userId), + }); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx index 7702488f5bf..50bfd53a27b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx @@ -4,6 +4,7 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import useIsOrgAdmin from "@/app/(dashboard)/hooks/useIsOrgAdmin"; import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser"; import { useInfiniteUsers } from "@/app/(dashboard)/hooks/users/useUsers"; +import { useUserInfo } from "@/app/(dashboard)/hooks/users/useUserInfo"; import { act, fireEvent, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; @@ -40,7 +41,20 @@ vi.mock("@/components/activity_metrics", () => ({ })); vi.mock("@/components/view_user_spend", () => ({ - default: () =>
View User Spend
, + default: ({ userMaxBudget, budgetDuration, budgetLoading }: any) => ( +
+ View User Spend +
+ ), +})); + +vi.mock("@/app/(dashboard)/hooks/users/useUserInfo", () => ({ + useUserInfo: vi.fn(), })); vi.mock("@/components/UsagePage/components/EntityUsage/TopKeyView", () => ({ @@ -166,6 +180,7 @@ describe("UsagePage", () => { const mockUseAuthorized = vi.mocked(useAuthorized); const mockUseCurrentUser = vi.mocked(useCurrentUser); const mockUseInfiniteUsers = vi.mocked(useInfiniteUsers); + const mockUseUserInfo = vi.mocked(useUserInfo); const mockSpendData = { results: [ @@ -411,6 +426,11 @@ describe("UsagePage", () => { isLoading: false, error: null, } as any); + mockUseUserInfo.mockReturnValue({ + data: undefined, + isLoading: false, + isError: false, + } as any); }); it("should render and fetch usage data on mount", async () => { @@ -1350,19 +1370,53 @@ describe("UsagePage", () => { }); }); - describe("tab navigation in global view", () => { - it("should render all expected tabs", async () => { + describe("selected-user budget wiring", () => { + const selectAlice = async () => { + const user = userEvent.setup(); + await user.click(screen.getByPlaceholderText("Search users by email…")); + await user.click(await screen.findByText("Alice (user-001)")); + }; + + it("feeds the tile the selected user's budget and duration, not the admin's", async () => { + mockUseCurrentUser.mockReturnValue({ + data: { user_id: "user-123", max_budget: 3000 }, + isLoading: false, + error: null, + } as any); + mockUseUserInfo.mockReturnValue({ + data: { user_id: "user-001", max_budget: 600, budget_duration: "30d" }, + isLoading: false, + isError: false, + } as any); + renderWithProviders(); + await waitFor(() => expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled()); + + await selectAlice(); await waitFor(() => { - expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); + expect(screen.getByTestId("view-user-spend")).toHaveAttribute("data-max-budget", "600"); }); + expect(screen.getByTestId("view-user-spend")).toHaveAttribute("data-budget-duration", "30d"); + expect(mockUseUserInfo).toHaveBeenCalledWith("user-001"); + }); - expect(screen.getByText("Cost")).toBeInTheDocument(); - expect(screen.getByText("Model Activity")).toBeInTheDocument(); - expect(screen.getByText("Key Activity")).toBeInTheDocument(); - expect(screen.getByText("MCP Server Activity")).toBeInTheDocument(); - expect(screen.getByText("Endpoint Activity")).toBeInTheDocument(); + it("marks the tile loading (not unlimited) while the selected user's info is unresolved", async () => { + mockUseCurrentUser.mockReturnValue({ + data: { user_id: "user-123", max_budget: 3000 }, + isLoading: false, + error: null, + } as any); + mockUseUserInfo.mockReturnValue({ data: undefined, isLoading: true, isError: false } as any); + + renderWithProviders(); + await waitFor(() => expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled()); + + await selectAlice(); + + await waitFor(() => { + expect(screen.getByTestId("view-user-spend")).toHaveAttribute("data-budget-loading", "true"); + }); }); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx index 4c5db0def62..0b81dfd0d33 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx @@ -23,6 +23,7 @@ import { useCustomers } from "@/app/(dashboard)/hooks/customers/useCustomers"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import useIsOrgAdmin from "@/app/(dashboard)/hooks/useIsOrgAdmin"; import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser"; +import { useUserInfo } from "@/app/(dashboard)/hooks/users/useUserInfo"; import { hasCapability } from "@/utils/capabilities"; import { formatNumberWithCommas } from "@/utils/dataUtils"; import { all_admin_roles, internalUserRoles } from "@/utils/roles"; @@ -141,6 +142,16 @@ const UsagePage: React.FC = ({ teams, organizations }) => { // For non-admins or "my-usage" view, always pass their own user_id const effectiveUserId = usageView === "my-usage" || !isAdmin ? userID || null : selectedUserId; + const { + data: selectedUserInfo, + isLoading: isSelectedUserLoading, + isError: isSelectedUserError, + } = useUserInfo(effectiveUserId); + const isSelectedUserResolved = effectiveUserId === null || (!isSelectedUserLoading && !isSelectedUserError); + const effectiveMaxBudget = + effectiveUserId !== null ? selectedUserInfo?.max_budget ?? null : currentUser?.max_budget ?? null; + const effectiveBudgetDuration = effectiveUserId !== null ? selectedUserInfo?.budget_duration ?? null : null; + const startTime = useMemo(() => (dateValue.from ? new Date(dateValue.from) : null), [dateValue.from]); const endTime = useMemo(() => (dateValue.to ? new Date(dateValue.to) : null), [dateValue.to]); @@ -550,7 +561,9 @@ const UsagePage: React.FC = ({ teams, organizations }) => { diff --git a/ui/litellm-dashboard/src/components/view_user_spend.test.tsx b/ui/litellm-dashboard/src/components/view_user_spend.test.tsx new file mode 100644 index 00000000000..42aff37bc60 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_user_spend.test.tsx @@ -0,0 +1,40 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import React from "react"; +import ViewUserSpend from "./view_user_spend"; + +vi.mock("./networking", () => ({ + modelAvailableCall: vi.fn().mockResolvedValue({ data: [] }), +})); + +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: () => ({ accessToken: "tok", userRole: "Admin", userId: "admin-id" }), +})); + +describe("ViewUserSpend — Max Budget tile", () => { + beforeEach(() => vi.clearAllMocks()); + + it("shows a finite cap with its reset period when budgetDuration is set", () => { + render(); + expect(screen.getByText(/\$600\.0000 limit/)).toBeInTheDocument(); + expect(screen.getByText(/over monthly/)).toBeInTheDocument(); + }); + + it('shows "No limit" for an unlimited (null) budget and no period', () => { + render(); + expect(screen.getByText("No limit")).toBeInTheDocument(); + expect(screen.queryByText(/over/)).not.toBeInTheDocument(); + }); + + it("shows a finite cap with no period when budgetDuration is absent", () => { + render(); + expect(screen.getByText(/\$300\.0000 limit/)).toBeInTheDocument(); + expect(screen.queryByText(/over/)).not.toBeInTheDocument(); + }); + + it('does not render "No limit" while the budget is loading', () => { + render(); + expect(screen.queryByText("No limit")).not.toBeInTheDocument(); + expect(screen.getByText("—")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/view_user_spend.tsx b/ui/litellm-dashboard/src/components/view_user_spend.tsx index 655fc092673..31581a2d093 100644 --- a/ui/litellm-dashboard/src/components/view_user_spend.tsx +++ b/ui/litellm-dashboard/src/components/view_user_spend.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useState } from "react"; import { modelAvailableCall } from "./networking"; import { formatNumberWithCommas } from "@/utils/dataUtils"; +import { getBudgetDurationLabel } from "./common_components/budget_duration_dropdown"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; // Define the props type @@ -9,8 +10,16 @@ interface ViewUserSpendProps { userSpend: number | null; userMaxBudget: number | null; selectedTeam: any | null; + budgetDuration?: string | null; + budgetLoading?: boolean; } -const ViewUserSpend: React.FC = ({ userSpend, userMaxBudget, selectedTeam }) => { +const ViewUserSpend: React.FC = ({ + userSpend, + userMaxBudget, + selectedTeam, + budgetDuration = null, + budgetLoading = false, +}) => { const { accessToken, userRole, userId: userID } = useAuthorized(); let [spend, setSpend] = useState(userSpend !== null ? userSpend : 0.0); const [maxBudget, setMaxBudget] = useState( @@ -107,7 +116,18 @@ const ViewUserSpend: React.FC = ({ userSpend, userMaxBudget, modelsToDisplay = userModels; } - const displayMaxBudget = maxBudget !== null ? `$${formatNumberWithCommas(Number(maxBudget), 4)} limit` : "No limit"; + let displayMaxBudget: string; + if (budgetLoading) { + displayMaxBudget = "—"; + } else if (maxBudget !== null) { + displayMaxBudget = `$${formatNumberWithCommas(Number(maxBudget), 4)} limit`; + } else { + displayMaxBudget = "No limit"; + } + + const durationLabel = maxBudget !== null && budgetDuration ? getBudgetDurationLabel(budgetDuration) : null; + const budgetPeriodSuffix = + !budgetLoading && durationLabel && durationLabel !== "Not set" ? ` over ${durationLabel}` : ""; const roundedSpend = spend !== undefined ? formatNumberWithCommas(spend, 4) : null; @@ -120,7 +140,12 @@ const ViewUserSpend: React.FC = ({ userSpend, userMaxBudget,

Max Budget

-

{displayMaxBudget}

+

+ {displayMaxBudget} + {budgetPeriodSuffix && ( + {budgetPeriodSuffix} + )} +

{/*