diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/customers/useCustomers.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/customers/useCustomers.ts new file mode 100644 index 0000000000..10cbedc04d --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/customers/useCustomers.ts @@ -0,0 +1,41 @@ +import { allEndUsersCall } from "@/components/networking"; +import { useQuery } from "@tanstack/react-query"; +import { createQueryKeys } from "../common/queryKeysFactory"; +import { all_admin_roles } from "@/utils/roles"; + +const customersKeys = createQueryKeys("customers"); + +export interface Customer { + user_id: string; + alias?: string | null; + spend: number; + blocked: boolean; + allowed_model_region?: string | null; + default_model?: string | null; + budget_id?: string | null; + litellm_budget_table?: { + budget_id: string; + max_budget?: number | null; + soft_budget?: number | null; + max_parallel_requests?: number | null; + tpm_limit?: number | null; + rpm_limit?: number | null; + model_max_budget?: Record | null; + budget_duration?: string | null; + budget_reset_at?: string | null; + created_at: string; + created_by: string; + updated_at: string; + updated_by: string; + } | null; +} + +export type CustomersResponse = Customer[]; + +export const useCustomers = (accessToken: string | null, userRole: string | null) => { + return useQuery({ + queryKey: customersKeys.list({}), + queryFn: async () => await allEndUsersCall(accessToken!), + enabled: Boolean(accessToken) && all_admin_roles.includes(userRole || ""), + }); +}; diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/ExportTypeSelector.tsx b/ui/litellm-dashboard/src/components/EntityUsageExport/ExportTypeSelector.tsx index 43e6f986df..17a833deac 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/ExportTypeSelector.tsx +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/ExportTypeSelector.tsx @@ -1,11 +1,11 @@ import React from "react"; import { Radio } from "antd"; -import type { ExportScope } from "./types"; +import type { ExportScope, EntityType } from "./types"; interface ExportTypeSelectorProps { value: ExportScope; onChange: (value: ExportScope) => void; - entityType: "tag" | "team" | "organization"; + entityType: EntityType; } const ExportTypeSelector: React.FC = ({ value, onChange, entityType }) => { diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx b/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx index 3547d65379..e326183d88 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx @@ -7,7 +7,7 @@ import type { EntitySpendData } from "./types"; interface UsageExportHeaderProps { dateValue: DateRangePickerValue; - entityType: "tag" | "team" | "organization"; + entityType: "tag" | "team" | "organization" | "customer"; spendData: EntitySpendData; // Optional filter props showFilters?: boolean; diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/types.ts b/ui/litellm-dashboard/src/components/EntityUsageExport/types.ts index ea11701f7e..ded2731c94 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/types.ts +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/types.ts @@ -2,6 +2,7 @@ import type { DateRangePickerValue } from "@tremor/react"; export type ExportFormat = "csv" | "json"; export type ExportScope = "daily" | "daily_with_models"; +export type EntityType = "tag" | "team" | "organization" | "customer"; export interface EntitySpendData { results: any[]; @@ -17,7 +18,7 @@ export interface EntitySpendData { export interface EntityUsageExportModalProps { isOpen: boolean; onClose: () => void; - entityType: "tag" | "team" | "organization"; + entityType: EntityType; spendData: EntitySpendData; dateRange: DateRangePickerValue; selectedFilters: string[]; diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts b/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts index 1327e158a6..c93155feb2 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts @@ -1,6 +1,6 @@ import { formatNumberWithCommas } from "@/utils/dataUtils"; import Papa from "papaparse"; -import type { EntitySpendData, EntityBreakdown, ExportMetadata, ExportScope } from "./types"; +import type { EntitySpendData, EntityBreakdown, ExportMetadata, ExportScope, EntityType } from "./types"; import type { DateRangePickerValue } from "@tremor/react"; export const getEntityBreakdown = (spendData: EntitySpendData): EntityBreakdown[] => { @@ -139,7 +139,7 @@ export const generateExportData = ( }; export const generateMetadata = ( - entityType: "tag" | "team" | "organization", + entityType: EntityType, dateRange: DateRangePickerValue, selectedFilters: string[], exportScope: ExportScope, @@ -166,7 +166,7 @@ export const handleExportCSV = ( spendData: EntitySpendData, exportScope: ExportScope, entityLabel: string, - entityType: "tag" | "team" | "organization", + entityType: EntityType, ): void => { const data = generateExportData(spendData, exportScope, entityLabel); const csv = Papa.unparse(data); @@ -186,7 +186,7 @@ export const handleExportJSON = ( spendData: EntitySpendData, exportScope: ExportScope, entityLabel: string, - entityType: "tag" | "team" | "organization", + entityType: EntityType, dateRange: DateRangePickerValue, selectedFilters: string[], ): void => { diff --git a/ui/litellm-dashboard/src/components/entity_usage.test.tsx b/ui/litellm-dashboard/src/components/entity_usage.test.tsx index 17016b6479..2c070427ea 100644 --- a/ui/litellm-dashboard/src/components/entity_usage.test.tsx +++ b/ui/litellm-dashboard/src/components/entity_usage.test.tsx @@ -18,6 +18,7 @@ vi.mock("./networking", () => ({ tagDailyActivityCall: vi.fn(), teamDailyActivityCall: vi.fn(), organizationDailyActivityCall: vi.fn(), + customerDailyActivityCall: vi.fn(), })); // Mock the child components to simplify testing @@ -42,6 +43,7 @@ describe("EntityUsage", () => { const mockTagDailyActivityCall = vi.mocked(networking.tagDailyActivityCall); const mockTeamDailyActivityCall = vi.mocked(networking.teamDailyActivityCall); const mockOrganizationDailyActivityCall = vi.mocked(networking.organizationDailyActivityCall); + const mockCustomerDailyActivityCall = vi.mocked(networking.customerDailyActivityCall); const mockSpendData = { results: [ @@ -128,9 +130,11 @@ describe("EntityUsage", () => { mockTagDailyActivityCall.mockClear(); mockTeamDailyActivityCall.mockClear(); mockOrganizationDailyActivityCall.mockClear(); + mockCustomerDailyActivityCall.mockClear(); mockTagDailyActivityCall.mockResolvedValue(mockSpendData); mockTeamDailyActivityCall.mockResolvedValue(mockSpendData); mockOrganizationDailyActivityCall.mockResolvedValue(mockSpendData); + mockCustomerDailyActivityCall.mockResolvedValue(mockSpendData); }); it("should render with tag entity type and display spend metrics", async () => { @@ -182,6 +186,21 @@ describe("EntityUsage", () => { }); }); + it("should render with customer entity type and call customer API", async () => { + render(); + + await waitFor(() => { + expect(mockCustomerDailyActivityCall).toHaveBeenCalled(); + }); + + expect(screen.getByText("Customer Spend Overview")).toBeInTheDocument(); + + await waitFor(() => { + const spendElements = screen.getAllByText("$100.50"); + expect(spendElements.length).toBeGreaterThan(0); + }); + }); + it("should switch between tabs", async () => { render(); diff --git a/ui/litellm-dashboard/src/components/entity_usage.tsx b/ui/litellm-dashboard/src/components/entity_usage.tsx index 501eac7124..ca30ded949 100644 --- a/ui/litellm-dashboard/src/components/entity_usage.tsx +++ b/ui/litellm-dashboard/src/components/entity_usage.tsx @@ -23,12 +23,18 @@ import { } from "@tremor/react"; import { ActivityMetrics, processActivityData } from "./activity_metrics"; import { DailyData, BreakdownMetrics, KeyMetricWithMetadata, EntityMetricWithMetadata, TagUsage } from "./usage/types"; -import { organizationDailyActivityCall, tagDailyActivityCall, teamDailyActivityCall } from "./networking"; +import { + organizationDailyActivityCall, + tagDailyActivityCall, + teamDailyActivityCall, + customerDailyActivityCall, +} from "./networking"; import TopKeyView from "./top_key_view"; import { formatNumberWithCommas } from "@/utils/dataUtils"; import { valueFormatterSpend } from "./usage/utils/value_formatters"; import { getProviderLogoAndName } from "./provider_info_helpers"; import { UsageExportHeader } from "./EntityUsageExport"; +import type { EntityType } from "./EntityUsageExport/types"; import TopModelView from "./top_model_view"; interface EntityMetrics { @@ -68,7 +74,7 @@ export interface EntityList { interface EntityUsageProps { accessToken: string | null; - entityType: "tag" | "team" | "organization"; + entityType: EntityType; entityId?: string | null; userID: string | null; userRole: string | null; @@ -135,6 +141,15 @@ const EntityUsage: React.FC = ({ selectedTags.length > 0 ? selectedTags : null, ); setSpendData(data); + } else if (entityType === "customer") { + const data = await customerDailyActivityCall( + accessToken, + startTime, + endTime, + 1, + selectedTags.length > 0 ? selectedTags : null, + ); + setSpendData(data); } else { throw new Error("Invalid entity type"); } diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 0e45c0f3a9..4582a8a8e1 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -1736,6 +1736,25 @@ export const organizationDailyActivityCall = async ( }); }; +export const customerDailyActivityCall = async ( + accessToken: string, + startTime: Date, + endTime: Date, + page: number = 1, + customerIds: string[] | null = null, +) => { + return fetchDailyActivity({ + accessToken, + endpoint: "/customer/daily/activity", + startTime, + endTime, + page, + extraQueryParams: { + end_user_ids: customerIds, + }, + }); +}; + export const getTotalSpendCall = async (accessToken: string) => { /** * Get all models on proxy @@ -2511,7 +2530,7 @@ export const allEndUsersCall = async (accessToken: string) => { console.log(data); return data; } catch (error) { - console.error("Failed to create key:", error); + console.error("Failed to fetch end users:", error); throw error; } }; diff --git a/ui/litellm-dashboard/src/components/new_usage.test.tsx b/ui/litellm-dashboard/src/components/new_usage.test.tsx index a06045137d..aec07765e7 100644 --- a/ui/litellm-dashboard/src/components/new_usage.test.tsx +++ b/ui/litellm-dashboard/src/components/new_usage.test.tsx @@ -3,6 +3,7 @@ import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest"; import NewUsagePage from "./new_usage"; import type { Organization } from "./networking"; import * as networking from "./networking"; +import { useCustomers } from "@/app/(dashboard)/hooks/customers/useCustomers"; // Polyfill ResizeObserver for test environment beforeAll(() => { @@ -53,9 +54,14 @@ vi.mock("./EntityUsageExport", () => ({ default: () =>
Entity Usage Export Modal
, })); +vi.mock("@/app/(dashboard)/hooks/customers/useCustomers", () => ({ + useCustomers: vi.fn(), +})); + describe("NewUsage", () => { const mockUserDailyActivityAggregatedCall = vi.mocked(networking.userDailyActivityAggregatedCall); const mockTagListCall = vi.mocked(networking.tagListCall); + const mockUseCustomers = vi.mocked(useCustomers); const mockSpendData = { results: [ @@ -174,6 +180,19 @@ describe("NewUsage", () => { }, ]; + const mockCustomers = [ + { + user_id: "customer-123", + alias: "Test Customer", + spend: 0, + blocked: false, + allowed_model_region: null, + default_model: null, + budget_id: null, + litellm_budget_table: null, + }, + ]; + const defaultProps = { accessToken: "test-token", userRole: "Admin", @@ -205,6 +224,11 @@ describe("NewUsage", () => { mockTagListCall.mockClear(); mockUserDailyActivityAggregatedCall.mockResolvedValue(mockSpendData); mockTagListCall.mockResolvedValue({}); + mockUseCustomers.mockReturnValue({ + data: [], + isLoading: false, + error: null, + } as any); }); it("should render and fetch usage data on mount", async () => { @@ -289,4 +313,26 @@ describe("NewUsage", () => { expect(entityUsageElements.length).toBeGreaterThan(0); }); }); + + it("should show customer usage tab for admins", async () => { + mockUseCustomers.mockReturnValue({ + data: mockCustomers, + isLoading: false, + error: null, + } as any); + + const { getByText, getAllByText } = render(); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); + }); + + const customerTab = getByText("Customer Usage"); + fireEvent.click(customerTab); + + await waitFor(() => { + const entityUsageElements = getAllByText("Entity Usage"); + expect(entityUsageElements.length).toBeGreaterThan(0); + }); + }); }); diff --git a/ui/litellm-dashboard/src/components/new_usage.tsx b/ui/litellm-dashboard/src/components/new_usage.tsx index a8d3088549..f9280ae2bf 100644 --- a/ui/litellm-dashboard/src/components/new_usage.tsx +++ b/ui/litellm-dashboard/src/components/new_usage.tsx @@ -27,9 +27,10 @@ import { Text, Title, } from "@tremor/react"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Alert } from "antd"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { useCustomers } from "@/app/(dashboard)/hooks/customers/useCustomers"; import { formatNumberWithCommas } from "@/utils/dataUtils"; import { Button } from "@tremor/react"; import { all_admin_roles } from "../utils/roles"; @@ -86,6 +87,7 @@ const NewUsagePage: React.FC = ({ }); const [allTags, setAllTags] = useState([]); + const { data: customers = [] } = useCustomers(accessToken, userRole); const [modelViewType, setModelViewType] = useState<"groups" | "individual">("groups"); const [isCloudZeroModalOpen, setIsCloudZeroModalOpen] = useState(false); const [isGlobalExportModalOpen, setIsGlobalExportModalOpen] = useState(false); @@ -430,6 +432,7 @@ const NewUsagePage: React.FC = ({ Your Organization Usage )} Team Usage + {all_admin_roles.includes(userRole || "") ? Customer Usage : <>} {all_admin_roles.includes(userRole || "") ? Tag Usage : <>} {all_admin_roles.includes(userRole || "") ? User Agent Activity : <>} @@ -798,6 +801,23 @@ const NewUsagePage: React.FC = ({ /> + {/* Customer Usage Panel */} + + ({ + label: customer.alias || customer.user_id, + value: customer.user_id, + })) || null + } + premiumUser={premiumUser} + dateValue={dateValue} + /> + {/* Tag Usage Panel */}