diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.test.tsx b/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.test.tsx index d881e22527..820adfcdd5 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.test.tsx +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.test.tsx @@ -10,7 +10,7 @@ */ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render } from "@testing-library/react"; +import { renderWithProviders } from "../../../tests/test-utils"; import userEvent from "@testing-library/user-event"; import EntityUsageExportModal from "./EntityUsageExportModal"; @@ -35,6 +35,16 @@ vi.mock("../molecules/notifications_manager", () => { }; }); +// Mock useTeams hook +vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ + useTeams: vi.fn(() => ({ + data: [], + isLoading: false, + error: null, + refetch: vi.fn(), + })), +})); + // JSDOM stubs for download flow used by the modal // @ts-ignore global.URL.createObjectURL = vi.fn(() => "blob:mock"); @@ -74,7 +84,7 @@ describe("EntityUsageExportModal", () => { const user = userEvent.setup(); const { handleExportCSV } = await import("./utils"); - const { getByRole } = render(); + const { getByRole } = renderWithProviders(); // Default primary action reflects CSV export expect(getByRole("button", { name: /Export CSV/i })).toBeInTheDocument(); @@ -83,7 +93,7 @@ describe("EntityUsageExportModal", () => { await user.click(getByRole("button", { name: /Export CSV/i })); // Verifies export function was invoked with correct parameters - expect(handleExportCSV).toHaveBeenCalledWith(baseProps.spendData, "daily", "Tag", "tag"); + expect(handleExportCSV).toHaveBeenCalledWith(baseProps.spendData, "daily", "Tag", "tag", {}); // Modal closes after export expect(baseProps.onClose).toHaveBeenCalled(); @@ -98,7 +108,7 @@ describe("EntityUsageExportModal", () => { const user = userEvent.setup(); const { handleExportCSV } = await import("./utils"); - const { getByText, getByRole } = render(); + const { getByText, getByRole } = renderWithProviders(); // Choose the alternate export type - click the label to trigger radio const dailyModelLabel = getByText(/Day-by-day by tag and model/i); @@ -109,7 +119,7 @@ describe("EntityUsageExportModal", () => { await user.click(exportBtn); // Ensure the selected scope flowed through - expect(handleExportCSV).toHaveBeenCalledWith(baseProps.spendData, "daily_with_models", "Tag", "tag"); + expect(handleExportCSV).toHaveBeenCalledWith(baseProps.spendData, "daily_with_models", "Tag", "tag", {}); // Modal closes after export expect(baseProps.onClose).toHaveBeenCalled(); diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.tsx b/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.tsx index 5b40d76198..4c934bbf1d 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.tsx +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.tsx @@ -1,12 +1,13 @@ -import React, { useState } from "react"; -import { Button } from "@tremor/react"; -import { Modal } from "antd"; +import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; +import { createTeamAliasMap } from "@/utils/teamUtils"; +import { Button, Modal, Skeleton } from "antd"; +import React, { useMemo, useState } from "react"; import NotificationsManager from "../molecules/notifications_manager"; +import ExportFormatSelector from "./ExportFormatSelector"; import ExportSummary from "./ExportSummary"; import ExportTypeSelector from "./ExportTypeSelector"; -import ExportFormatSelector from "./ExportFormatSelector"; -import { handleExportCSV, handleExportJSON } from "./utils"; import type { EntityUsageExportModalProps, ExportFormat, ExportScope } from "./types"; +import { handleExportCSV, handleExportJSON } from "./utils"; const EntityUsageExportModal: React.FC = ({ isOpen, @@ -20,19 +21,22 @@ const EntityUsageExportModal: React.FC = ({ const [exportFormat, setExportFormat] = useState("csv"); const [exportScope, setExportScope] = useState("daily"); const [isExporting, setIsExporting] = useState(false); + const { data: teams, isLoading: isLoadingTeams } = useTeams(); const entityLabel = entityType.charAt(0).toUpperCase() + entityType.slice(1); const modalTitle = customTitle || `Export ${entityLabel} Usage`; + // Cache team alias map using useMemo + const teamAliasMap = useMemo(() => createTeamAliasMap(teams), [teams]); const handleExport = async (format?: ExportFormat) => { const formatToUse = format || exportFormat; setIsExporting(true); try { if (formatToUse === "csv") { - handleExportCSV(spendData, exportScope, entityLabel, entityType); + handleExportCSV(spendData, exportScope, entityLabel, entityType, teamAliasMap); NotificationsManager.success(`${entityLabel} usage data exported successfully as CSV`); } else { - handleExportJSON(spendData, exportScope, entityLabel, entityType, dateRange, selectedFilters); + handleExportJSON(spendData, exportScope, entityLabel, entityType, dateRange, selectedFilters, teamAliasMap); NotificationsManager.success(`${entityLabel} usage data exported successfully as JSON`); } onClose(); @@ -51,23 +55,37 @@ const EntityUsageExportModal: React.FC = ({ onCancel={onClose} footer={null} width={480} - destroyOnClose >
- - - - - - -
- - -
+ {isLoadingTeams ? ( + + ) : ( + <> + + + + + )} + {isLoadingTeams ? ( +
+ + +
+ ) : ( +
+ + +
+ )}
); diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx b/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx index b390c19df1..989ff8703e 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx @@ -4,6 +4,7 @@ import { Select } from "antd"; import React, { useState } from "react"; import EntityUsageExportModal from "./EntityUsageExportModal"; import type { EntitySpendData, EntityType } from "./types"; +import type { Team } from "@/components/key_team_helpers/key_list"; interface UsageExportHeaderProps { dateValue: DateRangePickerValue; @@ -18,6 +19,7 @@ interface UsageExportHeaderProps { filterOptions?: Array<{ label: string; value: string }>; customTitle?: string; compactLayout?: boolean; + teams?: Team[]; } const UsageExportHeader: React.FC = ({ @@ -32,6 +34,7 @@ const UsageExportHeader: React.FC = ({ filterOptions = [], customTitle, compactLayout = false, + teams = [], }) => { const [isExportModalOpen, setIsExportModalOpen] = useState(false); @@ -95,6 +98,7 @@ const UsageExportHeader: React.FC = ({ dateRange={dateValue} selectedFilters={selectedFilters} customTitle={customTitle} + teams={teams} /> ); diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/types.ts b/ui/litellm-dashboard/src/components/EntityUsageExport/types.ts index 81b0307c71..84a20da7cb 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/types.ts +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/types.ts @@ -1,4 +1,5 @@ import type { DateRangePickerValue } from "@tremor/react"; +import type { Team } from "@/components/key_team_helpers/key_list"; export type ExportFormat = "csv" | "json"; export type ExportScope = "daily" | "daily_with_models"; @@ -23,6 +24,7 @@ export interface EntityUsageExportModalProps { dateRange: DateRangePickerValue; selectedFilters: string[]; customTitle?: string; + teams?: Team[]; } export interface ExportMetadata { diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/utils.test.ts b/ui/litellm-dashboard/src/components/EntityUsageExport/utils.test.ts new file mode 100644 index 0000000000..127e3fc208 --- /dev/null +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/utils.test.ts @@ -0,0 +1,1004 @@ +import type { DateRangePickerValue } from "@tremor/react"; +import Papa from "papaparse"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { EntitySpendData, ExportScope } from "./types"; +import { + generateDailyData, + generateDailyWithModelsData, + generateExportData, + generateMetadata, + getEntityBreakdown, + handleExportCSV, + handleExportJSON, +} from "./utils"; + +vi.mock("@/utils/dataUtils", () => ({ + formatNumberWithCommas: vi.fn((value: number, decimals: number = 0) => { + if (value === null || value === undefined || !Number.isFinite(value)) { + return "-"; + } + return value.toFixed(decimals); + }), +})); + +vi.mock("papaparse", () => ({ + default: { + unparse: vi.fn((data: any[]) => "mocked-csv-data"), + }, +})); + +describe("EntityUsageExport utils", () => { + const mockSpendData: EntitySpendData = { + results: [ + { + date: "2025-01-01", + breakdown: { + entities: { + entity1: { + metrics: { + spend: 10.5, + api_requests: 100, + successful_requests: 95, + failed_requests: 5, + total_tokens: 1000, + prompt_tokens: 600, + completion_tokens: 400, + cache_read_input_tokens: 50, + cache_creation_input_tokens: 30, + }, + api_key_breakdown: { + key1: { + metrics: { + spend: 10.5, + api_requests: 100, + successful_requests: 95, + failed_requests: 5, + total_tokens: 1000, + }, + metadata: { + team_id: "team-1", + key_alias: "alias-1", + }, + }, + }, + }, + entity2: { + metrics: { + spend: 20.3, + api_requests: 200, + successful_requests: 190, + failed_requests: 10, + total_tokens: 2000, + prompt_tokens: 1200, + completion_tokens: 800, + cache_read_input_tokens: 100, + cache_creation_input_tokens: 60, + }, + api_key_breakdown: { + key2: { + metrics: { + spend: 20.3, + api_requests: 200, + successful_requests: 190, + failed_requests: 10, + total_tokens: 2000, + }, + metadata: { + team_id: "team-2", + key_alias: "alias-2", + }, + }, + }, + }, + }, + }, + }, + { + date: "2025-01-02", + breakdown: { + entities: { + entity1: { + metrics: { + spend: 15.2, + api_requests: 150, + successful_requests: 145, + failed_requests: 5, + total_tokens: 1500, + prompt_tokens: 900, + completion_tokens: 600, + cache_read_input_tokens: 75, + cache_creation_input_tokens: 45, + }, + api_key_breakdown: { + key1: { + metrics: { + spend: 15.2, + api_requests: 150, + successful_requests: 145, + failed_requests: 5, + total_tokens: 1500, + }, + metadata: { + team_id: "team-1", + key_alias: "alias-1", + }, + }, + }, + }, + }, + }, + }, + ], + metadata: { + total_spend: 46.0, + total_api_requests: 450, + total_successful_requests: 430, + total_failed_requests: 20, + total_tokens: 4500, + }, + }; + + const mockTeamAliasMap: Record = { + "team-1": "Team One", + "team-2": "Team Two", + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("getEntityBreakdown", () => { + it("should aggregate entity spend data across multiple days", () => { + const result = getEntityBreakdown(mockSpendData); + + expect(result).toHaveLength(2); + expect(result[0].metadata.id).toBe("team-1"); + expect(result[0].metrics.spend).toBe(25.7); + expect(result[1].metadata.id).toBe("team-2"); + expect(result[1].metrics.spend).toBe(20.3); + }); + + it("should sort entities by spend descending", () => { + const result = getEntityBreakdown(mockSpendData); + + expect(result[0].metrics.spend).toBeGreaterThan(result[1].metrics.spend); + }); + + it("should aggregate all metrics correctly", () => { + const result = getEntityBreakdown(mockSpendData); + const entity1 = result.find((e) => e.metadata.id === "team-1"); + + expect(entity1?.metrics.api_requests).toBe(250); + expect(entity1?.metrics.successful_requests).toBe(240); + expect(entity1?.metrics.failed_requests).toBe(10); + expect(entity1?.metrics.total_tokens).toBe(2500); + expect(entity1?.metrics.prompt_tokens).toBe(1500); + expect(entity1?.metrics.completion_tokens).toBe(1000); + expect(entity1?.metrics.cache_read_input_tokens).toBe(125); + expect(entity1?.metrics.cache_creation_input_tokens).toBe(75); + }); + + it("should use key alias when available", () => { + const result = getEntityBreakdown(mockSpendData); + const entity1 = result.find((e) => e.metadata.id === "team-1"); + + expect(entity1?.metadata.alias).toBe("alias-1"); + }); + + it("should use team alias map when key alias is not available", () => { + const spendDataWithoutAlias: EntitySpendData = { + ...mockSpendData, + results: [ + { + date: "2025-01-01", + breakdown: { + entities: { + entity1: { + metrics: { + spend: 10.5, + api_requests: 100, + successful_requests: 95, + failed_requests: 5, + total_tokens: 1000, + prompt_tokens: 600, + completion_tokens: 400, + cache_read_input_tokens: 50, + cache_creation_input_tokens: 30, + }, + api_key_breakdown: { + key1: { + metrics: { + spend: 10.5, + api_requests: 100, + successful_requests: 95, + failed_requests: 5, + total_tokens: 1000, + }, + metadata: { + team_id: "team-1", + }, + }, + }, + }, + }, + }, + }, + ], + metadata: mockSpendData.metadata, + }; + + const result = getEntityBreakdown(spendDataWithoutAlias, mockTeamAliasMap); + const entity1 = result.find((e) => e.metadata.id === "team-1"); + + expect(entity1?.metadata.alias).toBe("Team One"); + }); + + it("should use entity id when team alias is not available", () => { + const spendDataWithoutTeamId: EntitySpendData = { + ...mockSpendData, + results: [ + { + date: "2025-01-01", + breakdown: { + entities: { + entity1: { + metrics: { + spend: 10.5, + api_requests: 100, + successful_requests: 95, + failed_requests: 5, + total_tokens: 1000, + prompt_tokens: 600, + completion_tokens: 400, + cache_read_input_tokens: 50, + cache_creation_input_tokens: 30, + }, + api_key_breakdown: {}, + }, + }, + }, + }, + ], + metadata: mockSpendData.metadata, + }; + + const result = getEntityBreakdown(spendDataWithoutTeamId); + const entity1 = result.find((e) => e.metadata.id === "entity1"); + + expect(entity1?.metadata.alias).toBe("entity1"); + }); + + it("should handle empty spend data", () => { + const emptySpendData: EntitySpendData = { + results: [], + metadata: { + total_spend: 0, + total_api_requests: 0, + total_successful_requests: 0, + total_failed_requests: 0, + total_tokens: 0, + }, + }; + + const result = getEntityBreakdown(emptySpendData); + + expect(result).toHaveLength(0); + }); + + it("should handle missing optional token fields", () => { + const spendDataWithMissingTokens: EntitySpendData = { + ...mockSpendData, + results: [ + { + date: "2025-01-01", + breakdown: { + entities: { + entity1: { + metrics: { + spend: 10.5, + api_requests: 100, + successful_requests: 95, + failed_requests: 5, + total_tokens: 1000, + }, + api_key_breakdown: { + key1: { + metrics: { + spend: 10.5, + api_requests: 100, + successful_requests: 95, + failed_requests: 5, + total_tokens: 1000, + }, + metadata: { + team_id: "team-1", + }, + }, + }, + }, + }, + }, + }, + ], + metadata: mockSpendData.metadata, + }; + + const result = getEntityBreakdown(spendDataWithMissingTokens); + const entity1 = result.find((e) => e.metadata.id === "team-1"); + + expect(entity1?.metrics.prompt_tokens).toBe(0); + expect(entity1?.metrics.completion_tokens).toBe(0); + }); + }); + + describe("generateDailyData", () => { + it("should generate daily breakdown data with correct structure", () => { + const result = generateDailyData(mockSpendData, "Team", mockTeamAliasMap); + + expect(result).toHaveLength(3); + expect(result[0]).toHaveProperty("Date"); + expect(result[0]).toHaveProperty("Team"); + expect(result[0]).toHaveProperty("Team ID"); + expect(result[0]).toHaveProperty("Spend ($)"); + expect(result[0]).toHaveProperty("Requests"); + expect(result[0]).toHaveProperty("Successful Requests"); + expect(result[0]).toHaveProperty("Failed Requests"); + expect(result[0]).toHaveProperty("Total Tokens"); + expect(result[0]).toHaveProperty("Prompt Tokens"); + expect(result[0]).toHaveProperty("Completion Tokens"); + }); + + it("should sort data by date ascending", () => { + const result = generateDailyData(mockSpendData, "Team"); + + const dates = result.map((r) => new Date(r.Date).getTime()); + for (let i = 0; i < dates.length - 1; i++) { + expect(dates[i]).toBeLessThanOrEqual(dates[i + 1]); + } + }); + + it("should use team alias when available", () => { + const result = generateDailyData(mockSpendData, "Team", mockTeamAliasMap); + const team1Entry = result.find((r) => r["Team ID"] === "team-1"); + + expect(team1Entry?.["Team"]).toBe("Team One"); + }); + + it("should use dash when team alias is not available", () => { + const result = generateDailyData(mockSpendData, "Team"); + const entryWithoutTeamId = result.find((r) => !r["Team ID"] || r["Team ID"] === "-"); + + if (entryWithoutTeamId) { + expect(entryWithoutTeamId["Team"]).toBe("-"); + } + }); + + it("should use dash when team id is not available", () => { + const spendDataWithoutTeamId: EntitySpendData = { + ...mockSpendData, + results: [ + { + date: "2025-01-01", + breakdown: { + entities: { + entity1: { + metrics: { + spend: 10.5, + api_requests: 100, + successful_requests: 95, + failed_requests: 5, + total_tokens: 1000, + prompt_tokens: 600, + completion_tokens: 400, + }, + api_key_breakdown: {}, + }, + }, + }, + }, + ], + metadata: mockSpendData.metadata, + }; + + const result = generateDailyData(spendDataWithoutTeamId, "Team"); + const entry = result[0]; + + expect(entry["Team ID"]).toBe("-"); + expect(entry["Team"]).toBe("-"); + }); + + it("should format spend values correctly", () => { + const result = generateDailyData(mockSpendData, "Team"); + + expect(result[0]["Spend ($)"]).toBeDefined(); + }); + + it("should handle missing optional token fields", () => { + const spendDataWithMissingTokens: EntitySpendData = { + ...mockSpendData, + results: [ + { + date: "2025-01-01", + breakdown: { + entities: { + entity1: { + metrics: { + spend: 10.5, + api_requests: 100, + successful_requests: 95, + failed_requests: 5, + total_tokens: 1000, + }, + api_key_breakdown: { + key1: { + metrics: { + spend: 10.5, + api_requests: 100, + successful_requests: 95, + failed_requests: 5, + total_tokens: 1000, + }, + metadata: { + team_id: "team-1", + }, + }, + }, + }, + }, + }, + }, + ], + metadata: mockSpendData.metadata, + }; + + const result = generateDailyData(spendDataWithMissingTokens, "Team"); + + expect(result[0]["Prompt Tokens"]).toBe(0); + expect(result[0]["Completion Tokens"]).toBe(0); + }); + }); + + describe("generateDailyWithModelsData", () => { + const mockSpendDataWithModels: EntitySpendData = { + results: [ + { + date: "2025-01-01", + breakdown: { + entities: { + entity1: { + metrics: { + spend: 10.5, + api_requests: 100, + successful_requests: 95, + failed_requests: 5, + total_tokens: 1000, + prompt_tokens: 600, + completion_tokens: 400, + cache_read_input_tokens: 50, + cache_creation_input_tokens: 30, + }, + api_key_breakdown: { + key1: { + metrics: { + spend: 5.0, + api_requests: 50, + successful_requests: 48, + failed_requests: 2, + total_tokens: 500, + }, + metadata: { + team_id: "team-1", + }, + }, + key2: { + metrics: { + spend: 5.5, + api_requests: 50, + successful_requests: 47, + failed_requests: 3, + total_tokens: 500, + }, + metadata: { + team_id: "team-1", + }, + }, + }, + }, + }, + models: { + "gpt-4": { + metrics: { + spend: 5.0, + api_requests: 50, + successful_requests: 48, + failed_requests: 2, + total_tokens: 500, + }, + }, + "gpt-3.5-turbo": { + metrics: { + spend: 5.5, + api_requests: 50, + successful_requests: 47, + failed_requests: 3, + total_tokens: 500, + }, + }, + }, + }, + }, + ], + metadata: { + total_spend: 10.5, + total_api_requests: 100, + total_successful_requests: 95, + total_failed_requests: 5, + total_tokens: 1000, + }, + }; + + it("should generate daily breakdown with model data", () => { + const result = generateDailyWithModelsData(mockSpendDataWithModels, "Team", mockTeamAliasMap); + + expect(result.length).toBeGreaterThan(0); + expect(result[0]).toHaveProperty("Date"); + expect(result[0]).toHaveProperty("Team"); + expect(result[0]).toHaveProperty("Team ID"); + expect(result[0]).toHaveProperty("Model"); + expect(result[0]).toHaveProperty("Spend ($)"); + expect(result[0]).toHaveProperty("Requests"); + expect(result[0]).toHaveProperty("Successful"); + expect(result[0]).toHaveProperty("Failed"); + expect(result[0]).toHaveProperty("Total Tokens"); + }); + + it("should sort data by date ascending", () => { + const multiDayData: EntitySpendData = { + results: [ + { + date: "2025-01-02", + breakdown: { + entities: { + entity1: { + metrics: { + spend: 10.5, + api_requests: 100, + successful_requests: 95, + failed_requests: 5, + total_tokens: 1000, + prompt_tokens: 600, + completion_tokens: 400, + cache_read_input_tokens: 50, + cache_creation_input_tokens: 30, + }, + api_key_breakdown: { + key1: { + metrics: { + spend: 10.5, + api_requests: 100, + successful_requests: 95, + failed_requests: 5, + total_tokens: 1000, + }, + metadata: { + team_id: "team-1", + }, + }, + }, + }, + }, + models: { + "gpt-4": { + metrics: { + spend: 10.5, + api_requests: 100, + successful_requests: 95, + failed_requests: 5, + total_tokens: 1000, + }, + }, + }, + }, + }, + ...mockSpendDataWithModels.results, + ], + metadata: mockSpendDataWithModels.metadata, + }; + + const result = generateDailyWithModelsData(multiDayData, "Team"); + + expect(new Date(result[0].Date).getTime()).toBeLessThanOrEqual( + new Date(result[result.length - 1].Date).getTime(), + ); + }); + + it("should aggregate model metrics from api key breakdown", () => { + const result = generateDailyWithModelsData(mockSpendDataWithModels, "Team"); + + const gpt4Entry = result.find((r) => r.Model === "gpt-4"); + expect(gpt4Entry).toBeDefined(); + expect(gpt4Entry?.Requests).toBeGreaterThan(0); + }); + + it("should use team alias when available", () => { + const result = generateDailyWithModelsData(mockSpendDataWithModels, "Team", mockTeamAliasMap); + const team1Entry = result.find((r) => r["Team ID"] === "team-1"); + + expect(team1Entry?.["Team"]).toBe("Team One"); + }); + + it("should use dash when team alias is not available", () => { + const result = generateDailyWithModelsData(mockSpendDataWithModels, "Team"); + const entryWithoutTeamId = result.find((r) => !r["Team ID"] || r["Team ID"] === "-"); + + if (entryWithoutTeamId) { + expect(entryWithoutTeamId["Team"]).toBe("-"); + } + }); + + it("should handle empty models breakdown", () => { + const spendDataWithoutModels: EntitySpendData = { + ...mockSpendDataWithModels, + results: [ + { + date: "2025-01-01", + breakdown: { + entities: { + entity1: { + metrics: { + spend: 10.5, + api_requests: 100, + successful_requests: 95, + failed_requests: 5, + total_tokens: 1000, + prompt_tokens: 600, + completion_tokens: 400, + cache_read_input_tokens: 50, + cache_creation_input_tokens: 30, + }, + api_key_breakdown: { + key1: { + metrics: { + spend: 10.5, + api_requests: 100, + successful_requests: 95, + failed_requests: 5, + total_tokens: 1000, + }, + metadata: { + team_id: "team-1", + }, + }, + }, + }, + }, + models: {}, + }, + }, + ], + metadata: mockSpendDataWithModels.metadata, + }; + + const result = generateDailyWithModelsData(spendDataWithoutModels, "Team"); + + expect(result).toHaveLength(0); + }); + }); + + describe("generateExportData", () => { + it("should return daily data when scope is daily", () => { + const result = generateExportData(mockSpendData, "daily", "Team", mockTeamAliasMap); + + expect(result.length).toBeGreaterThan(0); + expect(result[0]).toHaveProperty("Date"); + expect(result[0]).not.toHaveProperty("Model"); + }); + + it("should return daily with models data when scope is daily_with_models", () => { + const mockDataWithModels: EntitySpendData = { + ...mockSpendData, + results: [ + { + date: "2025-01-01", + breakdown: { + entities: { + entity1: { + metrics: { + spend: 10.5, + api_requests: 100, + successful_requests: 95, + failed_requests: 5, + total_tokens: 1000, + prompt_tokens: 600, + completion_tokens: 400, + cache_read_input_tokens: 50, + cache_creation_input_tokens: 30, + }, + api_key_breakdown: { + key1: { + metrics: { + spend: 10.5, + api_requests: 100, + successful_requests: 95, + failed_requests: 5, + total_tokens: 1000, + }, + metadata: { + team_id: "team-1", + }, + }, + }, + }, + }, + models: { + "gpt-4": { + metrics: { + spend: 10.5, + api_requests: 100, + successful_requests: 95, + failed_requests: 5, + total_tokens: 1000, + }, + }, + }, + }, + }, + ], + metadata: mockSpendData.metadata, + }; + + const result = generateExportData(mockDataWithModels, "daily_with_models", "Team", mockTeamAliasMap); + + expect(result.length).toBeGreaterThan(0); + expect(result[0]).toHaveProperty("Model"); + }); + + it("should default to daily data for unknown scope", () => { + const result = generateExportData(mockSpendData, "unknown" as ExportScope, "Team", mockTeamAliasMap); + + expect(result.length).toBeGreaterThan(0); + expect(result[0]).not.toHaveProperty("Model"); + }); + }); + + describe("generateMetadata", () => { + const mockDateRange: DateRangePickerValue = { + from: new Date("2025-01-01"), + to: new Date("2025-01-31"), + }; + + it("should generate metadata with correct structure", () => { + const result = generateMetadata("team", mockDateRange, [], "daily", mockSpendData); + + expect(result).toHaveProperty("export_date"); + expect(result).toHaveProperty("entity_type"); + expect(result).toHaveProperty("date_range"); + expect(result).toHaveProperty("filters_applied"); + expect(result).toHaveProperty("export_scope"); + expect(result).toHaveProperty("summary"); + }); + + it("should include export date as ISO string", () => { + const result = generateMetadata("team", mockDateRange, [], "daily", mockSpendData); + + expect(result.export_date).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); + }); + + it("should include entity type", () => { + const result = generateMetadata("team", mockDateRange, [], "daily", mockSpendData); + + expect(result.entity_type).toBe("team"); + }); + + it("should format date range correctly", () => { + const result = generateMetadata("team", mockDateRange, [], "daily", mockSpendData); + + expect(result.date_range.from).toBe("2025-01-01T00:00:00.000Z"); + expect(result.date_range.to).toBe("2025-01-31T00:00:00.000Z"); + }); + + it("should handle missing date range values", () => { + const incompleteDateRange: DateRangePickerValue = { + from: undefined, + to: undefined, + }; + + const result = generateMetadata("team", incompleteDateRange, [], "daily", mockSpendData); + + expect(result.date_range.from).toBeUndefined(); + expect(result.date_range.to).toBeUndefined(); + }); + + it("should set filters_applied to None when empty", () => { + const result = generateMetadata("team", mockDateRange, [], "daily", mockSpendData); + + expect(result.filters_applied).toBe("None"); + }); + + it("should include filters when provided", () => { + const result = generateMetadata("team", mockDateRange, ["filter1", "filter2"], "daily", mockSpendData); + + expect(result.filters_applied).toEqual(["filter1", "filter2"]); + }); + + it("should include export scope", () => { + const result = generateMetadata("team", mockDateRange, [], "daily_with_models", mockSpendData); + + expect(result.export_scope).toBe("daily_with_models"); + }); + + it("should include summary metrics from spend data", () => { + const result = generateMetadata("team", mockDateRange, [], "daily", mockSpendData); + + expect(result.summary.total_spend).toBe(46.0); + expect(result.summary.total_requests).toBe(450); + expect(result.summary.successful_requests).toBe(430); + expect(result.summary.failed_requests).toBe(20); + expect(result.summary.total_tokens).toBe(4500); + }); + }); + + describe("handleExportCSV", () => { + beforeEach(() => { + document.body.innerHTML = ""; + window.URL.createObjectURL = vi.fn(() => "blob:mock-url"); + window.URL.revokeObjectURL = vi.fn(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should create CSV file and trigger download", () => { + const createObjectURLSpy = vi.spyOn(window.URL, "createObjectURL").mockReturnValue("blob:mock-url"); + const revokeObjectURLSpy = vi.spyOn(window.URL, "revokeObjectURL"); + const createElementSpy = vi.spyOn(document, "createElement"); + const appendChildSpy = vi.spyOn(document.body, "appendChild"); + const removeChildSpy = vi.spyOn(document.body, "removeChild"); + + handleExportCSV(mockSpendData, "daily", "Team", "team", mockTeamAliasMap); + + expect(Papa.unparse).toHaveBeenCalled(); + expect(createObjectURLSpy).toHaveBeenCalled(); + expect(createElementSpy).toHaveBeenCalledWith("a"); + expect(appendChildSpy).toHaveBeenCalled(); + expect(removeChildSpy).toHaveBeenCalled(); + }); + + it("should generate correct filename", () => { + const anchorElement = document.createElement("a"); + const createElementSpy = vi.spyOn(document, "createElement").mockReturnValue(anchorElement); + + const today = new Date().toISOString().split("T")[0]; + + handleExportCSV(mockSpendData, "daily", "Team", "team", mockTeamAliasMap); + + expect(anchorElement.download).toBe(`team_usage_daily_${today}.csv`); + }); + + it("should create blob with correct type", () => { + let blobType = ""; + const originalBlob = window.Blob; + + window.Blob = class extends Blob { + constructor(parts?: BlobPart[] | undefined, options?: BlobPropertyBag | undefined) { + super(parts, options); + if (options?.type) { + blobType = options.type; + } + } + } as any; + + handleExportCSV(mockSpendData, "daily", "Team", "team", mockTeamAliasMap); + + expect(blobType).toBe("text/csv;charset=utf-8;"); + + window.Blob = originalBlob; + }); + }); + + describe("handleExportJSON", () => { + beforeEach(() => { + document.body.innerHTML = ""; + window.URL.createObjectURL = vi.fn(() => "blob:mock-url"); + window.URL.revokeObjectURL = vi.fn(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should create JSON file and trigger download", () => { + const createObjectURLSpy = vi.spyOn(window.URL, "createObjectURL").mockReturnValue("blob:mock-url"); + const revokeObjectURLSpy = vi.spyOn(window.URL, "revokeObjectURL"); + const createElementSpy = vi.spyOn(document, "createElement"); + const appendChildSpy = vi.spyOn(document.body, "appendChild"); + const removeChildSpy = vi.spyOn(document.body, "removeChild"); + + const mockDateRange: DateRangePickerValue = { + from: new Date("2025-01-01"), + to: new Date("2025-01-31"), + }; + + handleExportJSON(mockSpendData, "daily", "Team", "team", mockDateRange, [], mockTeamAliasMap); + + expect(createObjectURLSpy).toHaveBeenCalled(); + expect(createElementSpy).toHaveBeenCalledWith("a"); + expect(appendChildSpy).toHaveBeenCalled(); + expect(removeChildSpy).toHaveBeenCalled(); + }); + + it("should generate correct filename", () => { + const anchorElement = document.createElement("a"); + const createElementSpy = vi.spyOn(document, "createElement").mockReturnValue(anchorElement); + + const today = new Date().toISOString().split("T")[0]; + const mockDateRange: DateRangePickerValue = { + from: new Date("2025-01-01"), + to: new Date("2025-01-31"), + }; + + handleExportJSON(mockSpendData, "daily", "Team", "team", mockDateRange, [], mockTeamAliasMap); + + expect(anchorElement.download).toBe(`team_usage_daily_${today}.json`); + }); + + it("should create blob with correct type", () => { + let blobType = ""; + const originalBlob = window.Blob; + + window.Blob = class extends Blob { + constructor(parts?: BlobPart[] | undefined, options?: BlobPropertyBag | undefined) { + super(parts, options); + if (options?.type) { + blobType = options.type; + } + } + } as any; + + const mockDateRange: DateRangePickerValue = { + from: new Date("2025-01-01"), + to: new Date("2025-01-31"), + }; + + handleExportJSON(mockSpendData, "daily", "Team", "team", mockDateRange, [], mockTeamAliasMap); + + expect(blobType).toBe("application/json"); + + window.Blob = originalBlob; + }); + + it("should include metadata and data in JSON export", () => { + let jsonString = ""; + const originalBlob = window.Blob; + + window.Blob = class extends Blob { + constructor(parts?: BlobPart[] | undefined, options?: BlobPropertyBag | undefined) { + super(parts, options); + if (parts && parts[0]) { + jsonString = parts[0] as string; + } + } + } as any; + + const mockDateRange: DateRangePickerValue = { + from: new Date("2025-01-01"), + to: new Date("2025-01-31"), + }; + + handleExportJSON(mockSpendData, "daily", "Team", "team", mockDateRange, ["filter1"], mockTeamAliasMap); + + const exportObject = JSON.parse(jsonString); + expect(exportObject).toHaveProperty("metadata"); + expect(exportObject).toHaveProperty("data"); + expect(exportObject.metadata.entity_type).toBe("team"); + expect(exportObject.metadata.filters_applied).toEqual(["filter1"]); + + window.Blob = originalBlob; + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts b/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts index c93155feb2..cdb5d011af 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts @@ -1,13 +1,43 @@ import { formatNumberWithCommas } from "@/utils/dataUtils"; -import Papa from "papaparse"; -import type { EntitySpendData, EntityBreakdown, ExportMetadata, ExportScope, EntityType } from "./types"; import type { DateRangePickerValue } from "@tremor/react"; +import Papa from "papaparse"; +import type { EntityBreakdown, EntitySpendData, EntityType, ExportMetadata, ExportScope } from "./types"; -export const getEntityBreakdown = (spendData: EntitySpendData): EntityBreakdown[] => { +// Helper function to extract team_id from api_key_breakdown +const extractTeamIdFromApiKeyBreakdown = (apiKeyBreakdown: Record | undefined): string | null => { + if (!apiKeyBreakdown) return null; + + // Look through all API keys to find the first non-null team_id + for (const apiKeyData of Object.values(apiKeyBreakdown)) { + const teamId = (apiKeyData as any)?.metadata?.team_id; + if (teamId) { + return teamId; + } + } + return null; +}; + +export const getEntityBreakdown = ( + spendData: EntitySpendData, + teamAliasMap: Record = {}, +): EntityBreakdown[] => { const entitySpend: { [key: string]: EntityBreakdown } = {}; spendData.results.forEach((day) => { Object.entries(day.breakdown.entities || {}).forEach(([entity, data]: [string, any]) => { + // Extract team_id from api_key_breakdown metadata (not data.metadata which is empty) + const teamId = extractTeamIdFromApiKeyBreakdown(data.api_key_breakdown) || entity; + // Extract key_alias from the first API key that has one + const apiKeyBreakdown = data.api_key_breakdown || {}; + let keyAlias: string | null = null; + for (const apiKeyData of Object.values(apiKeyBreakdown)) { + const alias = (apiKeyData as any)?.metadata?.key_alias; + if (alias) { + keyAlias = alias; + break; + } + } + if (!entitySpend[entity]) { entitySpend[entity] = { metrics: { @@ -22,8 +52,8 @@ export const getEntityBreakdown = (spendData: EntitySpendData): EntityBreakdown[ cache_creation_input_tokens: 0, }, metadata: { - alias: data.metadata?.team_alias || entity, - id: entity, + alias: keyAlias || teamAliasMap[teamId] || entity, + id: teamId, }, }; } @@ -42,15 +72,23 @@ export const getEntityBreakdown = (spendData: EntitySpendData): EntityBreakdown[ return Object.values(entitySpend).sort((a, b) => b.metrics.spend - a.metrics.spend); }; -export const generateDailyData = (spendData: EntitySpendData, entityLabel: string): any[] => { +export const generateDailyData = ( + spendData: EntitySpendData, + entityLabel: string, + teamAliasMap: Record = {}, +): any[] => { const dailyBreakdown: any[] = []; spendData.results.forEach((day) => { Object.entries(day.breakdown.entities || {}).forEach(([entity, data]: [string, any]) => { + // Extract team_id from api_key_breakdown metadata (not data.metadata which is empty) + const teamId = extractTeamIdFromApiKeyBreakdown(data.api_key_breakdown); + const teamAlias = teamId ? teamAliasMap[teamId] || null : null; + dailyBreakdown.push({ Date: day.date, - [entityLabel]: data.metadata?.team_alias || entity, - [`${entityLabel} ID`]: entity, + [entityLabel]: teamAlias || "-", + [`${entityLabel} ID`]: teamId || "-", "Spend ($)": formatNumberWithCommas(data.metrics.spend, 4), Requests: data.metrics.api_requests, "Successful Requests": data.metrics.successful_requests, @@ -65,15 +103,17 @@ export const generateDailyData = (spendData: EntitySpendData, entityLabel: strin return dailyBreakdown.sort((a, b) => new Date(a.Date).getTime() - new Date(b.Date).getTime()); }; -export const generateDailyWithModelsData = (spendData: EntitySpendData, entityLabel: string): any[] => { +export const generateDailyWithModelsData = ( + spendData: EntitySpendData, + entityLabel: string, + teamAliasMap: Record = {}, +): any[] => { const dailyModelBreakdown: any[] = []; spendData.results.forEach((day) => { const dailyEntityModels: { [key: string]: { [key: string]: any } } = {}; Object.entries(day.breakdown.entities || {}).forEach(([entity, entityData]: [string, any]) => { - const entityName = entityData.metadata?.team_alias || entity; - if (!dailyEntityModels[entity]) { dailyEntityModels[entity] = {}; } @@ -102,13 +142,15 @@ export const generateDailyWithModelsData = (spendData: EntitySpendData, entityLa Object.entries(dailyEntityModels).forEach(([entity, models]) => { const entityData = day.breakdown.entities?.[entity]; - const entityName = entityData?.metadata?.team_alias || entity; + // Extract team_id from api_key_breakdown metadata (not entityData.metadata which is empty) + const teamId = extractTeamIdFromApiKeyBreakdown(entityData?.api_key_breakdown); + const teamAlias = teamId ? teamAliasMap[teamId] || null : null; Object.entries(models).forEach(([model, metrics]: [string, any]) => { dailyModelBreakdown.push({ Date: day.date, - [entityLabel]: entityName, - [`${entityLabel} ID`]: entity, + [entityLabel]: teamAlias || "-", + [`${entityLabel} ID`]: teamId || "-", Model: model, "Spend ($)": formatNumberWithCommas(metrics.spend, 4), Requests: metrics.requests, @@ -127,14 +169,15 @@ export const generateExportData = ( spendData: EntitySpendData, exportScope: ExportScope, entityLabel: string, + teamAliasMap: Record = {}, ): any[] => { switch (exportScope) { case "daily": - return generateDailyData(spendData, entityLabel); + return generateDailyData(spendData, entityLabel, teamAliasMap); case "daily_with_models": - return generateDailyWithModelsData(spendData, entityLabel); + return generateDailyWithModelsData(spendData, entityLabel, teamAliasMap); default: - return generateDailyData(spendData, entityLabel); + return generateDailyData(spendData, entityLabel, teamAliasMap); } }; @@ -167,8 +210,9 @@ export const handleExportCSV = ( exportScope: ExportScope, entityLabel: string, entityType: EntityType, + teamAliasMap: Record = {}, ): void => { - const data = generateExportData(spendData, exportScope, entityLabel); + const data = generateExportData(spendData, exportScope, entityLabel, teamAliasMap); const csv = Papa.unparse(data); const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); const url = window.URL.createObjectURL(blob); @@ -189,8 +233,9 @@ export const handleExportJSON = ( entityType: EntityType, dateRange: DateRangePickerValue, selectedFilters: string[], + teamAliasMap: Record = {}, ): void => { - const data = generateExportData(spendData, exportScope, entityLabel); + const data = generateExportData(spendData, exportScope, entityLabel, teamAliasMap); const metadata = generateMetadata(entityType, dateRange, selectedFilters, exportScope, spendData); const exportObject = { metadata, diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.test.tsx index 46714857c1..60674a7c33 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.test.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.test.tsx @@ -36,7 +36,11 @@ vi.mock("./TopModelView", () => ({ default: () =>
Top Models
, })); -vi.mock("./EntityUsageExport", () => ({ +vi.mock("../../../EntityUsageExport/EntityUsageExportModal", () => ({ + default: () =>
Entity Usage Export Modal
, +})); + +vi.mock("../../../EntityUsageExport", () => ({ UsageExportHeader: () =>
Usage Export Header
, })); diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx index 52e75005cc..abd21765ce 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx @@ -399,6 +399,7 @@ const EntityUsage: React.FC = ({ selectedFilters={selectedTags} onFiltersChange={setSelectedTags} filterOptions={getAllTags() || undefined} + teams={teams || []} />