allow filtering by user in global usage

This commit is contained in:
yuneng-jiang 2026-02-16 16:46:09 -08:00
parent d17bf84f84
commit 0d2aac6928
7 changed files with 1154 additions and 34 deletions

View file

@ -1911,6 +1911,10 @@ async def get_user_daily_activity(
default=None,
description="Filter by specific API key",
),
user_id: Optional[str] = fastapi.Query(
default=None,
description="Filter by specific user ID. Admins can filter by any user or omit for global view. Non-admins must provide their own user_id.",
),
page: int = fastapi.Query(
default=1, description="Page number for pagination", ge=1
),
@ -1955,9 +1959,26 @@ async def get_user_daily_activity(
)
try:
entity_id: Optional[str] = None
if not _user_has_admin_view(user_api_key_dict):
entity_id = user_api_key_dict.user_id
is_admin = _user_has_admin_view(user_api_key_dict)
if is_admin:
entity_id = user_id # None means global view, otherwise filter by user
else:
if user_id is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"error": "Non-admin users must provide a user_id. Global spend view is restricted to admins."
},
)
if user_id != user_api_key_dict.user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"error": "Non-admin users can only view their own spend data."
},
)
entity_id = user_id
return await get_daily_activity(
prisma_client=prisma_client,
@ -2008,6 +2029,10 @@ async def get_user_daily_activity_aggregated(
default=None,
description="Filter by specific API key",
),
user_id: Optional[str] = fastapi.Query(
default=None,
description="Filter by specific user ID. Admins can filter by any user or omit for global view. Non-admins must provide their own user_id.",
),
timezone: Optional[int] = fastapi.Query(
default=None,
description="Timezone offset in minutes from UTC (e.g., 480 for PST). "
@ -2034,9 +2059,26 @@ async def get_user_daily_activity_aggregated(
)
try:
entity_id: Optional[str] = None
if not _user_has_admin_view(user_api_key_dict):
entity_id = user_api_key_dict.user_id
is_admin = _user_has_admin_view(user_api_key_dict)
if is_admin:
entity_id = user_id # None means global view, otherwise filter by user
else:
if user_id is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"error": "Non-admin users must provide a user_id. Global spend view is restricted to admins."
},
)
if user_id != user_api_key_dict.user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"error": "Non-admin users can only view their own spend data."
},
)
entity_id = user_id
return await get_daily_activity_aggregated(
prisma_client=prisma_client,

View file

@ -1167,4 +1167,129 @@ def test_generate_request_base_validator():
# Test with None
req = GenerateRequestBase(max_budget=None)
assert req.max_budget is None
assert req.max_budget is None
@pytest.mark.asyncio
async def test_get_user_daily_activity_non_admin_cannot_view_other_users(monkeypatch):
"""
Test that non-admin users cannot view another user's daily activity data.
The endpoint should raise 403 when user_id does not match the caller's own user_id.
Also verifies that omitting user_id entirely is forbidden for non-admins.
"""
from unittest.mock import MagicMock
from fastapi import HTTPException
from litellm.proxy.management_endpoints.internal_user_endpoints import (
get_user_daily_activity,
)
# Mock the prisma client so the DB-not-connected check passes
mock_prisma_client = MagicMock()
monkeypatch.setattr(
"litellm.proxy.proxy_server.prisma_client", mock_prisma_client
)
# Non-admin caller
non_admin_key_dict = UserAPIKeyAuth(
user_id="regular-user-123",
user_role=LitellmUserRoles.INTERNAL_USER,
)
# Case 1: Non-admin tries to view a different user's data
# The inner 403 HTTPException is caught by the outer except block and
# re-raised as a 500, but the original message is preserved in the detail.
with pytest.raises(HTTPException) as exc_info:
await get_user_daily_activity(
start_date="2025-01-01",
end_date="2025-01-31",
model=None,
api_key=None,
user_id="other-user-456",
page=1,
page_size=50,
timezone=None,
user_api_key_dict=non_admin_key_dict,
)
assert "Non-admin users can only view their own spend data" in str(
exc_info.value.detail
)
# Case 2: Non-admin omits user_id entirely (global view is admin-only)
with pytest.raises(HTTPException) as exc_info:
await get_user_daily_activity(
start_date="2025-01-01",
end_date="2025-01-31",
model=None,
api_key=None,
user_id=None,
page=1,
page_size=50,
timezone=None,
user_api_key_dict=non_admin_key_dict,
)
assert "Non-admin users must provide a user_id" in str(exc_info.value.detail)
@pytest.mark.asyncio
async def test_get_user_daily_activity_aggregated_admin_global_view(monkeypatch):
"""
Test that admin users can call the aggregated endpoint without a user_id
to get a global view. Also verifies that the correct arguments are forwarded
to the underlying get_daily_activity_aggregated helper.
"""
from unittest.mock import AsyncMock, MagicMock
from litellm.proxy.management_endpoints.internal_user_endpoints import (
get_user_daily_activity_aggregated,
)
# Mock the prisma client
mock_prisma_client = MagicMock()
monkeypatch.setattr(
"litellm.proxy.proxy_server.prisma_client", mock_prisma_client
)
# Mock the downstream helper so we don't need a real DB
mock_response = MagicMock()
mock_get_daily_agg = AsyncMock(return_value=mock_response)
monkeypatch.setattr(
"litellm.proxy.management_endpoints.internal_user_endpoints.get_daily_activity_aggregated",
mock_get_daily_agg,
)
# Admin caller
admin_key_dict = UserAPIKeyAuth(
user_id="admin-user-001",
user_role=LitellmUserRoles.PROXY_ADMIN,
)
# Admin calls without user_id → global view (entity_id=None)
result = await get_user_daily_activity_aggregated(
start_date="2025-02-01",
end_date="2025-02-28",
model="gpt-4",
api_key=None,
user_id=None,
timezone=480,
user_api_key_dict=admin_key_dict,
)
assert result is mock_response
# Verify the helper was called with the right parameters
mock_get_daily_agg.assert_called_once_with(
prisma_client=mock_prisma_client,
table_name="litellm_dailyuserspend",
entity_id_field="user_id",
entity_id=None, # global view: no user_id filter
entity_metadata_field=None,
start_date="2025-02-01",
end_date="2025-02-28",
model="gpt-4",
api_key=None,
timezone_offset_minutes=480,
)

View file

@ -0,0 +1,339 @@
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 { useInfiniteUsers } from "./useUsers";
import { userListCall } from "@/components/networking";
import type { UserListResponse } from "@/components/networking";
vi.mock("@/components/networking", () => ({
userListCall: 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 DEFAULT_AUTH = {
accessToken: "test-access-token",
userId: "test-user-id",
userRole: "Admin",
token: "test-token",
userEmail: "test@example.com",
premiumUser: false,
disabledPersonalKeyCreation: null,
showSSOBanner: false,
};
const buildUserListResponse = (
page: number,
totalPages: number,
userCount = 2,
): UserListResponse => ({
page,
page_size: 50,
total: totalPages * userCount,
total_pages: totalPages,
users: Array.from({ length: userCount }, (_, i) => ({
user_id: `user-${page}-${i}`,
user_email: `user-${page}-${i}@example.com`,
user_alias: null,
user_role: "Internal User",
spend: 0,
max_budget: null,
key_count: 0,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
sso_user_id: null,
budget_duration: null,
})),
});
describe("useInfiniteUsers", () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
vi.clearAllMocks();
mockUseAuthorized.mockReturnValue(DEFAULT_AUTH);
});
const wrapper = ({ children }: { children: ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
it("should return paginated user data when query is successful", async () => {
const mockResponse = buildUserListResponse(1, 2);
(userListCall as any).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useInfiniteUsers(), { wrapper });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.pages).toHaveLength(1);
expect(result.current.data?.pages[0]).toEqual(mockResponse);
expect(userListCall).toHaveBeenCalledWith(
"test-access-token",
null,
1,
50,
null,
);
});
it("should use the default page size of 50", async () => {
const mockResponse = buildUserListResponse(1, 1);
(userListCall as any).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useInfiniteUsers(), { wrapper });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(userListCall).toHaveBeenCalledWith(
"test-access-token",
null,
1,
50,
null,
);
});
it("should use a custom page size when provided", async () => {
const customPageSize = 25;
const mockResponse = buildUserListResponse(1, 1, 5);
(userListCall as any).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useInfiniteUsers(customPageSize), {
wrapper,
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(userListCall).toHaveBeenCalledWith(
"test-access-token",
null,
1,
customPageSize,
null,
);
});
it("should pass searchEmail to userListCall when provided", async () => {
const searchEmail = "search@example.com";
const mockResponse = buildUserListResponse(1, 1, 1);
(userListCall as any).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useInfiniteUsers(50, searchEmail), {
wrapper,
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(userListCall).toHaveBeenCalledWith(
"test-access-token",
null,
1,
50,
searchEmail,
);
});
it("should pass null for searchEmail when not provided", async () => {
const mockResponse = buildUserListResponse(1, 1);
(userListCall as any).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useInfiniteUsers(50, undefined), {
wrapper,
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(userListCall).toHaveBeenCalledWith(
"test-access-token",
null,
1,
50,
null,
);
});
it("should fetch the next page when more pages are available", async () => {
const page1 = buildUserListResponse(1, 3);
const page2 = buildUserListResponse(2, 3);
let callCount = 0;
(userListCall as any).mockImplementation(async () => {
callCount++;
return callCount === 1 ? page1 : page2;
});
const { result } = renderHook(() => useInfiniteUsers(), { wrapper });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.hasNextPage).toBe(true);
result.current.fetchNextPage();
await waitFor(() => {
expect(result.current.isFetchingNextPage).toBe(false);
expect(result.current.data?.pages).toHaveLength(2);
});
expect(result.current.data?.pages[1]).toEqual(page2);
expect(userListCall).toHaveBeenCalledTimes(2);
expect(userListCall).toHaveBeenLastCalledWith(
"test-access-token",
null,
2,
50,
null,
);
});
it("should not have a next page when on the last page", async () => {
const lastPage = buildUserListResponse(2, 2);
(userListCall as any).mockResolvedValue(lastPage);
const { result } = renderHook(() => useInfiniteUsers(), { wrapper });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.hasNextPage).toBe(false);
});
it("should not execute query when accessToken is missing", async () => {
mockUseAuthorized.mockReturnValue({
...DEFAULT_AUTH,
accessToken: null,
});
const { result } = renderHook(() => useInfiniteUsers(), { wrapper });
expect(result.current.isLoading).toBe(false);
expect(result.current.data).toBeUndefined();
expect(result.current.isFetched).toBe(false);
expect(userListCall).not.toHaveBeenCalled();
});
it("should not execute query when userRole is not an admin role", async () => {
mockUseAuthorized.mockReturnValue({
...DEFAULT_AUTH,
userRole: "Internal User",
});
const { result } = renderHook(() => useInfiniteUsers(), { wrapper });
expect(result.current.isLoading).toBe(false);
expect(result.current.data).toBeUndefined();
expect(result.current.isFetched).toBe(false);
expect(userListCall).not.toHaveBeenCalled();
});
it("should not execute query when both accessToken and userRole are invalid", async () => {
mockUseAuthorized.mockReturnValue({
...DEFAULT_AUTH,
accessToken: null,
userRole: "App User",
});
const { result } = renderHook(() => useInfiniteUsers(), { wrapper });
expect(result.current.isLoading).toBe(false);
expect(result.current.data).toBeUndefined();
expect(result.current.isFetched).toBe(false);
expect(userListCall).not.toHaveBeenCalled();
});
it("should execute query for each admin role", async () => {
const adminRoles = [
"Admin",
"Admin Viewer",
"proxy_admin",
"proxy_admin_viewer",
"org_admin",
];
for (const role of adminRoles) {
vi.clearAllMocks();
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const mockResponse = buildUserListResponse(1, 1);
(userListCall as any).mockResolvedValue(mockResponse);
mockUseAuthorized.mockReturnValue({ ...DEFAULT_AUTH, userRole: role });
const { result } = renderHook(() => useInfiniteUsers(), { wrapper });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(userListCall).toHaveBeenCalledTimes(1);
}
});
it("should handle error when userListCall fails", async () => {
const testError = new Error("Failed to fetch users");
(userListCall as any).mockRejectedValue(testError);
const { result } = renderHook(() => useInfiniteUsers(), { wrapper });
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toEqual(testError);
expect(result.current.data).toBeUndefined();
});
it("should pass empty string searchEmail as null", async () => {
const mockResponse = buildUserListResponse(1, 1);
(userListCall as any).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useInfiniteUsers(50, ""), {
wrapper,
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(userListCall).toHaveBeenCalledWith(
"test-access-token",
null,
1,
50,
null,
);
});
});

View file

@ -0,0 +1,41 @@
import { userListCall, UserListResponse } from "@/components/networking";
import { useInfiniteQuery } from "@tanstack/react-query";
import { createQueryKeys } from "../common/queryKeysFactory";
import { all_admin_roles } from "@/utils/roles";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
const infiniteUsersKeys = createQueryKeys("infiniteUsers");
const DEFAULT_PAGE_SIZE = 50;
export const useInfiniteUsers = (
pageSize: number = DEFAULT_PAGE_SIZE,
searchEmail?: string,
) => {
const { accessToken, userRole } = useAuthorized();
return useInfiniteQuery<UserListResponse>({
queryKey: infiniteUsersKeys.list({
filters: {
pageSize,
...(searchEmail && { searchEmail }),
},
}),
queryFn: async ({ pageParam }) => {
return await userListCall(
accessToken!,
null, // userIDs
pageParam as number, // page
pageSize, // page_size
searchEmail || null, // userEmail
);
},
initialPageParam: 1,
getNextPageParam: (lastPage) => {
if (lastPage.page < lastPage.total_pages) {
return lastPage.page + 1;
}
return undefined;
},
enabled: Boolean(accessToken) && all_admin_roles.includes(userRole!),
});
};

View file

@ -2,6 +2,7 @@ import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents";
import { useCustomers } from "@/app/(dashboard)/hooks/customers/useCustomers";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser";
import { useInfiniteUsers } from "@/app/(dashboard)/hooks/users/useUsers";
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../../../../tests/test-utils";
@ -116,6 +117,10 @@ vi.mock("@/app/(dashboard)/hooks/users/useCurrentUser", () => ({
useCurrentUser: vi.fn(),
}));
vi.mock("@/app/(dashboard)/hooks/users/useUsers", () => ({
useInfiniteUsers: vi.fn(),
}));
vi.mock("antd", async (importOriginal) => {
const React = await import("react");
const actual = await importOriginal<typeof import("antd")>();
@ -223,6 +228,10 @@ vi.mock("@ant-design/icons", async () => {
return React.createElement("span");
}
function LoadingOutlined(props: any) {
return React.createElement("span", { "data-testid": "loading-icon", ...props });
}
return {
GlobalOutlined: Icon,
BankOutlined: Icon,
@ -235,6 +244,8 @@ vi.mock("@ant-design/icons", async () => {
ClockCircleOutlined: Icon,
CalendarOutlined: Icon,
InfoCircleOutlined: Icon,
UserOutlined: Icon,
LoadingOutlined,
};
});
@ -320,11 +331,13 @@ vi.mock("@tremor/react", async () => {
describe("UsagePage", () => {
const mockUserDailyActivityAggregatedCall = vi.mocked(networking.userDailyActivityAggregatedCall);
const mockUserDailyActivityCall = vi.mocked(networking.userDailyActivityCall);
const mockTagListCall = vi.mocked(networking.tagListCall);
const mockUseCustomers = vi.mocked(useCustomers);
const mockUseAgents = vi.mocked(useAgents);
const mockUseAuthorized = vi.mocked(useAuthorized);
const mockUseCurrentUser = vi.mocked(useCurrentUser);
const mockUseInfiniteUsers = vi.mocked(useInfiniteUsers);
const mockSpendData = {
results: [
@ -487,6 +500,8 @@ describe("UsagePage", () => {
beforeEach(() => {
mockUseAuthorized.mockReturnValue({
isLoading: false,
isAuthorized: true,
token: "mock-token",
accessToken: "test-token",
userId: "user-123",
@ -505,8 +520,30 @@ describe("UsagePage", () => {
error: null,
} as any);
mockUserDailyActivityAggregatedCall.mockClear();
mockUserDailyActivityCall.mockClear();
mockTagListCall.mockClear();
mockUserDailyActivityAggregatedCall.mockResolvedValue(mockSpendData);
mockUseInfiniteUsers.mockReturnValue({
data: {
pages: [
{
users: [
{ user_id: "user-001", user_alias: "Alice", user_email: "alice@example.com" },
{ user_id: "user-002", user_alias: null, user_email: "bob@example.com" },
{ user_id: "user-003", user_alias: null, user_email: null },
],
page: 1,
total_pages: 1,
total_count: 3,
},
],
pageParams: [1],
},
fetchNextPage: vi.fn(),
hasNextPage: false,
isFetchingNextPage: false,
isLoading: false,
} as any);
mockTagListCall.mockResolvedValue({});
mockUseCustomers.mockReturnValue({
data: [],
@ -661,4 +698,434 @@ describe("UsagePage", () => {
expect(entityUsageElements.length).toBeGreaterThan(0);
});
});
describe("admin user selector", () => {
it("should render user selector for admin users in global view", async () => {
renderWithProviders(<UsagePage {...defaultProps} />);
await waitFor(() => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
});
// Admin should see the user selector select element with the placeholder attribute
const userSelects = screen.getAllByRole("combobox");
const userSelect = userSelects.find(
(el) => el.getAttribute("placeholder") === "All Users (Global View)",
);
expect(userSelect).toBeDefined();
});
it("should format user options with alias when available", async () => {
renderWithProviders(<UsagePage {...defaultProps} />);
await waitFor(() => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
});
// User with alias should show "alias (id)"
expect(screen.getByText("Alice (user-001)")).toBeInTheDocument();
// User without alias but with email should show "email (id)"
expect(screen.getByText("bob@example.com (user-002)")).toBeInTheDocument();
// User with neither alias nor email should show just the id
expect(screen.getByText("user-003")).toBeInTheDocument();
});
it("should call useInfiniteUsers with debounced search", async () => {
renderWithProviders(<UsagePage {...defaultProps} />);
await waitFor(() => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
});
// useInfiniteUsers should be called with default page size
expect(mockUseInfiniteUsers).toHaveBeenCalledWith(50, undefined);
});
it("should deduplicate users across pages", async () => {
mockUseInfiniteUsers.mockReturnValue({
data: {
pages: [
{
users: [
{ user_id: "user-dup", user_alias: "DupUser", user_email: null },
],
page: 1,
total_pages: 2,
total_count: 2,
},
{
users: [
{ user_id: "user-dup", user_alias: "DupUser", user_email: null },
{ user_id: "user-unique", user_alias: "UniqueUser", user_email: null },
],
page: 2,
total_pages: 2,
total_count: 2,
},
],
pageParams: [1, 2],
},
fetchNextPage: vi.fn(),
hasNextPage: false,
isFetchingNextPage: false,
isLoading: false,
} as any);
renderWithProviders(<UsagePage {...defaultProps} />);
await waitFor(() => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
});
// Duplicate user should appear only once
const dupElements = screen.getAllByText("DupUser (user-dup)");
expect(dupElements).toHaveLength(1);
// Unique user should also appear
expect(screen.getByText("UniqueUser (user-unique)")).toBeInTheDocument();
});
it("should pass selected userId to aggregated call", async () => {
renderWithProviders(<UsagePage {...defaultProps} />);
await waitFor(() => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
});
// Initially called with null (global view for admin)
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledWith(
"test-token",
expect.any(Date),
expect.any(Date),
null,
);
});
});
describe("non-admin user behavior", () => {
it("should not render user selector for non-admin users", async () => {
mockUseAuthorized.mockReturnValue({
isLoading: false,
isAuthorized: true,
token: "mock-token",
accessToken: "test-token",
userId: "user-123",
userEmail: "test@example.com",
userRole: "Internal User",
premiumUser: false,
disabledPersonalKeyCreation: false,
showSSOBanner: false,
});
renderWithProviders(<UsagePage {...defaultProps} />);
await waitFor(() => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
});
// Non-admin should not see the user selector
const userSelects = screen.getAllByRole("combobox");
const userSelect = userSelects.find(
(el) => el.getAttribute("placeholder") === "All Users (Global View)",
);
expect(userSelect).toBeUndefined();
});
it("should always pass own userId for non-admin users", async () => {
mockUseAuthorized.mockReturnValue({
isLoading: false,
isAuthorized: true,
token: "mock-token",
accessToken: "test-token",
userId: "user-123",
userEmail: "test@example.com",
userRole: "Internal User",
premiumUser: false,
disabledPersonalKeyCreation: false,
showSSOBanner: false,
});
renderWithProviders(<UsagePage {...defaultProps} />);
await waitFor(() => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledWith(
"test-token",
expect.any(Date),
expect.any(Date),
"user-123",
);
});
});
});
describe("aggregated endpoint fallback", () => {
it("should fall back to paginated calls when aggregated endpoint fails", async () => {
mockUserDailyActivityAggregatedCall.mockRejectedValue(new Error("Aggregated endpoint not available"));
mockUserDailyActivityCall.mockResolvedValue({
...mockSpendData,
metadata: {
...mockSpendData.metadata,
total_pages: 1,
page: 1,
},
});
renderWithProviders(<UsagePage {...defaultProps} />);
await waitFor(() => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
expect(mockUserDailyActivityCall).toHaveBeenCalled();
});
// Should still render the data from the paginated fallback
expect(screen.getByText("1,500")).toBeInTheDocument();
});
it("should aggregate multiple pages when paginated endpoint has more than 1 page", async () => {
mockUserDailyActivityAggregatedCall.mockRejectedValue(new Error("Not available"));
const page1Data = {
results: [mockSpendData.results[0]],
metadata: {
total_spend: 60,
total_api_requests: 700,
total_successful_requests: 680,
total_failed_requests: 20,
total_tokens: 35000,
total_pages: 2,
page: 1,
},
};
const page2Data = {
results: [
{
...mockSpendData.results[0],
date: "2025-01-02",
},
],
metadata: {
total_spend: 65.75,
total_api_requests: 800,
total_successful_requests: 770,
total_failed_requests: 30,
total_tokens: 40000,
total_pages: 2,
page: 2,
},
};
mockUserDailyActivityCall
.mockResolvedValueOnce(page1Data)
.mockResolvedValueOnce(page2Data);
renderWithProviders(<UsagePage {...defaultProps} />);
await waitFor(() => {
// Both pages should have been fetched
expect(mockUserDailyActivityCall).toHaveBeenCalledTimes(2);
});
// Verify first page call
expect(mockUserDailyActivityCall).toHaveBeenCalledWith(
"test-token",
expect.any(Date),
expect.any(Date),
1,
null,
);
// Verify second page call
expect(mockUserDailyActivityCall).toHaveBeenCalledWith(
"test-token",
expect.any(Date),
expect.any(Date),
2,
null,
);
});
});
describe("MCP Server Activity tab", () => {
it("should render MCP Server Activity tab", async () => {
renderWithProviders(<UsagePage {...defaultProps} />);
await waitFor(() => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
});
// The tab list should contain MCP Server Activity
expect(screen.getByText("MCP Server Activity")).toBeInTheDocument();
});
});
describe("User Agent Activity view", () => {
it("should render User Agent Activity component when view is selected", async () => {
renderWithProviders(<UsagePage {...defaultProps} />);
await waitFor(() => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
});
const usageSelect = screen.getByTestId("usage-view-select");
act(() => {
fireEvent.change(usageSelect, { target: { value: "user-agent-activity" } });
});
await waitFor(() => {
// "User Agent Activity" appears both in the select option and in the rendered component
const elements = screen.getAllByText("User Agent Activity");
expect(elements.length).toBeGreaterThanOrEqual(2);
});
});
});
describe("Export Data button", () => {
it("should render Export Data button in global view for admin", async () => {
renderWithProviders(<UsagePage {...defaultProps} />);
await waitFor(() => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
});
expect(screen.getByText("Export Data")).toBeInTheDocument();
});
});
describe("model view toggle", () => {
it("should show Public Model Name view by default", async () => {
renderWithProviders(<UsagePage {...defaultProps} />);
await waitFor(() => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
});
// Default should be "groups" view showing "Top Public Model Names"
expect(screen.getByText("Top Public Model Names")).toBeInTheDocument();
expect(screen.getByText("Public Model Name")).toBeInTheDocument();
expect(screen.getByText("Litellm Model Name")).toBeInTheDocument();
});
it("should switch to Litellm Model Name view on toggle click", async () => {
renderWithProviders(<UsagePage {...defaultProps} />);
await waitFor(() => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
});
// Click the "Litellm Model Name" toggle
const litellmToggle = screen.getByText("Litellm Model Name");
act(() => {
fireEvent.click(litellmToggle);
});
// Title should change to "Top Litellm Models"
await waitFor(() => {
expect(screen.getByText("Top Litellm Models")).toBeInTheDocument();
});
});
it("should switch back to Public Model Name view", async () => {
renderWithProviders(<UsagePage {...defaultProps} />);
await waitFor(() => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
});
// Switch to individual first
const litellmToggle = screen.getByText("Litellm Model Name");
act(() => {
fireEvent.click(litellmToggle);
});
await waitFor(() => {
expect(screen.getByText("Top Litellm Models")).toBeInTheDocument();
});
// Switch back to groups
const publicToggle = screen.getByText("Public Model Name");
act(() => {
fireEvent.click(publicToggle);
});
await waitFor(() => {
expect(screen.getByText("Top Public Model Names")).toBeInTheDocument();
});
});
});
describe("customer usage banner", () => {
it("should show and be dismissible in customer view", async () => {
mockUseCustomers.mockReturnValue({
data: mockCustomers,
isLoading: false,
error: null,
} as any);
renderWithProviders(<UsagePage {...defaultProps} />);
await waitFor(() => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
});
const usageSelect = screen.getByTestId("usage-view-select");
act(() => {
fireEvent.change(usageSelect, { target: { value: "customer" } });
});
await waitFor(() => {
expect(screen.getByText("Customer usage is a new feature.")).toBeInTheDocument();
});
// Click the close button
const closeButton = screen.getByLabelText("Close");
act(() => {
fireEvent.click(closeButton);
});
await waitFor(() => {
expect(screen.queryByText("Customer usage is a new feature.")).not.toBeInTheDocument();
});
});
});
describe("agent usage banner", () => {
it("should show agent usage banner with A2A info", async () => {
mockUseAgents.mockReturnValue({
data: { agents: mockAgents },
isLoading: false,
error: null,
} as any);
renderWithProviders(<UsagePage {...defaultProps} />);
await waitFor(() => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
});
const usageSelect = screen.getByTestId("usage-view-select");
act(() => {
fireEvent.change(usageSelect, { target: { value: "agent" } });
});
await waitFor(() => {
expect(screen.getByText("Agent usage (A2A) is a new feature.")).toBeInTheDocument();
});
});
});
describe("tab navigation in global view", () => {
it("should render all expected tabs", async () => {
renderWithProviders(<UsagePage {...defaultProps} />);
await waitFor(() => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
});
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();
});
});
});

View file

@ -6,7 +6,7 @@
* Works at 1m+ spend logs, by querying an aggregate table instead.
*/
import { InfoCircleOutlined } from "@ant-design/icons";
import { InfoCircleOutlined, LoadingOutlined, UserOutlined } from "@ant-design/icons";
import {
BarChart,
Card,
@ -21,13 +21,15 @@ import {
Text,
Title
} from "@tremor/react";
import { Alert, Segmented, Tooltip } from "antd";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Alert, Segmented, Select, Tooltip } from "antd";
import { useDebouncedState } from "@tanstack/react-pacer/debouncer";
import React, { useCallback, useEffect, useMemo, useState, type UIEvent } from "react";
import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents";
import { useCustomers } from "@/app/(dashboard)/hooks/customers/useCustomers";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser";
import { useInfiniteUsers } from "@/app/(dashboard)/hooks/users/useUsers";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { Button } from "@tremor/react";
import { all_admin_roles } from "../../../utils/roles";
@ -81,6 +83,62 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
const { data: currentUser } = useCurrentUser();
console.log(`currentUser: ${JSON.stringify(currentUser)}`);
console.log(`currentUser max budget: ${currentUser?.max_budget}`);
const isAdmin = all_admin_roles.includes(userRole || "");
// Debounced search for user selector
const [userSearchInput, setUserSearchInput] = useState("");
const [debouncedUserSearch, setDebouncedUserSearch] = useDebouncedState("", {
wait: 300,
});
const {
data: usersInfiniteData,
fetchNextPage: fetchNextUsersPage,
hasNextPage: hasNextUsersPage,
isFetchingNextPage: isFetchingNextUsersPage,
isLoading: isLoadingUsers,
} = useInfiniteUsers(50, debouncedUserSearch || undefined);
const userOptions = useMemo(() => {
if (!usersInfiniteData?.pages) return [];
const seen = new Set<string>();
const result: { value: string; label: string }[] = [];
for (const page of usersInfiniteData.pages) {
for (const user of page.users) {
if (seen.has(user.user_id)) continue;
seen.add(user.user_id);
result.push({
value: user.user_id,
label: user.user_alias
? `${user.user_alias} (${user.user_id})`
: user.user_email
? `${user.user_email} (${user.user_id})`
: user.user_id,
});
}
}
return result;
}, [usersInfiniteData]);
const handleUserSearchChange = (value: string) => {
setUserSearchInput(value);
setDebouncedUserSearch(value);
};
const handleUserPopupScroll = (e: UIEvent<HTMLDivElement>) => {
const target = e.currentTarget;
const scrollRatio =
(target.scrollTop + target.clientHeight) / target.scrollHeight;
if (scrollRatio >= 0.8 && hasNextUsersPage && !isFetchingNextUsersPage) {
fetchNextUsersPage();
}
};
// For admins: null means global view (all users), a string means filter by that user
// For non-admins: always set to their own user ID
const [selectedUserId, setSelectedUserId] = useState<string | null>(
isAdmin ? null : (userID || null)
);
const [modelViewType, setModelViewType] = useState<"groups" | "individual">("groups");
const [isCloudZeroModalOpen, setIsCloudZeroModalOpen] = useState(false);
const [isGlobalExportModalOpen, setIsGlobalExportModalOpen] = useState(false);
@ -301,6 +359,9 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
const fetchUserSpendData = useCallback(async () => {
if (!accessToken || !dateValue.from || !dateValue.to) return;
// For non-admins, always pass their own user_id
const effectiveUserId = isAdmin ? selectedUserId : (userID || null);
setLoading(true);
// Create new Date objects to avoid mutating the original dates
@ -310,14 +371,14 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
try {
// Prefer aggregated endpoint to avoid many page requests
try {
const aggregated = await userDailyActivityAggregatedCall(accessToken, startTime, endTime);
const aggregated = await userDailyActivityAggregatedCall(accessToken, startTime, endTime, effectiveUserId);
setUserSpendData(aggregated);
return;
} catch (e) {
// Fallback to paginated calls if aggregated endpoint is unavailable
}
const firstPageData = await userDailyActivityCall(accessToken, startTime, endTime);
const firstPageData = await userDailyActivityCall(accessToken, startTime, endTime, 1, effectiveUserId);
if (firstPageData.metadata.total_pages <= 1) {
setUserSpendData(firstPageData);
@ -328,7 +389,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
const aggregatedMetadata = { ...firstPageData.metadata };
for (let page = 2; page <= firstPageData.metadata.total_pages; page++) {
const pageData = await userDailyActivityCall(accessToken, startTime, endTime, page);
const pageData = await userDailyActivityCall(accessToken, startTime, endTime, page, effectiveUserId);
allResults.push(...pageData.results);
if (pageData.metadata) {
aggregatedMetadata.total_spend += pageData.metadata.total_spend || 0;
@ -349,7 +410,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
setLoading(false);
setIsDateChanging(false);
}
}, [accessToken, dateValue.from, dateValue.to]);
}, [accessToken, dateValue.from, dateValue.to, selectedUserId, isAdmin, userID]);
// Super responsive date change handler
const handleDateChange = useCallback((newValue: DateRangePickerValue) => {
@ -423,12 +484,13 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
<UsageViewSelect
value={usageView}
onChange={(value) => setUsageView(value)}
isAdmin={all_admin_roles.includes(userRole || "")}
isAdmin={isAdmin}
/>
<AdvancedDatePicker value={dateValue} onValueChange={handleDateChange} />
</div>
{/* Your Usage Panel */}
{usageView === "global" && (
<>
<TabGroup>
<div className="flex justify-between items-center">
<TabList variant="solid" className="mt-1">
@ -460,24 +522,61 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
<Grid numItems={2} className="gap-2 w-full">
{/* Total Spend Card */}
<Col numColSpan={2}>
<Text className="text-tremor-default text-tremor-content dark:text-dark-tremor-content mb-2 mt-2 text-lg">
Project Spend{" "}
{dateValue.from && dateValue.to && (
<>
{dateValue.from.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: dateValue.from.getFullYear() !== dateValue.to.getFullYear() ? "numeric" : undefined,
})}
{" - "}
{dateValue.to.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
</>
<div className="flex items-center gap-4 mt-2 mb-2">
<Text className="text-tremor-default text-tremor-content dark:text-dark-tremor-content text-lg">
Project Spend{" "}
{dateValue.from && dateValue.to && (
<>
{dateValue.from.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: dateValue.from.getFullYear() !== dateValue.to.getFullYear() ? "numeric" : undefined,
})}
{" - "}
{dateValue.to.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
</>
)}
</Text>
{isAdmin && (
<div className="flex items-center gap-2">
<UserOutlined style={{ fontSize: "14px", color: "#6b7280" }} />
<Select
showSearch
allowClear
style={{ width: 300 }}
placeholder="All Users (Global View)"
value={selectedUserId}
onChange={(value) => setSelectedUserId(value ?? null)}
filterOption={false}
onSearch={handleUserSearchChange}
searchValue={userSearchInput}
onPopupScroll={handleUserPopupScroll}
loading={isLoadingUsers}
notFoundContent={isLoadingUsers ? <LoadingOutlined spin /> : "No users found"}
options={userOptions}
popupRender={(menu) => (
<>
{menu}
{isFetchingNextUsersPage && (
<div style={{ textAlign: "center", padding: 8 }}>
<LoadingOutlined spin />
</div>
)}
</>
)}
/>
{selectedUserId && (
<span className="text-xs text-gray-500">
Filtering by user
</span>
)}
</div>
)}
</Text>
</div>
<ViewUserSpend
userSpend={totalSpend}
@ -694,6 +793,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
</TabPanel>
</TabPanels>
</TabGroup>
</>
)}
{/* Organization Usage Panel */}

View file

@ -1726,7 +1726,7 @@ const fetchDailyActivity = async ({
}
};
export const userDailyActivityCall = async (accessToken: string, startTime: Date, endTime: Date, page: number = 1) => {
export const userDailyActivityCall = async (accessToken: string, startTime: Date, endTime: Date, page: number = 1, userId: string | null = null) => {
/**
* Get daily user activity on proxy
*/
@ -1736,6 +1736,9 @@ export const userDailyActivityCall = async (accessToken: string, startTime: Date
startTime,
endTime,
page,
extraQueryParams: {
user_id: userId,
},
});
};
@ -3405,7 +3408,7 @@ export interface User {
[key: string]: string; // Include any other potential keys in the dictionary
}
export const userDailyActivityAggregatedCall = async (accessToken: string, startTime: Date, endTime: Date) => {
export const userDailyActivityAggregatedCall = async (accessToken: string, startTime: Date, endTime: Date, userId: string | null = null) => {
/**
* Get aggregated daily user activity (no pagination)
*/
@ -3423,6 +3426,9 @@ export const userDailyActivityAggregatedCall = async (accessToken: string, start
queryParams.append("end_date", formatDate(endTime));
// Send timezone offset so backend can adjust date range for UTC storage
queryParams.append("timezone", new Date().getTimezoneOffset().toString());
if (userId) {
queryParams.append("user_id", userId);
}
const queryString = queryParams.toString();
if (queryString) {
url += `?${queryString}`;