mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-03 02:22:24 +00:00
Merge 22d99e23e4 into b94f5bdbed
This commit is contained in:
commit
673c46b2b7
6 changed files with 264 additions and 13 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<UserInfoV2Response> => {
|
||||
const { accessToken } = useAuthorized();
|
||||
return useQuery<UserInfoV2Response>({
|
||||
queryKey: userKeys.detail(userId ?? ""),
|
||||
queryFn: async () => {
|
||||
return await userGetInfoV2(accessToken!, userId!);
|
||||
},
|
||||
enabled: Boolean(accessToken && userId),
|
||||
});
|
||||
};
|
||||
|
|
@ -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: () => <div>View User Spend</div>,
|
||||
default: ({ userMaxBudget, budgetDuration, budgetLoading }: any) => (
|
||||
<div
|
||||
data-testid="view-user-spend"
|
||||
data-max-budget={userMaxBudget === null || userMaxBudget === undefined ? "null" : String(userMaxBudget)}
|
||||
data-budget-duration={budgetDuration ?? "null"}
|
||||
data-budget-loading={budgetLoading ? "true" : "false"}
|
||||
>
|
||||
View User Spend
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
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(<UsagePage {...defaultProps} />);
|
||||
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(<UsagePage {...defaultProps} />);
|
||||
await waitFor(() => expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled());
|
||||
|
||||
await selectAlice();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("view-user-spend")).toHaveAttribute("data-budget-loading", "true");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<UsagePageProps> = ({ 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<UsagePageProps> = ({ teams, organizations }) => {
|
|||
<ViewUserSpend
|
||||
userSpend={totalSpend}
|
||||
selectedTeam={null}
|
||||
userMaxBudget={currentUser?.max_budget || null}
|
||||
userMaxBudget={effectiveMaxBudget}
|
||||
budgetDuration={effectiveBudgetDuration}
|
||||
budgetLoading={!isSelectedUserResolved}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
40
ui/litellm-dashboard/src/components/view_user_spend.test.tsx
Normal file
40
ui/litellm-dashboard/src/components/view_user_spend.test.tsx
Normal file
|
|
@ -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(<ViewUserSpend userSpend={10} userMaxBudget={600} selectedTeam={null} budgetDuration="30d" />);
|
||||
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(<ViewUserSpend userSpend={10} userMaxBudget={null} selectedTeam={null} budgetDuration="30d" />);
|
||||
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(<ViewUserSpend userSpend={10} userMaxBudget={300} selectedTeam={null} />);
|
||||
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(<ViewUserSpend userSpend={10} userMaxBudget={null} selectedTeam={null} budgetLoading={true} />);
|
||||
expect(screen.queryByText("No limit")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("—")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<ViewUserSpendProps> = ({ userSpend, userMaxBudget, selectedTeam }) => {
|
||||
const ViewUserSpend: React.FC<ViewUserSpendProps> = ({
|
||||
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<ViewUserSpendProps> = ({ 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<ViewUserSpendProps> = ({ userSpend, userMaxBudget,
|
|||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Max Budget</p>
|
||||
<p className="text-2xl font-semibold text-foreground">{displayMaxBudget}</p>
|
||||
<p className="text-2xl font-semibold text-foreground">
|
||||
{displayMaxBudget}
|
||||
{budgetPeriodSuffix && (
|
||||
<span className="text-sm font-normal text-muted-foreground">{budgetPeriodSuffix}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* <div className="ml-auto">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue