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..8f3d1712f69 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUserInfo.ts @@ -0,0 +1,25 @@ +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=. + * + * Companion to useCurrentUser (which self-looks-up the caller): pass the user + * you actually want to display. Disabled when userId is null so the caller can + * fall back to a global/unfiltered view without firing a request. Admins may + * query any user; the backend authorizes the lookup. + */ +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.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx index 691c5dc839a..bb5afc20459 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"; @@ -140,6 +141,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; + // Budget shown in the header tile must reflect the SELECTED user, not the + // logged-in admin (currentUser). Fetch the effective user's own record so the + // Max Budget tile and its reset period track "Filter by user". Falls back to + // currentUser only while the per-user fetch is unresolved or when no user is + // selected (global view has no single budget to show). + const { data: selectedUserInfo } = useUserInfo(effectiveUserId); + 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]); @@ -578,7 +589,8 @@ 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..73b05e89580 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_user_spend.test.tsx @@ -0,0 +1,36 @@ +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"; + +// ViewUserSpend fetches available models in an effect; stub the network call. +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(); + // 30d maps to "monthly" via getBudgetDurationLabel + 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(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/view_user_spend.tsx b/ui/litellm-dashboard/src/components/view_user_spend.tsx index 655fc092673..5773a7420bc 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; + // Optional reset period paired with userMaxBudget (e.g. "24h", "30d"), rendered + // as a human-readable window next to the cap. Omitted/null → no period shown. + budgetDuration?: string | null; } -const ViewUserSpend: React.FC = ({ userSpend, userMaxBudget, selectedTeam }) => { +const ViewUserSpend: React.FC = ({ + userSpend, + userMaxBudget, + selectedTeam, + budgetDuration = null, +}) => { const { accessToken, userRole, userId: userID } = useAuthorized(); let [spend, setSpend] = useState(userSpend !== null ? userSpend : 0.0); const [maxBudget, setMaxBudget] = useState( @@ -109,6 +118,11 @@ const ViewUserSpend: React.FC = ({ userSpend, userMaxBudget, const displayMaxBudget = maxBudget !== null ? `$${formatNumberWithCommas(Number(maxBudget), 4)} limit` : "No limit"; + // Show the reset window (e.g. "over monthly") only for a finite cap that has a + // paired duration; an unlimited budget or a cap with no duration shows nothing. + const durationLabel = maxBudget !== null && budgetDuration ? getBudgetDurationLabel(budgetDuration) : null; + const budgetPeriodSuffix = durationLabel && durationLabel !== "Not set" ? ` over ${durationLabel}` : ""; + const roundedSpend = spend !== undefined ? formatNumberWithCommas(spend, 4) : null; return ( @@ -120,7 +134,12 @@ const ViewUserSpend: React.FC = ({ userSpend, userMaxBudget,

Max Budget

-

{displayMaxBudget}

+

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

{/*