diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index daf12d11743..2465d07129c 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1140,15 +1140,7 @@ "count": 1 }, "react-hooks/set-state-in-effect": { - "count": 3 - } - }, - "src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.ts": { - "react-hooks/refs": { - "count": 1 - }, - "react-hooks/set-state-in-effect": { - "count": 1 + "count": 2 } }, "src/app/(dashboard)/users/_components/BulkEditUsers.tsx": { @@ -1279,11 +1271,6 @@ "count": 1 } }, - "src/components/EntityUsageExport/utils.ts": { - "max-params": { - "count": 3 - } - }, "src/components/GuardrailSettingsView.tsx": { "no-nested-ternary": { "count": 1 @@ -1786,13 +1773,13 @@ "count": 1 }, "max-params": { - "count": 21 + "count": 15 }, "no-nested-ternary": { "count": 5 }, "no-restricted-syntax": { - "count": 147 + "count": 146 }, "prefer-const": { "count": 31 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.test.tsx index 8d328c4c329..95abc4e22cb 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.test.tsx @@ -1,9 +1,18 @@ import { fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { DailyData, KeyMetricWithMetadata, SpendMetrics } from "@/components/UsagePage/types"; +import type { components } from "@/lib/http/schema"; +import type { KeySpendActivityRow } from "@/components/UsagePage/dailyActivityApi"; +import { EMPTY_DAILY_ACTIVITY_METADATA } from "@/components/UsagePage/dailyActivityApi"; +import type { DailyData, SpendMetrics } from "@/components/UsagePage/types"; import type { DailyActivityRange } from "./useDailyActivityRange"; +const mockCacheLeakageKeysCall = vi.fn(); + +vi.mock("@/components/networking", () => ({ + cacheLeakageKeysCall: (...args: unknown[]) => mockCacheLeakageKeysCall(...args), +})); + vi.mock("@/components/shared/advanced_date_picker", () => ({ __esModule: true, default: () =>
, @@ -11,8 +20,9 @@ vi.mock("@/components/shared/advanced_date_picker", () => ({ import CacheLeakageCard from "./CacheLeakageCard"; -const baseMetrics = (overrides: Partial): SpendMetrics => ({ +const baseMetrics = (overrides: Partial): components["schemas"]["SpendMetrics"] => ({ spend: 0, + flat_cost: 0, prompt_tokens: 0, completion_tokens: 0, total_tokens: 0, @@ -21,27 +31,22 @@ const baseMetrics = (overrides: Partial): SpendMetrics => ({ failed_requests: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, + compression_saved_tokens: 0, + compression_savings_spend: 0, + prompt_caching_savings_spend: 0, + gateway_injected_caching_savings_spend: 0, + autorouter_savings_spend: 0, + total_response_time_ms: 0, + timed_requests: 0, ...overrides, }); -const key = (alias: string, metrics: Partial): KeyMetricWithMetadata => ({ +const keyRow = (hash: string, alias: string, metrics: Partial): KeySpendActivityRow => ({ + api_key: hash, metrics: baseMetrics(metrics), metadata: { key_alias: alias, team_id: null }, }); -const dayWithKeys = (date: string, apiKeys: Record): DailyData => ({ - date, - metrics: baseMetrics({}), - breakdown: { - models: {}, - model_groups: {}, - mcp_servers: {}, - providers: {}, - api_keys: apiKeys, - entities: {}, - }, -}); - const dayWithModels = (date: string, models: Record>): DailyData => ({ date, metrics: baseMetrics({}), @@ -67,27 +72,37 @@ const renderWith = (results: DailyData[], overrides: Partial dateValue: {}, onDateChange: vi.fn(), results, + metadata: EMPTY_DAILY_ACTIVITY_METADATA, loading: false, - isFetchingMore: false, - progress: { currentPage: 1, totalPages: 1 }, - cancelled: false, failed: false, - cancel: vi.fn(), + scope: { + accessToken: "test-token", + startTime: new Date(2025, 0, 1), + endTime: new Date(2025, 0, 31), + userId: null, + apiKey: null, + }, ...overrides, }} />, ); describe("CacheLeakageCard", () => { - it("ranks leaking keys by uncached prompt tokens and shows cache hit ratio", () => { - renderWith([ - dayWithKeys("2026-07-12", { - "hash-caching": key("caching-key", { prompt_tokens: 1000, cache_read_input_tokens: 900 }), - "hash-leaky": key("leaky-key", { prompt_tokens: 10000, cache_read_input_tokens: 0 }), - }), - ]); + beforeEach(() => { + mockCacheLeakageKeysCall.mockReset(); + mockCacheLeakageKeysCall.mockResolvedValue({ api_keys: [] }); + }); - expect(screen.getByText("leaky-key")).toBeInTheDocument(); + it("ranks leaking keys from the server-ranked key list and shows cache hit ratio", async () => { + mockCacheLeakageKeysCall.mockResolvedValue({ + api_keys: [ + keyRow("hash-caching", "caching-key", { prompt_tokens: 1000, cache_read_input_tokens: 900 }), + keyRow("hash-leaky", "leaky-key", { prompt_tokens: 10000, cache_read_input_tokens: 0 }), + ], + }); + renderWith([]); + + expect(await screen.findByText("leaky-key")).toBeInTheDocument(); expect(screen.getByText("0.0%")).toBeInTheDocument(); expect(screen.getByText("90.0%")).toBeInTheDocument(); [ @@ -97,23 +112,42 @@ describe("CacheLeakageCard", () => { ].forEach((info) => expect(screen.getByLabelText(info)).toBeInTheDocument()); }); - it("sorts by the clicked column, worst cache hit rate first", () => { - renderWith([ - dayWithKeys("2026-07-12", { - "hash-a": key("alpha", { + it("asks the server for the key ranking under the activity scope", async () => { + renderWith([], { + scope: { + accessToken: "test-token", + startTime: new Date(2025, 0, 1), + endTime: new Date(2025, 0, 31), + userId: "u1", + apiKey: "hash-1", + }, + }); + + await screen.findByText("No key usage in this range."); + expect(mockCacheLeakageKeysCall).toHaveBeenCalledWith( + expect.objectContaining({ entityIds: ["u1"], apiKey: "hash-1", includeCurrentUtcDay: true }), + ); + }); + + it("sorts by the clicked column, worst cache hit rate first", async () => { + mockCacheLeakageKeysCall.mockResolvedValue({ + api_keys: [ + keyRow("hash-a", "alpha", { prompt_tokens: 10000, cache_read_input_tokens: 9000, prompt_caching_savings_spend: 9.0, }), - "hash-b": key("bravo", { + keyRow("hash-b", "bravo", { prompt_tokens: 500, cache_read_input_tokens: 50, prompt_caching_savings_spend: 0.05, }), - }), - ]); + ], + }); + renderWith([]); const firstDataRow = () => screen.getAllByRole("row")[1]; + expect(await screen.findByText("alpha")).toBeInTheDocument(); expect(firstDataRow()).toHaveTextContent("alpha"); fireEvent.click(screen.getByText("Cache hit rate")); @@ -123,7 +157,7 @@ describe("CacheLeakageCard", () => { expect(firstDataRow()).toHaveTextContent("alpha"); }); - it("switches to the model view and lists models from every provider", () => { + it("switches to the model view and lists models from every provider", async () => { renderWith([ dayWithModels("2026-07-12", { "claude-sonnet-5": { prompt_tokens: 5000, cache_read_input_tokens: 0 }, @@ -131,51 +165,32 @@ describe("CacheLeakageCard", () => { }), ]); - fireEvent.click(screen.getByText("By model")); + fireEvent.click(await screen.findByText("By model")); expect(screen.getByText("Cache leakage by model")).toBeInTheDocument(); expect(screen.getByText("claude-sonnet-5")).toBeInTheDocument(); expect(screen.getByText("vertex_ai/gemini-2.5-pro")).toBeInTheDocument(); }); - it("shows an empty state when no key used tokens in the range", () => { - renderWith([dayWithKeys("2026-07-12", {})]); + it("shows an empty state when no key used tokens in the range", async () => { + renderWith([]); - expect(screen.getByText("No key usage in this range.")).toBeInTheDocument(); + expect(await screen.findByText("No key usage in this range.")).toBeInTheDocument(); expect(screen.queryByRole("table")).not.toBeInTheDocument(); }); - it("tells the user the table is still filling in while fallback pages stream", () => { - const day = dayWithKeys("2026-07-12", { - "hash-leaky": key("leaky-key", { prompt_tokens: 10000, cache_read_input_tokens: 0 }), - }); - renderWith([day], { isFetchingMore: true }); + it("reports a load failure instead of claiming the range is empty", async () => { + mockCacheLeakageKeysCall.mockRejectedValue(new Error("route unavailable")); + renderWith([]); - expect(screen.getByRole("table")).toBeInTheDocument(); - expect( - screen.getByText("Data is still loading; rows and totals will update as the rest of the range arrives."), - ).toBeInTheDocument(); + expect(await screen.findByText("Could not load key usage for this range.")).toBeInTheDocument(); + expect(screen.queryByText("No key usage in this range.")).not.toBeInTheDocument(); }); - it("keeps the streaming note off while a fresh range loads over the previous range's rows", () => { - const day = dayWithKeys("2026-07-12", { - "hash-leaky": key("leaky-key", { prompt_tokens: 10000, cache_read_input_tokens: 0 }), - }); - renderWith([day], { loading: true }); + it("shows a loading state while the key ranking is in flight", () => { + mockCacheLeakageKeysCall.mockReturnValue(new Promise(() => {})); + renderWith([]); - expect( - screen.queryByText("Data is still loading; rows and totals will update as the rest of the range arrives."), - ).not.toBeInTheDocument(); - }); - - it("drops the streaming note once the range has settled", () => { - const day = dayWithKeys("2026-07-12", { - "hash-leaky": key("leaky-key", { prompt_tokens: 10000, cache_read_input_tokens: 0 }), - }); - renderWith([day]); - - expect( - screen.queryByText("Data is still loading; rows and totals will update as the rest of the range arrives."), - ).not.toBeInTheDocument(); + expect(screen.getByText("Loading...")).toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.tsx index cfac77788b7..7a63ccd1ee4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.tsx @@ -8,8 +8,17 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { formatNumberWithCommas } from "@/utils/dataUtils"; -import { CacheLeakageDimension, CacheLeakageRow, computeCacheLeakage, pct, usd } from "./costOptimizationUtils"; +import { + CacheLeakageDimension, + CacheLeakageRow, + computeCacheLeakage, + leakageRowsFromKeyRows, + netSavingsPerCachedToken, + pct, + usd, +} from "./costOptimizationUtils"; import { DailyActivityRange } from "./useDailyActivityRange"; +import { useCacheLeakageKeys } from "./useCacheLeakageKeys"; interface CacheLeakageCardProps { activity: DailyActivityRange; @@ -80,11 +89,20 @@ const SortableHead = ({ }; const CacheLeakageCard: React.FC = ({ activity }) => { - const { results, loading, isFetchingMore } = activity; + const { results, loading } = activity; const [dimension, setDimension] = useState("key"); const [sort, setSort] = useState({ column: "potentialSavings", dir: "desc" }); - const leakage = useMemo(() => computeCacheLeakage(results, dimension), [results, dimension]); - const rows = useMemo(() => [...leakage.rows].sort((a, b) => compareRows(a, b, sort)), [leakage.rows, sort]); + const leakageRate = useMemo(() => netSavingsPerCachedToken(results), [results]); + const keyLeakage = useCacheLeakageKeys(activity, dimension === "key"); + const unsortedRows = useMemo( + () => + dimension === "key" + ? leakageRowsFromKeyRows(keyLeakage.rows, leakageRate) + : computeCacheLeakage(results, "model").rows, + [dimension, keyLeakage.rows, leakageRate, results], + ); + const rows = useMemo(() => [...unsortedRows].sort((a, b) => compareRows(a, b, sort)), [unsortedRows, sort]); + const rowsLoading = dimension === "key" ? keyLeakage.loading : loading; const onSort = (column: SortColumn) => setSort((prev) => @@ -96,6 +114,10 @@ const CacheLeakageCard: React.FC = ({ activity }) => { const subject = dimension === "model" ? "Models" : "Keys"; const firstColumn = dimension === "model" ? "Model" : "Key"; const emptyNoun = dimension === "model" ? "model" : "key"; + const emptyMessage = + dimension === "key" && keyLeakage.failed + ? "Could not load key usage for this range." + : `No ${emptyNoun} usage in this range.`; return ( @@ -119,14 +141,9 @@ const CacheLeakageCard: React.FC = ({ activity }) => { - {rows.length > 0 && isFetchingMore && ( -

- Data is still loading; rows and totals will update as the rest of the range arrives. -

- )} {rows.length === 0 ? (

- {loading || isFetchingMore ? "Loading..." : `No ${emptyNoun} usage in this range.`} + {rowsLoading ? "Loading..." : emptyMessage}

) : ( diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.activity.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.activity.test.tsx index f8336f5ab56..204c4b1a409 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.activity.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.activity.test.tsx @@ -3,8 +3,7 @@ import { fireEvent, render, waitFor, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -const mockUserDailyActivityCall = vi.fn(); -const mockUserDailyActivityAggregatedCall = vi.fn(); +const mockDailyActivityAggregatedCall = vi.fn(); const { useAuthorizedMock, mockToolSpendResponse } = vi.hoisted(() => ({ useAuthorizedMock: vi.fn(), mockToolSpendResponse: { by_tool: [], daily: [], start_date: null, end_date: null }, @@ -15,8 +14,8 @@ vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ })); vi.mock("@/components/networking", () => ({ - userDailyActivityCall: (...args: unknown[]) => mockUserDailyActivityCall(...args), - userDailyActivityAggregatedCall: (...args: unknown[]) => mockUserDailyActivityAggregatedCall(...args), + dailyActivityAggregatedCall: (...args: unknown[]) => mockDailyActivityAggregatedCall(...args), + cacheLeakageKeysCall: vi.fn().mockResolvedValue({ api_keys: [] }), getToolSpend: vi.fn().mockResolvedValue(mockToolSpendResponse), getGeneralSettingsCall: vi.fn().mockResolvedValue([]), organizationListCall: vi.fn().mockResolvedValue([]), @@ -53,7 +52,7 @@ const singlePage = { describe("CostOptimizationView daily activity", () => { it("fetches daily activity once for the page and shares it with every tab that needs it", async () => { - mockUserDailyActivityAggregatedCall.mockResolvedValue(singlePage); + mockDailyActivityAggregatedCall.mockResolvedValue(singlePage); useAuthorizedMock.mockReturnValue({ accessToken: "test-token", userId: "u1", userRole: "proxy_admin" }); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); @@ -63,25 +62,17 @@ describe("CostOptimizationView daily activity", () => { , ); - await waitFor(() => expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(mockDailyActivityAggregatedCall).toHaveBeenCalledTimes(1)); fireEvent.click(screen.getByRole("tab", { name: "Prompt Caching" })); await screen.findByTestId("caching-settings"); - expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledTimes(1); - expect(mockUserDailyActivityCall).not.toHaveBeenCalled(); - expect(screen.queryByText(/Currently fetching spend data/)).not.toBeInTheDocument(); + expect(mockDailyActivityAggregatedCall).toHaveBeenCalledTimes(1); }); - it("shows the fetch-progress banner while the paginated fallback streams pages in", async () => { - mockUserDailyActivityAggregatedCall.mockReset(); - mockUserDailyActivityCall.mockReset(); - mockUserDailyActivityAggregatedCall.mockRejectedValue(new Error("aggregated unavailable")); - mockUserDailyActivityCall.mockImplementation((...args: unknown[]) => - args[3] === 1 - ? Promise.resolve({ results: [], metadata: { total_pages: 3, has_more: true, page: 1 } }) - : new Promise(() => {}), - ); + it("surfaces a failure alert when the aggregated fetch fails", async () => { + mockDailyActivityAggregatedCall.mockReset(); + mockDailyActivityAggregatedCall.mockRejectedValue(new Error("aggregated unavailable")); useAuthorizedMock.mockReturnValue({ accessToken: "test-token", userId: "u1", userRole: "proxy_admin" }); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); @@ -91,7 +82,6 @@ describe("CostOptimizationView daily activity", () => { , ); - expect(await screen.findByText(/Currently fetching spend data: fetched 1 \/ 3 pages/)).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Stop" })).toBeInTheDocument(); + expect(await screen.findByText(/Fetching spend data failed/)).toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.test.tsx index 028367555a1..a2f0ca5edc9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.test.tsx @@ -11,12 +11,7 @@ vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ vi.mock("@/components/networking", () => ({ organizationListCall: vi.fn().mockResolvedValue([]), - userDailyActivityCall: vi - .fn() - .mockResolvedValue({ results: [], metadata: { total_pages: 1, has_more: false, page: 1 } }), - userDailyActivityAggregatedCall: vi - .fn() - .mockResolvedValue({ results: [], metadata: { total_pages: 1, has_more: false, page: 1 } }), + dailyActivityAggregatedCall: vi.fn().mockResolvedValue({ results: [], metadata: {} }), })); vi.mock("./UsageTab", () => ({ __esModule: true, default: () =>
})); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx index 25bd3de0382..0aa4f88495a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx @@ -4,7 +4,7 @@ import React from "react"; import { Info, PiggyBank } from "lucide-react"; import useCan from "@/app/(dashboard)/hooks/useCan"; -import PaginationStatusAlerts from "@/components/shared/PaginationStatusAlerts"; +import { Alert, AlertDescription } from "@/components/shared/Alert"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { PageHeader } from "@/components/shared/PageHeader"; import UsageTab from "./UsageTab"; @@ -84,13 +84,14 @@ const CostOptimizationView: React.FC = ({ accessToken

- + {activity.failed && ( + + + Fetching spend data failed, so the savings below may be empty rather than final. Reload the page to try + again. + + + )} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingTab.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingTab.test.tsx index 35464c5852e..a4a06cb6db0 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingTab.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingTab.test.tsx @@ -1,6 +1,8 @@ import { fireEvent, render, waitFor, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; +import { EMPTY_DAILY_ACTIVITY_METADATA } from "@/components/UsagePage/dailyActivityApi"; + const mockGetGeneralSettingsCall = vi.fn(); vi.mock("@/components/networking", () => ({ @@ -46,12 +48,16 @@ describe("PromptCachingTab", () => { dateValue: {}, onDateChange: vi.fn(), results: [], + metadata: EMPTY_DAILY_ACTIVITY_METADATA, loading: false, - isFetchingMore: false, - progress: { currentPage: 1, totalPages: 1 }, - cancelled: false, failed: false, - cancel: vi.fn(), + scope: { + accessToken: "test-token", + startTime: null, + endTime: null, + userId: null, + apiKey: null, + }, }; render(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.test.tsx index c62208aacc5..1d88e25396b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.test.tsx @@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ToolSpendResponse } from "@/components/networking"; +import { EMPTY_DAILY_ACTIVITY_METADATA } from "@/components/UsagePage/dailyActivityApi"; import type { DailyData, SpendMetrics } from "@/components/UsagePage/types"; const mockGetToolSpend = vi.fn(); @@ -119,12 +120,16 @@ const renderWith = (results: DailyData[], options: RenderOptions = {}) => { dateValue: { from, to }, onDateChange: vi.fn(), results, + metadata: EMPTY_DAILY_ACTIVITY_METADATA, loading: false, - isFetchingMore: false, - progress: { currentPage: 1, totalPages: 1 }, - cancelled: false, failed: false, - cancel: vi.fn(), + scope: { + accessToken: "test-token", + startTime: from, + endTime: to, + userId: null, + apiKey: null, + }, }} />, ); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx index 83b202590cb..2d673d96296 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx @@ -44,7 +44,7 @@ const EMPTY_TOOL_SPEND: ToolSpendResponse = { const isoDay = (d: Date): string => d.toISOString().slice(0, 10); const UsageTab: React.FC = ({ accessToken, activity }) => { - const { dateValue, onDateChange, results, loading, isFetchingMore } = activity; + const { dateValue, onDateChange, results, loading } = activity; const startTime = dateValue.from ?? null; const endTime = dateValue.to ?? null; @@ -130,7 +130,7 @@ const UsageTab: React.FC = ({ accessToken, activity }) => { - +
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/costOptimizationUtils.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/costOptimizationUtils.ts index 5f16b1b04fd..b95e7e0d973 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/costOptimizationUtils.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/costOptimizationUtils.ts @@ -1,3 +1,4 @@ +import type { KeySpendActivityRow } from "@/components/UsagePage/dailyActivityApi"; import { DailyData, SpendMetrics } from "@/components/UsagePage/types"; import { ToolSpendDailyEntry, ToolSpendEntry } from "@/components/networking"; import { formatNumberWithCommas } from "@/utils/dataUtils"; @@ -101,6 +102,71 @@ const aggregateByModel = (results: readonly DailyData[]): Map { + const totals = [...aggregateByModel(results).values()].reduce( + (agg, a) => ({ + cachedTokens: agg.cachedTokens + a.cacheReadTokens + a.cacheCreationTokens, + realizedCachingSavings: agg.realizedCachingSavings + a.realizedCachingSavings, + }), + { cachedTokens: 0, realizedCachingSavings: 0 }, + ); + const rate = totals.cachedTokens > 0 ? totals.realizedCachingSavings / totals.cachedTokens : null; + return rate != null && rate > 0 ? rate : null; +}; + +const toLeakageRow = ( + id: string, + a: { + alias: string | null; + teamId: string | null; + promptTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + }, + rate: number | null, + dimension: CacheLeakageDimension, +): CacheLeakageRow => { + const uncachedPromptTokens = Math.max(0, a.promptTokens - a.cacheReadTokens - a.cacheCreationTokens); + return { + id, + label: dimension === "model" ? id : a.alias ?? `${id.slice(0, 8)}...`, + sublabel: dimension === "model" ? null : a.teamId, + uncachedPromptTokens, + cacheHitRatio: a.promptTokens > 0 ? a.cacheReadTokens / a.promptTokens : 0, + potentialSavings: rate != null ? uncachedPromptTokens * rate : null, + }; +}; + +const sortAndLimit = (rows: CacheLeakageRow[], rate: number | null, limit: number): CacheLeakageRow[] => + rows + .filter((row) => row.uncachedPromptTokens > 0) + .sort((x, y) => + rate != null + ? (y.potentialSavings ?? 0) - (x.potentialSavings ?? 0) + : y.uncachedPromptTokens - x.uncachedPromptTokens, + ) + .slice(0, limit); + +export const leakageRowsFromKeyRows = ( + rows: readonly KeySpendActivityRow[], + rate: number | null, + limit = 10, +): CacheLeakageRow[] => + sortAndLimit( + rows.map((row) => { + const metrics = { + alias: row.metadata.key_alias ?? null, + teamId: row.metadata.team_id ?? null, + promptTokens: row.metrics.prompt_tokens ?? 0, + cacheReadTokens: row.metrics.cache_read_input_tokens ?? 0, + cacheCreationTokens: row.metrics.cache_creation_input_tokens ?? 0, + }; + return toLeakageRow(row.api_key, metrics, rate, "key"); + }), + rate, + limit, + ); + export const computeCacheLeakage = ( results: readonly DailyData[], dimension: CacheLeakageDimension = "key", @@ -123,27 +189,13 @@ export const computeCacheLeakage = ( // A non-positive rate prices no leakage: there is no saving to extrapolate from const rate = netSavingsPerCachedToken != null && netSavingsPerCachedToken > 0 ? netSavingsPerCachedToken : null; - const rows: CacheLeakageRow[] = [...byEntity.entries()] - .map(([id, a]) => { - const uncachedPromptTokens = Math.max(0, a.promptTokens - a.cacheReadTokens - a.cacheCreationTokens); - return { - id, - label: dimension === "model" ? id : a.alias ?? `${id.slice(0, 8)}...`, - sublabel: dimension === "model" ? null : a.teamId, - uncachedPromptTokens, - cacheHitRatio: a.promptTokens > 0 ? a.cacheReadTokens / a.promptTokens : 0, - potentialSavings: rate != null ? uncachedPromptTokens * rate : null, - }; - }) - .filter((row) => row.uncachedPromptTokens > 0); - - const sorted = rows.sort((x, y) => - rate != null - ? (y.potentialSavings ?? 0) - (x.potentialSavings ?? 0) - : y.uncachedPromptTokens - x.uncachedPromptTokens, + const rows = sortAndLimit( + [...byEntity.entries()].map(([id, a]) => toLeakageRow(id, a, rate, dimension)), + rate, + limit, ); - return { rows: sorted.slice(0, limit), netSavingsPerCachedToken }; + return { rows, netSavingsPerCachedToken }; }; export interface DailyToolSpendPoint { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useCacheLeakageKeys.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useCacheLeakageKeys.ts new file mode 100644 index 00000000000..4febd774eb8 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useCacheLeakageKeys.ts @@ -0,0 +1,66 @@ +import { useEffect, useRef, useState } from "react"; + +import { cacheLeakageKeysCall } from "@/components/networking"; +import type { KeySpendActivityRow } from "@/components/UsagePage/dailyActivityApi"; +import type { DailyActivityRange } from "./useDailyActivityRange"; + +interface CacheLeakageKeysResult { + rows: KeySpendActivityRow[]; + loading: boolean; + failed: boolean; +} + +interface SettledKeys { + key: string; + rows: KeySpendActivityRow[]; + failed: boolean; +} + +export const useCacheLeakageKeys = (range: DailyActivityRange, enabled: boolean): CacheLeakageKeysResult => { + const { accessToken, startTime, endTime, userId, apiKey } = range.scope; + const [settled, setSettled] = useState(null); + const requestIdRef = useRef(0); + + const hasTimeRange = !!startTime && !!endTime; + const scopeReady = enabled && !!accessToken && hasTimeRange; + const scopeKey = scopeReady ? JSON.stringify([accessToken, startTime, endTime, userId, apiKey]) : null; + + useEffect(() => { + if (!scopeKey) return; + if (!accessToken || !startTime || !endTime) return; + + const requestId = ++requestIdRef.current; + const isStale = () => requestIdRef.current !== requestId; + + const request = { + accessToken, + startTime, + endTime, + entityIds: userId ? [userId] : null, + apiKey, + includeCurrentUtcDay: true, + }; + cacheLeakageKeysCall(request) + .then((response) => { + if (isStale()) return; + setSettled({ key: scopeKey, rows: response.api_keys, failed: false }); + }) + .catch((error) => { + if (isStale()) return; + console.error("Failed to fetch cache leakage keys:", error); + setSettled({ key: scopeKey, rows: [], failed: true }); + }); + + return () => { + requestIdRef.current++; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- scopeKey serializes the scope + }, [scopeKey]); + + const current = scopeKey !== null && settled?.key === scopeKey ? settled : null; + return { + rows: current?.rows ?? [], + loading: scopeKey !== null && current === null, + failed: current?.failed ?? false, + }; +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.test.tsx index b94fa45ecb7..90d6694ccba 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.test.tsx @@ -1,35 +1,35 @@ import { renderHook } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; -const mockUsePaginatedDailyActivity = vi.fn(); +import { EMPTY_DAILY_ACTIVITY_METADATA } from "@/components/UsagePage/dailyActivityApi"; -const mockCancel = vi.fn(); +const mockUseAggregatedDailyActivity = vi.fn(); -vi.mock("@/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity", () => ({ - usePaginatedDailyActivity: (args: unknown) => { - mockUsePaginatedDailyActivity(args); +vi.mock("@/app/(dashboard)/usage/_components/hooks/useAggregatedDailyActivity", () => ({ + useAggregatedDailyActivity: (options: unknown) => { + mockUseAggregatedDailyActivity(options); return { - data: { results: [] }, + data: { results: [], metadata: EMPTY_DAILY_ACTIVITY_METADATA }, loading: false, - isFetchingMore: false, - progress: { currentPage: 4, totalPages: 9 }, - cancelled: false, failed: false, - coversRange: true, - cancel: mockCancel, }; }, })); vi.mock("@/components/networking", () => ({ - userDailyActivityCall: vi.fn(), - userDailyActivityAggregatedCall: vi.fn(), + dailyActivityAggregatedCall: vi.fn().mockResolvedValue({ results: [], metadata: {} }), })); -import { userDailyActivityAggregatedCall } from "@/components/networking"; +import { dailyActivityAggregatedCall } from "@/components/networking"; import { useActivityDateRange, useDailyActivityRange } from "./useDailyActivityRange"; -const argsOfLastCall = () => mockUsePaginatedDailyActivity.mock.calls.at(-1)?.[0].args as unknown[]; +interface CapturedOptions { + fetch: () => Promise; + enabled: boolean; + deps: unknown[]; +} + +const lastOptions = () => mockUseAggregatedDailyActivity.mock.calls.at(-1)?.[0] as CapturedOptions; describe("useDailyActivityRange", () => { it("offers date-range state without starting a daily-activity query", () => { @@ -37,49 +37,53 @@ describe("useDailyActivityRange", () => { expect(result.current.dateValue.from).toBeInstanceOf(Date); expect(result.current.dateValue.to).toBeInstanceOf(Date); - expect(mockUsePaginatedDailyActivity).not.toHaveBeenCalled(); + expect(mockUseAggregatedDailyActivity).not.toHaveBeenCalled(); }); - it("queries every user's activity for an admin", () => { + it("fetches every user's activity for an admin through the aggregated endpoint", async () => { renderHook(() => useDailyActivityRange("test-token", "u1", "proxy_admin")); - expect(argsOfLastCall()).toEqual(["test-token", expect.any(Date), expect.any(Date), null, true, null]); + await lastOptions().fetch(); + expect(dailyActivityAggregatedCall).toHaveBeenCalledWith( + "user", + expect.objectContaining({ + accessToken: "test-token", + entityIds: null, + includeCurrentUtcDay: true, + }), + ); }); - it("scopes the query to the caller for a non-admin", () => { + it("scopes the query to the caller for a non-admin", async () => { renderHook(() => useDailyActivityRange("test-token", "u1", "internal_user")); - expect(argsOfLastCall()).toEqual(["test-token", expect.any(Date), expect.any(Date), "u1", true, null]); + await lastOptions().fetch(); + expect(dailyActivityAggregatedCall).toHaveBeenCalledWith("user", expect.objectContaining({ entityIds: ["u1"] })); }); it.each(["org_admin", "Org Admin"])( "scopes the query to the caller for %s, who has no admin view on this endpoint", - (role) => { + async (role) => { renderHook(() => useDailyActivityRange("test-token", "u1", role)); - expect(argsOfLastCall()).toEqual(["test-token", expect.any(Date), expect.any(Date), "u1", true, null]); + await lastOptions().fetch(); + expect(dailyActivityAggregatedCall).toHaveBeenCalledWith("user", expect.objectContaining({ entityIds: ["u1"] })); }, ); - it("fetches through the single-shot aggregated endpoint first so days never fragment across pages", () => { - renderHook(() => useDailyActivityRange("test-token", "u1", "proxy_admin")); - - expect(mockUsePaginatedDailyActivity).toHaveBeenLastCalledWith( - expect.objectContaining({ aggregatedFetchFn: userDailyActivityAggregatedCall }), - ); - }); - - it("forwards the pagination progress and cancel affordances instead of dropping them", () => { - const { result } = renderHook(() => useDailyActivityRange("test-token", "u1", "proxy_admin")); - - expect(result.current.progress).toEqual({ currentPage: 4, totalPages: 9 }); - expect(result.current.cancelled).toBe(false); - expect(result.current.cancel).toBe(mockCancel); - }); - it("stays disabled until an access token is available", () => { renderHook(() => useDailyActivityRange(null, "u1", "proxy_admin")); - expect(mockUsePaginatedDailyActivity).toHaveBeenLastCalledWith(expect.objectContaining({ enabled: false })); + expect(lastOptions().enabled).toBe(false); + }); + + it("exposes the request scope so sibling hooks fetch under the same filters", () => { + const { result } = renderHook(() => useDailyActivityRange("test-token", "u1", "internal_user")); + + expect(result.current.scope).toMatchObject({ + accessToken: "test-token", + userId: "u1", + apiKey: null, + }); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.ts index 605926132e9..1af009397b1 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.ts @@ -1,9 +1,15 @@ import { useMemo, useState } from "react"; -import { userDailyActivityAggregatedCall, userDailyActivityCall } from "@/components/networking"; +import { dailyActivityAggregatedCall } from "@/components/networking"; +import { + EMPTY_DAILY_ACTIVITY_METADATA, + toDailyData, + type DailyActivityMetadata, + type DailyActivityRequest, +} from "@/components/UsagePage/dailyActivityApi"; import { DailyData } from "@/components/UsagePage/types"; import { spendScopeUserId } from "@/utils/roles"; -import { usePaginatedDailyActivity } from "@/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity"; +import { useAggregatedDailyActivity } from "@/app/(dashboard)/usage/_components/hooks/useAggregatedDailyActivity"; const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000; @@ -12,30 +18,22 @@ export interface DateRange { to?: Date; } +export interface DailyActivityScope { + accessToken: string | null; + startTime: Date | null; + endTime: Date | null; + userId: string | null; + apiKey: string | null; +} + export interface DailyActivityRange { dateValue: DateRange; onDateChange: (value: DateRange) => void; results: DailyData[]; + metadata: DailyActivityMetadata; loading: boolean; - isFetchingMore: boolean; - progress: { currentPage: number; totalPages: number }; - cancelled: boolean; failed: boolean; - cancel: () => void; -} - -/** - * Which slice of daily activity to read. Both fields are passed straight through to the - * endpoint as filters, so the caller — not this hook — decides what the viewer may see. - * - * `userId: null` asks for the whole proxy, which the backend only honours for admins; - * a non-admin must send its own id or the request is rejected. That role decision lives in - * `useDailyActivityRange` below rather than in here, so a caller scoping to one key is not - * silently re-scoped to a user as well. - */ -export interface DailyActivityScope { - userId: string | null; - apiKey?: string | null; + scope: DailyActivityScope; } export type ActivityDateRange = Pick; @@ -47,39 +45,49 @@ export const useActivityDateRange = (): ActivityDateRange => { return { dateValue, onDateChange: setDateValue }; }; +export interface ScopedActivityInput { + userId: string | null; + apiKey?: string | null; +} + export const useScopedDailyActivityRange = ( accessToken: string | null, - scope: DailyActivityScope, + scope: ScopedActivityInput, { dateValue, onDateChange }: ActivityDateRange, ): DailyActivityRange => { const startTime = dateValue.from ?? null; const endTime = dateValue.to ?? null; const { userId, apiKey = null } = scope; - const activityQueryOptions = { - fetchFn: userDailyActivityCall, - aggregatedFetchFn: userDailyActivityAggregatedCall, - // Positional, and read by two functions whose signatures diverge at index 3: the paginated - // call takes `page` there (injected by the hook) and the aggregated one does not. Anything - // appended here must therefore be appended to BOTH networking signatures, in this order. - args: [accessToken, startTime, endTime, userId, true, apiKey], - enabled: !!accessToken && !!startTime && !!endTime, - }; - const { data, loading, isFetchingMore, progress, cancelled, failed, coversRange, cancel } = - usePaginatedDailyActivity(activityQueryOptions); - const readUnavailable = failed || cancelled; - const waitingForRange = activityQueryOptions.enabled && !coversRange && !readUnavailable; + const request = useMemo( + () => + accessToken && startTime && endTime + ? { + accessToken, + startTime, + endTime, + entityIds: userId ? [userId] : null, + apiKey, + includeCurrentUtcDay: true, + } + : null, + [accessToken, startTime, endTime, userId, apiKey], + ); + + const { data, loading, failed } = useAggregatedDailyActivity({ + fetch: () => dailyActivityAggregatedCall("user", request as DailyActivityRequest), + enabled: request !== null, + deps: [accessToken, startTime, endTime, userId, apiKey], + }); return { dateValue, onDateChange, - results: data.results as DailyData[], - loading: loading || waitingForRange, - isFetchingMore, - progress, - cancelled, + results: toDailyData(data), + metadata: data.metadata ?? EMPTY_DAILY_ACTIVITY_METADATA, + loading, failed, - cancel, + scope: { accessToken, startTime, endTime, userId, apiKey }, }; }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx index 8f5bb9455d0..23bd772729b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx @@ -5,7 +5,13 @@ import type { ReactNode } from "react"; import { useInfiniteUsers } from "@/app/(dashboard)/hooks/users/useUsers"; import useTeams from "@/app/(dashboard)/hooks/useTeams"; import * as networking from "@/components/networking"; -import type { DailyData, KeyMetadata, KeyMetricWithMetadata, SpendMetrics } from "@/components/UsagePage/types"; +import type { + DailyData, + KeyMetadata, + KeyMetricWithMetadata, + ModelActivityData, + SpendMetrics, +} from "@/components/UsagePage/types"; import EntityUsage from "./EntityUsage"; import { getGlobalTopKeys, getTopAPIKeys } from "./entityUsageAggregations"; @@ -51,21 +57,31 @@ beforeAll(() => { // Mock the networking module vi.mock("@/components/networking", () => ({ - tagDailyActivityCall: vi.fn(), - teamDailyActivityCall: vi.fn(), - teamDailyActivityAggregatedCall: vi.fn(), - organizationDailyActivityCall: vi.fn(), - customerDailyActivityCall: vi.fn(), - agentDailyActivityCall: vi.fn(), - userDailyActivityCall: vi.fn(), + dailyActivityAggregatedCall: vi.fn(), + dailyActivityKeyPageCall: vi.fn(), + dailyActivityKeySearchCall: vi.fn(), + dailyActivityModelTopKeysCall: vi.fn(), + dailyActivityExportCall: vi.fn(), })); // Mock the child components to simplify testing vi.mock("@/components/activity_metrics", () => ({ - ActivityMetrics: ({ modelMetrics }: { modelMetrics?: { __source?: string } }) => ( + ActivityMetrics: ({ + modelMetrics, + summaryMetrics, + summaryTitle = "Overall Usage", + fetchTopApiKeys, + }: { + modelMetrics?: { __source?: string }; + summaryMetrics?: ModelActivityData; + summaryTitle?: string; + fetchTopApiKeys?: (model: string) => Promise; + }) => (
Activity Metrics {`metrics-source:${modelMetrics?.__source ?? "none"}`} + {summaryMetrics !== undefined && {summaryTitle}} + {fetchTopApiKeys !== undefined && {`top-keys-fetcher:${modelMetrics?.__source ?? "none"}`}}
), processActivityData: (_data: unknown, key: string) => ({ __source: key }), @@ -126,7 +142,9 @@ vi.mock("@/app/(dashboard)/hooks/users/useUsers", () => ({ })); vi.mock("@/components/common_components/team_multi_select", () => ({ - default: () =>
Team Multi Select
, + default: ({ onChange }: { onChange: (value: string[]) => void }) => ( + + ), })); // Mock useTeams hook @@ -138,13 +156,22 @@ vi.mock("@/app/(dashboard)/hooks/useTeams", () => ({ })); describe("EntityUsage", () => { - const mockTagDailyActivityCall = vi.mocked(networking.tagDailyActivityCall); - const mockTeamDailyActivityCall = vi.mocked(networking.teamDailyActivityCall); - const mockTeamDailyActivityAggregatedCall = vi.mocked(networking.teamDailyActivityAggregatedCall); - const mockOrganizationDailyActivityCall = vi.mocked(networking.organizationDailyActivityCall); - const mockCustomerDailyActivityCall = vi.mocked(networking.customerDailyActivityCall); - const mockAgentDailyActivityCall = vi.mocked(networking.agentDailyActivityCall); - const mockUserDailyActivityCall = vi.mocked(networking.userDailyActivityCall); + const mockDailyActivityAggregatedCall = vi.mocked(networking.dailyActivityAggregatedCall); + const mockDailyActivityKeyPageCall = vi.mocked(networking.dailyActivityKeyPageCall); + const mockTagDailyActivityCall = vi.fn(); + const mockTeamDailyActivityCall = vi.fn(); + const mockOrganizationDailyActivityCall = vi.fn(); + const mockCustomerDailyActivityCall = vi.fn(); + const mockAgentDailyActivityCall = vi.fn(); + const mockUserDailyActivityCall = vi.fn(); + const entityMocks: Record> = { + tag: mockTagDailyActivityCall, + team: mockTeamDailyActivityCall, + organization: mockOrganizationDailyActivityCall, + customer: mockCustomerDailyActivityCall, + agent: mockAgentDailyActivityCall, + user: mockUserDailyActivityCall, + }; const mockUseInfiniteUsers = vi.mocked(useInfiniteUsers); const infiniteUsersResult = (users: { user_id: string; user_alias: string | null; user_email: string | null }[]) => @@ -440,16 +467,25 @@ describe("EntityUsage", () => { }; beforeEach(() => { - mockTagDailyActivityCall.mockClear(); - mockTeamDailyActivityCall.mockClear(); - mockTeamDailyActivityAggregatedCall.mockClear(); - mockOrganizationDailyActivityCall.mockClear(); - mockCustomerDailyActivityCall.mockClear(); - mockAgentDailyActivityCall.mockClear(); - mockUserDailyActivityCall.mockClear(); + mockDailyActivityAggregatedCall.mockReset(); + mockDailyActivityKeyPageCall.mockReset(); + const emptyKeyPage = { + api_keys: [], + total_api_keys: 0, + offset: 0, + limit: 50, + }; + mockDailyActivityKeyPageCall.mockResolvedValue(emptyKeyPage); + mockDailyActivityAggregatedCall.mockImplementation((entity, request) => + ( + entityMocks[entity] as unknown as ( + req: typeof request, + ) => ReturnType + )(request), + ); + Object.values(entityMocks).forEach((mock) => mock.mockClear()); mockTagDailyActivityCall.mockResolvedValue(mockSpendData); mockTeamDailyActivityCall.mockResolvedValue(mockSpendData); - mockTeamDailyActivityAggregatedCall.mockResolvedValue(mockSpendData); mockOrganizationDailyActivityCall.mockResolvedValue(mockSpendData); mockCustomerDailyActivityCall.mockResolvedValue(mockSpendData); mockAgentDailyActivityCall.mockResolvedValue(mockAgentSpendData); @@ -534,7 +570,7 @@ describe("EntityUsage", () => { render(); await waitFor(() => { - expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled(); + expect(mockTeamDailyActivityCall).toHaveBeenCalled(); }); // Check that it shows team-specific label @@ -650,6 +686,36 @@ describe("EntityUsage", () => { expect(screen.getAllByText("Activity Metrics")[1]).toBeInTheDocument(); }); + it("loads key pages separately from the aggregate using the current entity scope", async () => { + render(); + + await waitFor(() => { + expect(mockTeamDailyActivityCall).toHaveBeenCalledTimes(1); + }); + fireEvent.click(screen.getByText("Team Multi Select")); + await waitFor(() => { + expect(mockTeamDailyActivityCall).toHaveBeenCalledTimes(2); + }); + fireEvent.click(screen.getByText("Key Activity")); + await waitFor(() => { + expect(mockDailyActivityKeyPageCall).toHaveBeenCalledWith( + "team", + expect.objectContaining({ entityIds: ["team-1"] }), + 0, + 50, + ); + }); + expect(mockDailyActivityAggregatedCall.mock.lastCall?.[1]).not.toHaveProperty("apiKeyLimit"); + + const pageCallsBeforeFilterChange = mockDailyActivityKeyPageCall.mock.calls.length; + fireEvent.click(screen.getByText("Team Multi Select")); + + await waitFor(() => { + expect(mockDailyActivityKeyPageCall.mock.calls.length).toBeGreaterThan(pageCallsBeforeFilterChange); + }); + expect(mockDailyActivityKeyPageCall.mock.lastCall?.[1]).toEqual(expect.objectContaining({ entityIds: ["team-1"] })); + }); + // An inactive tab panel is marked aria-selected="false" by one tab library and hidden by the // other, so treat either as "not on screen" and the assertion holds whichever one is rendering. const isShowing = (element: HTMLElement): boolean => { @@ -671,7 +737,7 @@ describe("EntityUsage", () => { const NON_TEAM_PANELS: [string, string][] = [ ["Cost", "Tag Spend Overview"], ["Model Activity", "metrics-source:model_groups"], - ["Key Activity", "metrics-source:api_keys"], + ["Key Activity", "Overall Usage"], ["Endpoint Activity", "Endpoint Usage Panel"], ]; @@ -697,7 +763,7 @@ describe("EntityUsage", () => { ["Cost", "Team Spend Overview"], ["Model Activity", "metrics-source:model_groups"], ["Agent Activity", "metrics-source:entities"], - ["Key Activity", "metrics-source:api_keys"], + ["Key Activity", "Overall Usage"], ["Endpoint Activity", "Endpoint Usage Panel"], ]; @@ -705,7 +771,7 @@ describe("EntityUsage", () => { render(); await waitFor(() => { - expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled(); + expect(mockTeamDailyActivityCall).toHaveBeenCalled(); }); act(() => { @@ -878,7 +944,7 @@ describe("EntityUsage", () => { render(); await waitFor(() => { - expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled(); + expect(mockTeamDailyActivityCall).toHaveBeenCalled(); }); expect(screen.getByText("Agent Activity")).toBeInTheDocument(); @@ -898,7 +964,7 @@ describe("EntityUsage", () => { render(); await waitFor(() => { - expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled(); + expect(mockTeamDailyActivityCall).toHaveBeenCalled(); }); expect(screen.getByText("Top Agents Driving Spend")).toBeInTheDocument(); @@ -918,16 +984,56 @@ describe("EntityUsage", () => { render(); await waitFor(() => { - expect(mockAgentDailyActivityCall).toHaveBeenCalledWith( - "test-token", - expect.any(Date), - expect.any(Date), - 1, - null, - ); + expect(mockAgentDailyActivityCall).toHaveBeenCalledWith(expect.objectContaining({ accessToken: "test-token" })); }); }); + it("offers per-model top keys in Model Activity but not in the agent breakdown", async () => { + render(); + + await waitFor(() => { + expect(mockTeamDailyActivityCall).toHaveBeenCalled(); + }); + + act(() => { + fireEvent.click(screen.getByText("Model Activity")); + }); + expect(showingCount("top-keys-fetcher:model_groups")).toBeGreaterThan(0); + + act(() => { + fireEvent.click(screen.getByText("Agent Activity")); + }); + expect(showingCount("metrics-source:entities")).toBeGreaterThan(0); + expect(screen.queryByText("top-keys-fetcher:entities")).not.toBeInTheDocument(); + }); + + it("shows a loader instead of zero totals while the aggregate is in flight", async () => { + let resolveSpend: (value: typeof mockSpendData) => void = () => {}; + mockTagDailyActivityCall.mockReturnValue( + new Promise((resolve) => { + resolveSpend = resolve; + }), + ); + render(); + + await waitFor(() => { + expect(mockTagDailyActivityCall).toHaveBeenCalled(); + }); + expect(screen.getAllByText("Loading chart data...")).toHaveLength(2); + expect(screen.queryByText("Total Spend")).not.toBeInTheDocument(); + expect(screen.queryByText("Overall Usage")).not.toBeInTheDocument(); + expect(screen.queryByText("$0.00")).not.toBeInTheDocument(); + + await act(async () => { + resolveSpend(mockSpendData); + }); + + expect(screen.queryByText("Loading chart data...")).not.toBeInTheDocument(); + expect(screen.getByText("Overall Usage")).toBeInTheDocument(); + expect(screen.getByText("Total Spend")).toBeInTheDocument(); + expect(screen.getAllByText("$100.50").length).toBeGreaterThan(0); + }); + it("should not fetch agent activity data for non-team entity types", async () => { render(); @@ -942,7 +1048,7 @@ describe("EntityUsage", () => { render(); await waitFor(() => { - expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled(); + expect(mockTeamDailyActivityCall).toHaveBeenCalled(); }); const agentActivityTab = screen.getByText("Agent Activity"); @@ -1098,7 +1204,7 @@ describe("EntityUsage", () => { render(); await waitFor(() => { - expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled(); + expect(mockTeamDailyActivityCall).toHaveBeenCalled(); }); expect(screen.getByText("Team Spend Overview")).toBeInTheDocument(); @@ -1155,7 +1261,7 @@ describe("EntityUsage", () => { cache_read_input_tokens: 0, cache_creation_input_tokens: 0, }; - mockTeamDailyActivityAggregatedCall.mockResolvedValue({ + mockTeamDailyActivityCall.mockResolvedValue({ ...mockSpendData, results: [ { @@ -1183,31 +1289,71 @@ describe("EntityUsage", () => { expect(screen.getByText(/^top-models:Code Review Agent=/)).toBeInTheDocument(); }); - it("uses the aggregated team endpoint and never drains paginated pages for teams", async () => { + it("uses the aggregated team endpoint and makes a single bounded request", async () => { render(); await waitFor(() => { - expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled(); + expect(mockDailyActivityAggregatedCall).toHaveBeenCalledWith("team", expect.anything()); }); - expect(mockTeamDailyActivityCall).not.toHaveBeenCalled(); + expect(mockDailyActivityAggregatedCall.mock.calls.filter((c) => c[0] === "team")).toHaveLength(1); await waitFor(() => { expect(screen.getAllByText("$100.50").length).toBeGreaterThan(0); }); }); - it("falls back to the paginated team endpoint when the aggregated call fails", async () => { - mockTeamDailyActivityAggregatedCall.mockRejectedValue(new Error("aggregated unavailable")); - + it("does not scope the agent breakdown by the selected team ids", async () => { render(); await waitFor(() => { expect(mockTeamDailyActivityCall).toHaveBeenCalled(); + expect(mockAgentDailyActivityCall).toHaveBeenCalled(); }); + fireEvent.click(screen.getByRole("button", { name: "Team Multi Select" })); + await waitFor(() => { - expect(screen.getAllByText("$100.50").length).toBeGreaterThan(0); + const teamRequests = mockDailyActivityAggregatedCall.mock.calls.filter((call) => call[0] === "team"); + expect(teamRequests.some((call) => call[1].entityIds?.includes("team-1"))).toBe(true); }); + + const agentRequests = mockDailyActivityAggregatedCall.mock.calls.filter((call) => call[0] === "agent"); + expect(agentRequests.length).toBeGreaterThan(0); + agentRequests.forEach((call) => { + expect(call[1].entityIds).toBeNull(); + }); + }); + + it("does not refetch agent activity when the team selection changes", async () => { + render(); + + await waitFor(() => { + expect(mockAgentDailyActivityCall).toHaveBeenCalled(); + }); + const agentCallsBefore = mockDailyActivityAggregatedCall.mock.calls.filter((call) => call[0] === "agent").length; + const teamCallsBefore = mockDailyActivityAggregatedCall.mock.calls.filter((call) => call[0] === "team").length; + + fireEvent.click(screen.getByRole("button", { name: "Team Multi Select" })); + + await waitFor(() => { + expect(mockDailyActivityAggregatedCall.mock.calls.filter((call) => call[0] === "team").length).toBeGreaterThan( + teamCallsBefore, + ); + }); + expect(mockDailyActivityAggregatedCall.mock.calls.filter((call) => call[0] === "agent")).toHaveLength( + agentCallsBefore, + ); + }); + + it("surfaces a failure alert when the aggregated call fails instead of retrying other routes", async () => { + mockTeamDailyActivityCall.mockRejectedValue(new Error("aggregated unavailable")); + + render(); + + await waitFor(() => { + expect(screen.getAllByText(/Fetching spend data failed/).length).toBeGreaterThan(0); + }); + expect(mockDailyActivityAggregatedCall.mock.calls.filter((c) => c[0] === "team")).toHaveLength(1); }); describe("user filter (LIT-5654)", () => { @@ -1244,18 +1390,16 @@ describe("EntityUsage", () => { const user = userEvent.setup(); await renderUserUsage(); - expect(mockUserDailyActivityCall).toHaveBeenCalledWith("test-token", expect.any(Date), expect.any(Date), 1, null); + expect(mockUserDailyActivityCall).toHaveBeenCalledWith( + expect.objectContaining({ accessToken: "test-token", entityIds: null }), + ); await user.click(userCombobox()); await user.click(await screen.findByText("Alice (user-001)")); await waitFor(() => { expect(mockUserDailyActivityCall).toHaveBeenCalledWith( - "test-token", - expect.any(Date), - expect.any(Date), - 1, - "user-001", + expect.objectContaining({ accessToken: "test-token", entityIds: ["user-001"] }), ); }); @@ -1264,11 +1408,7 @@ describe("EntityUsage", () => { await waitFor(() => { expect(mockUserDailyActivityCall).toHaveBeenCalledWith( - "test-token", - expect.any(Date), - expect.any(Date), - 1, - null, + expect.objectContaining({ accessToken: "test-token", entityIds: null }), ); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx index 6687bd4df03..3f239250967 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx @@ -6,7 +6,6 @@ import { getTopAgents, getTopAPIKeys, getTopModels, - type ExtendedDailyData, type ProviderSpendRow, } from "./entityUsageAggregations"; import { buildCostBreakdownTiles, buildSummaryTiles, hasFlatCost, type SummaryTile } from "./entityUsageSummary"; @@ -17,27 +16,25 @@ import { formatNumberWithCommas } from "@/utils/dataUtils"; import type { DateRangePickerValue } from "@/components/shared/date_picker_types"; import { ChevronDown, ChevronRight, Info } from "lucide-react"; import type { ColumnDef } from "@tanstack/react-table"; -import PaginationStatusAlerts from "@/components/shared/PaginationStatusAlerts"; +import { Alert, AlertDescription } from "@/components/shared/Alert"; +import { ChartLoader } from "@/components/shared/chart_loader"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import React, { type ReactNode, useMemo, useState } from "react"; +import React, { type ReactNode, useCallback, useMemo, useState } from "react"; import TeamMultiSelect from "@/components/common_components/team_multi_select"; import UserDropdown from "@/components/common_components/UserDropdown"; import { ActivityMetrics, processActivityData } from "@/components/activity_metrics"; import { UsageExportHeader } from "@/components/EntityUsageExport"; -import { getExportBlockedReason } from "@/components/EntityUsageExport/exportBlockedReason"; import type { EntityType } from "@/components/EntityUsageExport/types"; -import { - agentDailyActivityCall, - customerDailyActivityCall, - organizationDailyActivityCall, - tagDailyActivityCall, - teamDailyActivityAggregatedCall, - teamDailyActivityCall, - userDailyActivityCall, -} from "@/components/networking"; import { Logo } from "@/components/molecules/logo/Logo"; -import { usePaginatedDailyActivity } from "../../hooks/usePaginatedDailyActivity"; +import { useAggregatedDailyActivity } from "../../hooks/useAggregatedDailyActivity"; +import { ENTITY_API } from "./entityFetchFns"; +import { + EMPTY_DAILY_ACTIVITY_METADATA, + toDailyData, + type DailyActivityRequest, +} from "@/components/UsagePage/dailyActivityApi"; +import { keyDetailFromResponse, overallUsageMetrics } from "@/components/UsagePage/keyActivityData"; import { EntityMetricWithMetadata } from "@/components/UsagePage/types"; import { valueFormatterSpend } from "@/components/UsagePage/utils/value_formatters"; import EndpointUsage from "../EndpointUsage/EndpointUsage"; @@ -62,18 +59,6 @@ interface EntityMetrics { metadata: Record; } -interface EntitySpendData { - results: ExtendedDailyData[]; - metadata: { - total_spend: number; - total_flat_cost?: number; - total_api_requests: number; - total_successful_requests: number; - total_failed_requests: number; - total_tokens: number; - }; -} - export interface EntityList { label: string; value: string; @@ -91,21 +76,6 @@ interface EntityUsageProps { isOrgAdmin?: boolean; } -const ENTITY_FETCH_FNS: Record Promise> = { - tag: tagDailyActivityCall, - team: teamDailyActivityCall, - organization: organizationDailyActivityCall, - customer: customerDailyActivityCall, - agent: agentDailyActivityCall, - user: userDailyActivityCall, -}; - -// Single-shot endpoints returning the whole range in one response; entity types -// without one fall back to page-draining the paginated endpoint. -const ENTITY_AGGREGATED_FETCH_FNS: Partial Promise>> = { - team: teamDailyActivityAggregatedCall, -}; - const ENTITY_CAPABILITIES: Partial> = { organization: "viewOrganizationUsage", agent: "viewAgentUsage", @@ -121,6 +91,7 @@ const EntityUsage: React.FC = ({ isOrgAdmin = false, }) => { const { teams } = useTeams(); + const teamList = useMemo(() => teams ?? [], [teams]); const [selectedTags, setSelectedTags] = useState([]); const [modelViewType, setModelViewType] = useState("groups"); const [topKeysLimit, setTopKeysLimit] = useState(5); @@ -130,56 +101,103 @@ const EntityUsage: React.FC = ({ const startTime = useMemo(() => (dateValue.from ? new Date(dateValue.from) : null), [dateValue.from]); const endTime = useMemo(() => (dateValue.to ? new Date(dateValue.to) : null), [dateValue.to]); - - const entityFilterArg = useMemo(() => { - if (entityType === "user") return selectedTags.length > 0 ? selectedTags[0] : null; - return selectedTags.length > 0 ? selectedTags : null; - }, [entityType, selectedTags]); - - const fetchFn = ENTITY_FETCH_FNS[entityType]; - const aggregatedFetchFn = ENTITY_AGGREGATED_FETCH_FNS[entityType]; + const api = ENTITY_API[entityType]; const entityCapability = ENTITY_CAPABILITIES[entityType]; const canViewEntity = entityCapability === undefined || hasCapability(userRole, entityCapability, isOrgAdmin); const showAgentBreakdown = entityType === "team" && hasCapability(userRole, "viewAgentUsage"); const hasRequestWindow = !!accessToken && !!startTime && !!endTime; const enabled = hasRequestWindow && canViewEntity; + const request = useMemo( + () => + hasRequestWindow + ? { + accessToken: accessToken as string, + startTime: startTime as Date, + endTime: endTime as Date, + entityIds: selectedTags.length > 0 ? selectedTags : null, + } + : null, + [hasRequestWindow, accessToken, startTime, endTime, selectedTags], + ); + const agentRequest = useMemo( + () => + hasRequestWindow + ? { + accessToken: accessToken as string, + startTime: startTime as Date, + endTime: endTime as Date, + entityIds: null, + } + : null, + [hasRequestWindow, accessToken, startTime, endTime], + ); + const { data: spendDataRaw, - isFetchingMore, - progress, - cancelled, + loading, failed, - coversRange, - cancel, - } = usePaginatedDailyActivity({ - fetchFn, - args: [accessToken, startTime, endTime, entityFilterArg], - enabled, - aggregatedFetchFn, + } = useAggregatedDailyActivity({ + fetch: () => api.aggregated(request as DailyActivityRequest), + enabled: enabled && request !== null, + deps: [entityType, accessToken, startTime, endTime, selectedTags], }); - const spendData = spendDataRaw as unknown as EntitySpendData; + const spendData = useMemo( + () => ({ + results: toDailyData(spendDataRaw), + metadata: spendDataRaw.metadata ?? EMPTY_DAILY_ACTIVITY_METADATA, + }), + [spendDataRaw], + ); + const summaryMetrics = useMemo(() => overallUsageMetrics(spendData.results, spendData.metadata), [spendData]); const { data: agentSpendDataRaw, - isFetchingMore: agentIsFetchingMore, - progress: agentProgress, - cancelled: agentCancelled, + loading: agentLoading, failed: agentFailed, - cancel: agentCancel, - } = usePaginatedDailyActivity({ - fetchFn: agentDailyActivityCall, - args: [accessToken, startTime, endTime, null], - enabled: enabled && showAgentBreakdown, + } = useAggregatedDailyActivity({ + fetch: () => ENTITY_API.agent.aggregated(agentRequest as DailyActivityRequest), + enabled: enabled && showAgentBreakdown && agentRequest !== null, + deps: [accessToken, startTime, endTime, showAgentBreakdown], }); - const agentSpendData = agentSpendDataRaw as unknown as EntitySpendData; + const agentSpendData = useMemo( + () => ({ + results: toDailyData(agentSpendDataRaw), + metadata: agentSpendDataRaw.metadata ?? EMPTY_DAILY_ACTIVITY_METADATA, + }), + [agentSpendDataRaw], + ); + + const fetchTopApiKeys = useCallback( + (model: string) => api.modelTopKeys(request as DailyActivityRequest, model, modelViewType === "groups"), + [api, request, modelViewType], + ); + const searchKeys = useCallback( + (query: string) => (request === null ? Promise.resolve({ api_keys: [] }) : api.searchKeys(request, query)), + [api, request], + ); + const fetchKeyPage = useCallback( + (offset: number, limit: number) => + request === null + ? Promise.resolve({ api_keys: [], total_api_keys: 0, offset, limit }) + : api.keyPage(request, offset, limit), + [api, request], + ); + const fetchKeyDetail = useCallback( + (apiKey: string) => + request === null + ? Promise.resolve(undefined) + : api + .aggregated({ ...request, apiKey, apiKeyLimit: 1 }) + .then((response) => keyDetailFromResponse(response, apiKey, teamList)), + [api, request, teamList], + ); const modelBreakdownKey = modelViewType === "groups" ? "model_groups" : "models"; - const modelMetrics = processActivityData(spendData, modelBreakdownKey, teams || []); - const keyMetrics = processActivityData(spendData, "api_keys", teams || []); - const agentMetrics = showAgentBreakdown ? processActivityData(agentSpendData, "entities", teams || []) : {}; + const modelMetrics = processActivityData(spendData, modelBreakdownKey, teamList); + const agentMetrics = showAgentBreakdown ? processActivityData(agentSpendData, "entities", teamList) : {}; const getAllTags = () => { if (entityList) { @@ -393,7 +411,9 @@ const EntityUsage: React.FC = ({ const modelViewTitle = modelViewType === "groups" ? "Top Public Model Names" : "Top Litellm Models"; - const costPanel = ( + const costPanel = loading ? ( + + ) : (
@@ -594,11 +614,15 @@ const EntityUsage: React.FC = ({

Top Agents Driving Spend

- + {agentLoading ? ( + + ) : ( + + )}
@@ -649,46 +673,67 @@ const EntityUsage: React.FC = ({
- + ), }, ...(showAgentBreakdown - ? [{ key: "agents", label: "Agent Activity", content: }] + ? [ + { + key: "agents", + label: "Agent Activity", + content: , + }, + ] : []), { key: "keys", label: "Key Activity", - content: , + content: ( + + ), }, { key: "endpoints", label: "Endpoint Activity", content: }, ]; - const spendFetchState = { coversRange, cancelled, failed }; - return (
- - {showAgentBreakdown && ( - + {failed && ( + + + Fetching spend data failed, so the totals below may be empty rather than final. Reload the page to try + again. + + + )} + {showAgentBreakdown && agentFailed && ( + + + Fetching agent data failed, so the totals below may be empty rather than final. Reload the page to try + again. + + )} + request + ? api.exportRows(request, exportType, format) + : Promise.reject(new Error("Select a date range to export")) + } showFilters={filterSlot === undefined && entityList !== null} filterSlot={filterSlot} filterLabel={getFilterLabel(entityType)} @@ -696,8 +741,7 @@ const EntityUsage: React.FC = ({ selectedFilters={selectedTags} onFiltersChange={setSelectedTags} filterOptions={getAllTags() || undefined} - teams={teams || []} - exportBlockedReason={getExportBlockedReason(spendFetchState)} + teams={teamList} /> diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/entityFetchFns.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/entityFetchFns.ts new file mode 100644 index 00000000000..6dc80ef4591 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/entityFetchFns.ts @@ -0,0 +1,52 @@ +import { + dailyActivityAggregatedCall, + dailyActivityExportCall, + dailyActivityKeyPageCall, + dailyActivityKeySearchCall, + dailyActivityModelTopKeysCall, +} from "@/components/networking"; +import type { EntityType } from "@/components/EntityUsageExport/types"; +import type { + DailyActivityAggregatedResponse, + DailyActivityEntity, + DailyActivityKeySearchResponse, + DailyActivityKeyPageResponse, + DailyActivityRequest, + ExportFormat, + ExportType, + ModelTopKeysResponse, +} from "@/components/UsagePage/dailyActivityApi"; + +export interface EntityApi { + aggregated(req: DailyActivityRequest): Promise; + keyPage(req: DailyActivityRequest, offset: number, limit: number): Promise; + searchKeys(req: DailyActivityRequest, search: string, limit?: number): Promise; + modelTopKeys( + req: DailyActivityRequest, + model: string, + byModelGroup: boolean, + limit?: number, + ): Promise; + exportRows(req: DailyActivityRequest, exportType: ExportType, format: ExportFormat): Promise; +} + +const entityApi = ( + entity: DailyActivityEntity, + defaults?: Pick, +): EntityApi => ({ + aggregated: (req) => dailyActivityAggregatedCall(entity, { ...defaults, ...req }), + keyPage: (req, offset, limit) => dailyActivityKeyPageCall(entity, { ...defaults, ...req }, offset, limit), + searchKeys: (req, search, limit) => dailyActivityKeySearchCall(entity, { ...defaults, ...req }, search, limit), + modelTopKeys: (req, model, byModelGroup, limit) => + dailyActivityModelTopKeysCall(entity, { ...defaults, ...req }, model, byModelGroup, limit), + exportRows: (req, exportType, format) => dailyActivityExportCall(entity, { ...defaults, ...req }, exportType, format), +}); + +export const ENTITY_API: Record = { + tag: entityApi("tag"), + team: entityApi("team", { excludeEntityIds: ["litellm-dashboard"] }), + organization: entityApi("organization"), + customer: entityApi("customer"), + agent: entityApi("agent"), + user: entityApi("user"), +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx index 7702488f5bf..41873ca554b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx @@ -25,8 +25,11 @@ beforeAll(() => { // Mock the networking module vi.mock("@/components/networking", () => ({ - userDailyActivityCall: vi.fn(), - userDailyActivityAggregatedCall: vi.fn(), + dailyActivityAggregatedCall: vi.fn(), + dailyActivityKeyPageCall: vi.fn(), + dailyActivityKeySearchCall: vi.fn(), + dailyActivityModelTopKeysCall: vi.fn(), + dailyActivityExportCall: vi.fn(), gatewayDailyActivityCall: vi.fn(), tagListCall: vi.fn(), })); @@ -66,16 +69,25 @@ vi.mock("./EndpointUsage/EndpointUsage", () => ({ vi.mock("./UsageViewSelect/UsageViewSelect", async () => { const React = await import("react"); - const UsageViewSelect = ({ value, onChange, canViewTagUsage = false }: any) => { + const UsageViewSelect = ({ + value, + onChange, + canViewTagUsage = false, + }: { + value: string; + onChange?: (value: string) => void; + canViewTagUsage?: boolean; + }) => { const tagOption = canViewTagUsage ? React.createElement("option", { value: "tag" }, "Tag Usage") : null; + const selectProps = { + value, + onChange: (e: React.ChangeEvent) => onChange?.(e.target.value), + role: "combobox", + "data-testid": "usage-view-select", + }; return React.createElement( "select", - { - value, - onChange: (e: any) => onChange?.(e.target.value), - role: "combobox", - "data-testid": "usage-view-select", - }, + selectProps, React.createElement("option", { value: "global" }, "Global Usage"), React.createElement("option", { value: "team" }, "Team Usage"), React.createElement("option", { value: "organization" }, "Organization Usage"), @@ -157,8 +169,9 @@ vi.mock("@/app/(dashboard)/hooks/users/useUsers", () => ({ })); describe("UsagePage", () => { - const mockUserDailyActivityAggregatedCall = vi.mocked(networking.userDailyActivityAggregatedCall); - const mockUserDailyActivityCall = vi.mocked(networking.userDailyActivityCall); + const mockUserDailyActivityAggregatedCall = vi.fn(); + const mockDailyActivityAggregatedCall = vi.mocked(networking.dailyActivityAggregatedCall); + const mockDailyActivityKeyPageCall = vi.mocked(networking.dailyActivityKeyPageCall); const mockTagListCall = vi.mocked(networking.tagListCall); const mockGatewayDailyActivityCall = vi.mocked(networking.gatewayDailyActivityCall); const mockUseCustomers = vi.mocked(useCustomers); @@ -374,7 +387,17 @@ describe("UsagePage", () => { error: null, } as any); mockUserDailyActivityAggregatedCall.mockClear(); - mockUserDailyActivityCall.mockClear(); + mockDailyActivityAggregatedCall.mockReset(); + mockDailyActivityKeyPageCall.mockReset(); + mockDailyActivityKeyPageCall.mockResolvedValue({ + api_keys: [], + total_api_keys: 0, + offset: 0, + limit: 50, + }); + mockDailyActivityAggregatedCall.mockImplementation((entity: string, request: unknown) => + entity === "user" ? mockUserDailyActivityAggregatedCall(request) : Promise.resolve({ results: [], metadata: {} }), + ); mockTagListCall.mockClear(); mockGatewayDailyActivityCall.mockClear(); mockUserDailyActivityAggregatedCall.mockResolvedValue(mockSpendData); @@ -462,6 +485,11 @@ describe("UsagePage", () => { expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledTimes(2); }); expect(screen.queryByText("75,000")).not.toBeInTheDocument(); + expect(screen.queryByText("$0.00")).not.toBeInTheDocument(); + expect(screen.getByText("Total Tokens")).toBeInTheDocument(); + expect(await screen.findByText("425,151")).toBeInTheDocument(); + expect(screen.getByText("Total Tokens").closest('[data-slot="card"]')).not.toHaveTextContent(/\d/); + expect(screen.queryByText("$0.0000")).not.toBeInTheDocument(); await act(async () => { releaseSecondFetch(); @@ -469,6 +497,28 @@ describe("UsagePage", () => { await waitFor(() => { expect(screen.getAllByText("75,000").length).toBeGreaterThan(0); }); + expect(screen.getByText("Total Tokens")).toBeInTheDocument(); + expect(screen.getByText("$0.0838")).toBeInTheDocument(); + }); + + it("loads key pages separately from the aggregate and refreshes them when the range changes", async () => { + renderWithProviders(); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); + }); + fireEvent.click(screen.getByText("Key Activity")); + await waitFor(() => { + expect(mockDailyActivityKeyPageCall).toHaveBeenCalledWith("user", expect.any(Object), 0, 50); + }); + expect(mockUserDailyActivityAggregatedCall.mock.lastCall?.[0]).not.toHaveProperty("apiKeyLimit"); + const pageCallsBeforeRangeChange = mockDailyActivityKeyPageCall.mock.calls.length; + fireEvent.click(screen.getByTestId("pick-a-different-range")); + + await waitFor(() => { + expect(mockDailyActivityKeyPageCall.mock.calls.length).toBeGreaterThan(pageCallsBeforeRangeChange); + }); + expect(mockUserDailyActivityAggregatedCall.mock.lastCall?.[0]).not.toHaveProperty("apiKeyLimit"); }); it("should fall back to the spend-derived count when the gateway endpoint is unavailable", async () => { @@ -793,7 +843,7 @@ describe("UsagePage", () => { it.each(["organization", "agent"])("should not render the %s usage view for an internal user", async (usageView) => { mockUseAuthorized.mockReturnValue(nonAdminSession); - + mockDailyActivityKeyPageCall.mockImplementation(() => new Promise(() => {})); renderWithProviders(); await waitFor(() => { @@ -924,10 +974,7 @@ describe("UsagePage", () => { // Initially called with null (global view for admin) expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledWith( - "test-token", - expect.any(Date), - expect.any(Date), - null, + expect.objectContaining({ accessToken: "test-token", entityIds: null }), ); }); }); @@ -1016,127 +1063,23 @@ describe("UsagePage", () => { await waitFor(() => { expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledWith( - "test-token", - expect.any(Date), - expect.any(Date), - "user-123", + expect.objectContaining({ accessToken: "test-token", entityIds: ["user-123"] }), ); }); }); }); - describe("aggregated endpoint fallback", () => { - it("should fall back to paginated calls when aggregated endpoint fails", async () => { + describe("aggregated endpoint failure", () => { + it("shows the failure alert instead of retrying other routes when the aggregated call fails", async () => { mockUserDailyActivityAggregatedCall.mockRejectedValue(new Error("Aggregated endpoint not available")); - mockUserDailyActivityCall.mockResolvedValue({ - ...mockSpendData, - metadata: { - ...mockSpendData.metadata, - total_pages: 1, - page: 1, - }, - }); renderWithProviders(); await waitFor(() => { expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); - expect(mockUserDailyActivityCall).toHaveBeenCalled(); }); - - // Should still render the data from the paginated fallback, which lands a render after the call - expect(await screen.findByText("75,000")).toBeInTheDocument(); - }); - - it("should stop showing the previous range's paginated pages while a new range is in flight", async () => { - // Same rule as the aggregate, one fallback further down. The flag that - // decides whether these pages are read belongs to the range the failure - // happened on, or the previous range's pages reach the tile through it. - let releaseSecondAggregated: () => void = () => {}; - mockUserDailyActivityAggregatedCall.mockReset(); - mockUserDailyActivityAggregatedCall - .mockRejectedValueOnce(new Error("Aggregated endpoint not available")) - .mockImplementationOnce( - () => - new Promise((_resolve, reject) => { - releaseSecondAggregated = () => reject(new Error("Aggregated endpoint not available")); - }), - ); - mockUserDailyActivityCall.mockResolvedValue({ - ...mockSpendData, - metadata: { ...mockSpendData.metadata, total_pages: 1, page: 1 }, - }); - - renderWithProviders(); - await waitFor(() => { - expect(screen.getAllByText("75,000").length).toBeGreaterThan(0); - }); - - await act(async () => { - fireEvent.click(screen.getByTestId("pick-a-different-range")); - }); - - await waitFor(() => { - expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledTimes(2); - }); - expect(screen.queryByText("75,000")).not.toBeInTheDocument(); - - await act(async () => { - releaseSecondAggregated(); - }); - await waitFor(() => { - expect(screen.getAllByText("75,000").length).toBeGreaterThan(0); - }); - }); - - 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(); - - 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); + expect(mockDailyActivityAggregatedCall.mock.calls.filter((c) => c[0] === "user")).toHaveLength(1); + expect(await screen.findByText(/Fetching spend data failed/)).toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx index 018dfc84740..6f18b2fe1d2 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx @@ -8,15 +8,15 @@ import { ChevronDown, ChevronRight, Download, Info, Sparkles, X } from "lucide-react"; import type { DateRangePickerValue } from "@/components/shared/date_picker_types"; -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import React, { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { BarChart } from "@/components/shared/charts"; import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/shared/Alert"; -import PaginationStatusAlerts from "@/components/shared/PaginationStatusAlerts"; import { Button } from "@/components/ui/button"; import { Card as ShadcnCard, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { Skeleton } from "@/components/ui/skeleton"; import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents"; import { useCustomers } from "@/app/(dashboard)/hooks/customers/useCustomers"; @@ -30,23 +30,23 @@ import { ActivityMetrics, processActivityData } from "@/components/activity_metr import CloudZeroExportModal from "@/components/cloudzero_export_modal"; import UserDropdown from "@/components/common_components/UserDropdown"; import EntityUsageExportModal from "@/components/EntityUsageExport"; -import { getExportBlockedReason } from "@/components/EntityUsageExport/exportBlockedReason"; import KeyActivityPanel from "@/components/UsagePage/components/KeyActivityPanel"; import { Team } from "@/components/key_team_helpers/key_list"; -import { - gatewayDailyActivityCall, - Organization, - tagListCall, - userDailyActivityAggregatedCall, - userDailyActivityCall, -} from "@/components/networking"; +import { gatewayDailyActivityCall, Organization, tagListCall } from "@/components/networking"; import AdvancedDatePicker from "@/components/shared/advanced_date_picker"; import { ChartLoader } from "@/components/shared/chart_loader"; import { Tag } from "@/components/tag_management/types"; import UserAgentActivity from "@/components/user_agent_activity"; import ViewUserSpend from "@/components/view_user_spend"; -import { usePaginatedDailyActivity } from "../hooks/usePaginatedDailyActivity"; -import { DailyData, MetricWithMetadata } from "@/components/UsagePage/types"; +import { useAggregatedDailyActivity } from "../hooks/useAggregatedDailyActivity"; +import { ENTITY_API } from "./EntityUsage/entityFetchFns"; +import { + EMPTY_DAILY_ACTIVITY_METADATA, + toDailyData, + type DailyActivityRequest, +} from "@/components/UsagePage/dailyActivityApi"; +import { keyDetailFromResponse, overallUsageMetrics } from "@/components/UsagePage/keyActivityData"; +import { MetricWithMetadata } from "@/components/UsagePage/types"; import { valueFormatterSpend } from "@/components/UsagePage/utils/value_formatters"; import { fetchedRangeKey, @@ -72,18 +72,11 @@ interface UsagePageProps { organizations: Organization[]; } +const MetricValue = ({ pending, className, children }: { pending: boolean; className: string; children: ReactNode }) => + pending ? :

{children}

; + const UsagePage: React.FC = ({ teams, organizations }) => { const { accessToken, userRole, userId: userID, premiumUser } = useAuthorized(); - // Aggregated endpoint: try first, fall back to paginated if unavailable - const [aggregatedData, setAggregatedData] = useState | null>(null); - // Stamped like the data itself: the flag decides whether the paginated - // fallback is read, and a flag left over from the previous range would let - // that fallback's own leftover rows through. - const [aggregatedFailure, setAggregatedFailure] = useState | null>(null); - const [aggregatedLoading, setAggregatedLoading] = useState(false); const [gatewayActivityData, setGatewayActivityData] = useState(null); // Separate loading states for better UX @@ -142,7 +135,6 @@ const UsagePage: React.FC = ({ teams, organizations }) => { const startTime = useMemo(() => (dateValue.from ? new Date(dateValue.from) : null), [dateValue.from]); const endTime = useMemo(() => (dateValue.to ? new Date(dateValue.to) : null), [dateValue.to]); - // Stamped and selected during render like the request tiles below: the tag // filter reads "no tags" from an empty list, so a list left over from the // previous range would state that about a range nobody has measured yet. @@ -181,30 +173,29 @@ const UsagePage: React.FC = ({ teams, organizations }) => { // can paint them. One source is not enough, since the tiles read the gateway // counts, fall through to the aggregate, and fall through again to the // paginated pages, so a stamp on any one of them is escaped by the next. - const currentAggregatedRangeKey = fetchedRangeKey(startTime, endTime, effectiveUserId); const currentGatewayRangeKey = fetchedRangeKey(startTime, endTime); - // Try aggregated endpoint first, fall back to paginated on failure - const aggregatedFetchIdRef = useRef(0); - useEffect(() => { - if (!accessToken || !startTime || !endTime) return; - const fetchId = ++aggregatedFetchIdRef.current; - const rangeKey = currentAggregatedRangeKey; - setAggregatedLoading(true); - - userDailyActivityAggregatedCall(accessToken, startTime, endTime, effectiveUserId) - .then((data) => { - if (aggregatedFetchIdRef.current !== fetchId) return; - setAggregatedData({ rangeKey, value: data }); - setAggregatedLoading(false); - setIsDateChanging(false); - }) - .catch(() => { - if (aggregatedFetchIdRef.current !== fetchId) return; - setAggregatedFailure({ rangeKey, value: true }); - setAggregatedLoading(false); - }); - }, [accessToken, startTime, endTime, effectiveUserId, currentAggregatedRangeKey]); + const dailyActivityRequest = useMemo( + () => + accessToken && startTime && endTime + ? { + accessToken, + startTime, + endTime, + entityIds: effectiveUserId ? [effectiveUserId] : null, + } + : null, + [accessToken, startTime, endTime, effectiveUserId], + ); + const { + data: aggregatedRaw, + loading: aggregatedLoading, + failed: aggregatedFailed, + } = useAggregatedDailyActivity({ + fetch: () => ENTITY_API.user.aggregated(dailyActivityRequest as DailyActivityRequest), + enabled: dailyActivityRequest !== null, + deps: [accessToken, startTime, endTime, effectiveUserId], + }); // Gateway request counts (SGR). Admin-only: the source table is // deployment-wide, so a non-admin must not see it. @@ -228,43 +219,28 @@ const UsagePage: React.FC = ({ teams, organizations }) => { }, [isAdmin, gatewayRequest, currentGatewayRangeKey]); const gatewayActivity = selectGatewayActivity(isAdmin, gatewayActivityData, currentGatewayRangeKey); - const activeAggregated = selectForRange(aggregatedData, currentAggregatedRangeKey); - // A failure belongs to the range it happened on. Reading it through the same - // rule keeps the paginated hook disabled while a new range is in flight, and - // disabled is what empties it, so its previous rows never reach a tile. - const aggregatedFailed = selectForRange(aggregatedFailure, currentAggregatedRangeKey) === true; - // Paginated fallback — only enabled when aggregated endpoint fails - const paginatedResult = usePaginatedDailyActivity({ - fetchFn: userDailyActivityCall, - args: [accessToken, startTime, endTime, effectiveUserId], - enabled: aggregatedFailed && !!accessToken && !!startTime && !!endTime, - }); + const userSpendData = useMemo( + () => ({ + results: toDailyData(aggregatedRaw), + metadata: aggregatedRaw.metadata ?? EMPTY_DAILY_ACTIVITY_METADATA, + }), + [aggregatedRaw], + ); - // Derive userSpendData from whichever source is active - const userSpendData = useMemo(() => { - if (activeAggregated) return activeAggregated; - if (aggregatedFailed) return paginatedResult.data; - return { results: [] as DailyData[], metadata: {} as any }; - }, [activeAggregated, aggregatedFailed, paginatedResult.data]); + const loading = aggregatedLoading; + const requestCountsPending = loading && gatewayActivity === null; - const loading = aggregatedLoading || paginatedResult.loading; + const summaryMetrics = useMemo( + () => overallUsageMetrics(userSpendData.results, userSpendData.metadata), + [userSpendData], + ); - // Read through the same range stamp as the tiles, so the export is blocked from the first - // render of a new range rather than from whenever the fetch effect gets around to running. - const spendFetchState = { - coversRange: activeAggregated !== null || paginatedResult.coversRange, - cancelled: paginatedResult.cancelled, - failed: paginatedResult.failed, - }; - const exportBlockedReason = getExportBlockedReason(spendFetchState); - - // Clear isDateChanging when paginated data starts arriving useEffect(() => { - if (aggregatedFailed && !paginatedResult.loading && paginatedResult.data.results.length > 0) { + if (!loading) { setIsDateChanging(false); } - }, [aggregatedFailed, paginatedResult.loading, paginatedResult.data.results.length]); + }, [loading]); // Super responsive date change handler const handleDateChange = useCallback((newValue: DateRangePickerValue) => { @@ -432,12 +408,43 @@ const UsagePage: React.FC = ({ teams, organizations }) => { () => processActivityData(userSpendData, modelViewType === "groups" ? "model_groups" : "models", teams), [userSpendData, modelViewType, teams], ); - const keyMetrics = useMemo(() => processActivityData(userSpendData, "api_keys", teams), [userSpendData, teams]); const mcpServerMetrics = useMemo( () => processActivityData(userSpendData, "mcp_servers", teams), [userSpendData, teams], ); + const fetchTopApiKeys = useCallback( + (model: string) => + ENTITY_API.user.modelTopKeys(dailyActivityRequest as DailyActivityRequest, model, modelViewType === "groups"), + [dailyActivityRequest, modelViewType], + ); + const searchKeys = useCallback( + (query: string) => + dailyActivityRequest === null + ? Promise.resolve({ api_keys: [] }) + : ENTITY_API.user.searchKeys(dailyActivityRequest, query), + [dailyActivityRequest], + ); + const fetchKeyPage = useCallback( + (offset: number, limit: number) => { + if (dailyActivityRequest === null) { + const emptyPage = { api_keys: [], total_api_keys: 0, offset, limit }; + return Promise.resolve(emptyPage); + } + return ENTITY_API.user.keyPage(dailyActivityRequest, offset, limit); + }, + [dailyActivityRequest], + ); + const fetchKeyDetail = useCallback( + (apiKey: string) => + dailyActivityRequest === null + ? Promise.resolve(undefined) + : ENTITY_API.user + .aggregated({ ...dailyActivityRequest, apiKey, apiKeyLimit: 1 }) + .then((response) => keyDetailFromResponse(response, apiKey, teams)), + [dailyActivityRequest, teams], + ); + return (
{/* Global Date Picker and Tabs - Single Row */} @@ -453,13 +460,14 @@ const UsagePage: React.FC = ({ teams, organizations }) => { />
- + {aggregatedFailed && ( + + + Fetching spend data failed, so the totals below may be empty rather than final. Reload the page to try + again. + + + )} {/* Your Usage / Global Usage Panel */} {(usageView === "global" || usageView === "my-usage") && ( <> @@ -493,16 +501,10 @@ const UsagePage: React.FC = ({ teams, organizations }) => { Ask AI - - - +
{/* Cost Panel */} @@ -532,11 +534,13 @@ const UsagePage: React.FC = ({ teams, organizations }) => {

- + {!loading && ( + + )}
@@ -547,12 +551,12 @@ const UsagePage: React.FC = ({ teams, organizations }) => {

Total Requests

-

+ {(gatewayActivity ? gatewayActivity.total_successful_requests + gatewayActivity.total_failed_requests : userSpendData.metadata?.total_api_requests )?.toLocaleString() || 0} -

+
@@ -577,12 +581,15 @@ const UsagePage: React.FC = ({ teams, organizations }) => { today: a non-admin (who may not read deployment-wide counts) and an admin on a proxy whose table is still backfilling. */} -

+ {( gatewayActivity?.total_successful_requests ?? userSpendData.metadata?.total_successful_requests )?.toLocaleString() || 0} -

+
@@ -602,24 +609,27 @@ const UsagePage: React.FC = ({ teams, organizations }) => {
{/* Same source as Successful Requests: the two must agree, or the tile disagrees with the endpoint breakdown chart below it. */} -

+ {( gatewayActivity?.total_failed_requests ?? userSpendData.metadata?.total_failed_requests )?.toLocaleString() || 0} -

+

Average Cost per Request

-

+ $ {formatNumberWithCommas( (totalSpend || 0) / (userSpendData.metadata?.total_api_requests || 1), 4, )} -

+
= ({ teams, organizations }) => { )} -

+ {userSpendData.metadata?.total_tokens?.toLocaleString() || 0} -

+
@@ -646,33 +656,33 @@ const UsagePage: React.FC = ({ teams, organizations }) => {

Input Tokens

-

+ {(userSpendData.metadata?.total_prompt_tokens || 0).toLocaleString()} -

+

Output Tokens

-

+ {userSpendData.metadata?.total_completion_tokens?.toLocaleString() || 0} -

+

Cache Read Tokens

-

+ {userSpendData.metadata?.total_cache_read_input_tokens?.toLocaleString() || 0} -

+

Cache Write Tokens

-

+ {userSpendData.metadata?.total_cache_creation_input_tokens?.toLocaleString() || 0} -

+
@@ -858,10 +868,20 @@ const UsagePage: React.FC = ({ teams, organizations }) => {
- + - + @@ -1004,11 +1024,12 @@ const UsagePage: React.FC = ({ teams, organizations }) => { setIsGlobalExportModalOpen(false)} - entityType="team" - spendData={{ - results: userSpendData.results, - metadata: userSpendData.metadata, - }} + entityType="user" + onExport={(exportType, format) => + dailyActivityRequest + ? ENTITY_API.user.exportRows(dailyActivityRequest, exportType, format) + : Promise.reject(new Error("Missing access token or date range")) + } dateRange={dateValue} selectedFilters={[]} customTitle="Export Usage Data" diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/useAggregatedDailyActivity.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/useAggregatedDailyActivity.test.ts new file mode 100644 index 00000000000..16a45d03db9 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/useAggregatedDailyActivity.test.ts @@ -0,0 +1,71 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { + EMPTY_DAILY_ACTIVITY_METADATA, + type DailyActivityAggregatedResponse, +} from "@/components/UsagePage/dailyActivityApi"; +import { useAggregatedDailyActivity } from "./useAggregatedDailyActivity"; + +const response = (spend: number): DailyActivityAggregatedResponse => ({ + results: [], + metadata: { ...EMPTY_DAILY_ACTIVITY_METADATA, total_spend: spend }, +}); + +describe("useAggregatedDailyActivity", () => { + it("calls fetch once per deps change and exposes the data", async () => { + const fetch = vi.fn().mockResolvedValue(response(12)); + const { result, rerender } = renderHook( + ({ dep }: { dep: string }) => useAggregatedDailyActivity({ fetch, enabled: true, deps: [dep] }), + { initialProps: { dep: "a" } }, + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(fetch).toHaveBeenCalledTimes(1); + expect(result.current.data.metadata?.total_spend).toBe(12); + + rerender({ dep: "b" }); + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); + }); + + it("does not fetch while disabled and returns empty data", () => { + const fetch = vi.fn(); + const { result } = renderHook(() => useAggregatedDailyActivity({ fetch, enabled: false, deps: ["a"] })); + + expect(fetch).not.toHaveBeenCalled(); + expect(result.current.data.results).toEqual([]); + expect(result.current.loading).toBe(false); + expect(result.current.failed).toBe(false); + }); + + it("exposes failed on rejection", async () => { + const fetch = vi.fn().mockRejectedValue(new Error("boom")); + const { result } = renderHook(() => useAggregatedDailyActivity({ fetch, enabled: true, deps: ["a"] })); + + await waitFor(() => expect(result.current.failed).toBe(true)); + expect(result.current.loading).toBe(false); + }); + + it("ignores an out-of-order resolution from the previous deps", async () => { + let resolveFirst: ((value: DailyActivityAggregatedResponse) => void) | undefined; + const first = new Promise((resolve) => { + resolveFirst = resolve; + }); + const fetch = vi + .fn() + .mockImplementationOnce(() => first) + .mockResolvedValueOnce(response(99)); + + const { result, rerender } = renderHook( + ({ dep }: { dep: string }) => useAggregatedDailyActivity({ fetch, enabled: true, deps: [dep] }), + { initialProps: { dep: "a" } }, + ); + + rerender({ dep: "b" }); + await act(async () => { + resolveFirst?.(response(1)); + }); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.data.metadata?.total_spend).toBe(99); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/useAggregatedDailyActivity.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/useAggregatedDailyActivity.ts new file mode 100644 index 00000000000..08aec2e77f2 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/useAggregatedDailyActivity.ts @@ -0,0 +1,65 @@ +import { useEffect, useRef, useState } from "react"; +import { + EMPTY_DAILY_ACTIVITY_RESPONSE, + type DailyActivityAggregatedResponse, +} from "@/components/UsagePage/dailyActivityApi"; + +interface Options { + fetch: () => Promise; + enabled: boolean; + deps: readonly unknown[]; +} + +interface Result { + data: DailyActivityAggregatedResponse; + loading: boolean; + failed: boolean; +} + +interface SettledFetch { + key: string; + data: DailyActivityAggregatedResponse; + failed: boolean; +} + +export function useAggregatedDailyActivity({ fetch, enabled, deps }: Options): Result { + const [settled, setSettled] = useState(null); + const requestIdRef = useRef(0); + const fetchRef = useRef(fetch); + + useEffect(() => { + fetchRef.current = fetch; + }); + + const depsKey = JSON.stringify(deps); + + useEffect(() => { + if (!enabled) return; + + const requestId = ++requestIdRef.current; + const isStale = () => requestIdRef.current !== requestId; + + fetchRef + .current() + .then((response) => { + if (isStale()) return; + setSettled({ key: depsKey, data: response, failed: false }); + }) + .catch((error) => { + if (isStale()) return; + console.error("Error fetching daily activity:", error); + setSettled({ key: depsKey, data: EMPTY_DAILY_ACTIVITY_RESPONSE, failed: true }); + }); + + return () => { + requestIdRef.current++; + }; + }, [enabled, depsKey]); + + const current = enabled && settled?.key === depsKey ? settled : null; + return { + data: current?.data ?? EMPTY_DAILY_ACTIVITY_RESPONSE, + loading: enabled && current === null, + failed: current?.failed ?? false, + }; +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.test.ts deleted file mode 100644 index 0b8cfecbfc6..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.test.ts +++ /dev/null @@ -1,321 +0,0 @@ -import { renderHook, waitFor } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; -import { DailyData, SpendMetrics } from "@/components/UsagePage/types"; -import { mergeDailyResults, sumMetadata, usePaginatedDailyActivity } from "./usePaginatedDailyActivity"; - -describe("sumMetadata", () => { - it("sums flat cost across pages instead of keeping the first page's value", () => { - // A team whose activity spans more than one page accrues flat cost on each of them. - // Keeping page 1's value under-reports the Flat Cost and Total Cost tiles. - const merged = sumMetadata({ total_spend: 1, total_flat_cost: 174.5 }, { total_spend: 2, total_flat_cost: 777 }); - - expect(merged.total_flat_cost).toBe(951.5); - expect(merged.total_spend).toBe(3); - }); - - it("treats a page missing the field as zero rather than dropping the running total", () => { - expect(sumMetadata({ total_flat_cost: 480 }, {}).total_flat_cost).toBe(480); - expect(sumMetadata({}, { total_flat_cost: 480 }).total_flat_cost).toBe(480); - }); - - it("carries non-summable keys through from the first page", () => { - const merged = sumMetadata( - { page: 1, total_pages: 3, total_spend: 1 }, - { page: 2, total_pages: 3, total_spend: 2 }, - ); - - expect(merged.page).toBe(1); - expect(merged.total_pages).toBe(3); - }); - - it("sums every total_* metric the daily activity metadata exposes", () => { - // Guards the class of bug rather than one field: a new backend total that nobody adds - // to SUMMABLE_METADATA_KEYS freezes at page 1, and spend still looks right so it reads - // as trustworthy. - const page = { - total_spend: 1, - total_prompt_tokens: 1, - total_completion_tokens: 1, - total_tokens: 1, - total_api_requests: 1, - total_successful_requests: 1, - total_failed_requests: 1, - total_cache_read_input_tokens: 1, - total_cache_creation_input_tokens: 1, - total_flat_cost: 1, - total_response_time_ms: 1, - total_timed_requests: 1, - }; - const merged = sumMetadata(page, page); - - for (const key of Object.keys(page)) { - expect(merged[key], `${key} must be summed across pages`).toBe(2); - } - }); -}); - -const metricsOf = (spend: number): SpendMetrics => ({ - spend, - prompt_tokens: 0, - completion_tokens: 0, - total_tokens: 0, - api_requests: 1, - successful_requests: 1, - failed_requests: 0, - cache_read_input_tokens: 0, - cache_creation_input_tokens: 0, - compression_savings_spend: spend, -}); - -const dayOf = (date: string, spend: number, apiKey: string = "sk-1"): DailyData => ({ - date, - metrics: metricsOf(spend), - breakdown: { - models: { - "gpt-4o": { - metrics: metricsOf(spend), - metadata: {}, - api_key_breakdown: { - [apiKey]: { metrics: metricsOf(spend), metadata: { key_alias: "alias-1", team_id: null } }, - }, - }, - }, - model_groups: {}, - mcp_servers: {}, - providers: {}, - api_keys: { [apiKey]: { metrics: metricsOf(spend), metadata: { key_alias: "alias-1", team_id: null } } }, - entities: {}, - }, -}); - -describe("mergeDailyResults", () => { - it("collapses repeated dates into one entry with summed metrics (the LIT-5818 $2/$2/$1 case)", () => { - const merged = mergeDailyResults(mergeDailyResults([dayOf("2026-08-16", 2)], [dayOf("2026-08-16", 2)]), [ - dayOf("2026-08-16", 1), - ]); - - expect(merged).toHaveLength(1); - expect(merged[0].metrics.spend).toBe(5); - expect(merged[0].metrics.compression_savings_spend).toBe(5); - }); - - it("appends unseen dates in arrival order", () => { - const merged = mergeDailyResults([dayOf("2026-08-16", 2)], [dayOf("2026-08-15", 0.5)]); - - expect(merged.map((d) => d.date)).toEqual(["2026-08-16", "2026-08-15"]); - expect(merged[1].metrics.spend).toBe(0.5); - }); - - it("merges every breakdown level including the nested per-key breakdown", () => { - const merged = mergeDailyResults([dayOf("2026-08-16", 2, "sk-1")], [dayOf("2026-08-16", 3, "sk-1")]); - - expect(merged[0].breakdown.models["gpt-4o"].metrics.spend).toBe(5); - expect(merged[0].breakdown.models["gpt-4o"].api_key_breakdown["sk-1"].metrics.spend).toBe(5); - expect(merged[0].breakdown.api_keys["sk-1"].metrics.spend).toBe(5); - expect(merged[0].breakdown.api_keys["sk-1"].metadata.key_alias).toBe("alias-1"); - }); - - it("unions breakdown keys that appear on different pages", () => { - const merged = mergeDailyResults([dayOf("2026-08-16", 2, "sk-1")], [dayOf("2026-08-16", 3, "sk-2")]); - - expect(merged[0].breakdown.api_keys["sk-1"].metrics.spend).toBe(2); - expect(merged[0].breakdown.api_keys["sk-2"].metrics.spend).toBe(3); - }); - - it("sums metric keys it has never heard of so a future backend column cannot silently freeze", () => { - const withExtra = (spend: number): DailyData => ({ - ...dayOf("2026-08-16", spend), - metrics: { ...metricsOf(spend), future_savings_spend: spend } as SpendMetrics, - }); - const merged = mergeDailyResults([withExtra(2)], [withExtra(3)]); - - expect((merged[0].metrics as Record).future_savings_spend).toBe(5); - }); -}); - -describe("usePaginatedDailyActivity page accumulation", () => { - it("returns one entry per date when a date's rows span multiple pages", async () => { - const pages = [ - { results: [dayOf("2026-08-16", 2)], metadata: { total_pages: 3, page: 1, total_spend: 2 } }, - { results: [dayOf("2026-08-16", 2)], metadata: { total_pages: 3, page: 2, total_spend: 2 } }, - { - results: [dayOf("2026-08-16", 1), dayOf("2026-08-15", 0.5)], - metadata: { total_pages: 3, page: 3, total_spend: 1.5 }, - }, - ]; - const fetchFn = vi.fn((_token: string, _start: Date, _end: Date, page: number) => Promise.resolve(pages[page - 1])); - const start = new Date("2026-08-10"); - const end = new Date("2026-08-17"); - - const { result } = renderHook(() => - usePaginatedDailyActivity({ fetchFn, args: ["tok", start, end, null], enabled: true }), - ); - - await waitFor(() => expect(result.current.data.metadata.page).toBe(3), { timeout: 5000 }); - - expect(result.current.data.results.map((d) => d.date)).toEqual(["2026-08-16", "2026-08-15"]); - expect(result.current.data.results[0].metrics.spend).toBe(5); - expect(result.current.data.metadata.total_spend).toBe(5.5); - }); -}); - -describe("usePaginatedDailyActivity failure reporting", () => { - const firstPage = { results: [dayOf("2026-08-16", 2)], metadata: { total_pages: 3, page: 1, total_spend: 2 } }; - const start = new Date("2026-08-10"); - const end = new Date("2026-08-17"); - - it("reports a failed range so partial totals cannot pass as the whole range", async () => { - const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); - const fetchFn = vi.fn((_token: string, _start: Date, _end: Date, page: number) => - page === 1 ? Promise.resolve(firstPage) : Promise.reject(new Error("page 2 never came back")), - ); - - const { result } = renderHook(() => - usePaginatedDailyActivity({ fetchFn, args: ["tok", start, end, null], enabled: true }), - ); - - await waitFor(() => expect(result.current.failed).toBe(true), { timeout: 5000 }); - - expect(result.current.isFetchingMore).toBe(false); - expect(result.current.loading).toBe(false); - expect(result.current.data.metadata.total_spend).toBe(2); - consoleError.mockRestore(); - }); - - it("reports no pages loaded when the very first request is what failed", async () => { - const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); - const fetchFn = vi.fn(() => Promise.reject(new Error("page 1 never came back"))); - - const { result } = renderHook(() => - usePaginatedDailyActivity({ fetchFn, args: ["tok", start, end, null], enabled: true }), - ); - - await waitFor(() => expect(result.current.failed).toBe(true), { timeout: 5000 }); - - expect(result.current.progress).toEqual({ currentPage: 0, totalPages: 0 }); - consoleError.mockRestore(); - }); - - it("stays unfailed when every page arrives", async () => { - const pages = [ - firstPage, - { results: [dayOf("2026-08-15", 1)], metadata: { total_pages: 2, page: 2, total_spend: 1 } }, - ]; - const fetchFn = vi.fn((_token: string, _start: Date, _end: Date, page: number) => - Promise.resolve({ ...pages[page - 1], metadata: { ...pages[page - 1].metadata, total_pages: 2 } }), - ); - - const { result } = renderHook(() => - usePaginatedDailyActivity({ fetchFn, args: ["tok", start, end, null], enabled: true }), - ); - - await waitFor(() => expect(result.current.data.metadata.page).toBe(2), { timeout: 5000 }); - - expect(result.current.failed).toBe(false); - }); - - it("clears the failure when a new range is requested, so the banner cannot outlive it", async () => { - const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); - const fetchFn = vi.fn((...callArgs: unknown[]) => { - const [, , , page, filter] = callArgs as [string, Date, Date, number, string | null]; - if (filter !== "broken") - return Promise.resolve({ ...firstPage, metadata: { ...firstPage.metadata, total_pages: 1 } }); - if (page === 1) return Promise.resolve({ ...firstPage, metadata: { ...firstPage.metadata, total_pages: 2 } }); - return Promise.reject(new Error("page 2 never came back")); - }); - - const { result, rerender } = renderHook( - ({ filter }: { filter: string | null }) => - usePaginatedDailyActivity({ fetchFn, args: ["tok", start, end, filter], enabled: true }), - { initialProps: { filter: "broken" as string | null } }, - ); - - await waitFor(() => expect(result.current.failed).toBe(true), { timeout: 5000 }); - - rerender({ filter: "healthy" }); - - await waitFor(() => expect(result.current.failed).toBe(false), { timeout: 5000 }); - consoleError.mockRestore(); - }); -}); - -describe("usePaginatedDailyActivity range coverage", () => { - const start = new Date("2026-08-10"); - const end = new Date("2026-08-17"); - const singlePage = { results: [dayOf("2026-08-16", 2)], metadata: { total_pages: 1, page: 1, total_spend: 2 } }; - - it("does not cover the range while the hook is disabled", () => { - const fetchFn = vi.fn(() => Promise.resolve(singlePage)); - - const { result } = renderHook(() => - usePaginatedDailyActivity({ fetchFn, args: ["tok", start, end, null], enabled: false }), - ); - - expect(result.current.coversRange).toBe(false); - expect(fetchFn).not.toHaveBeenCalled(); - }); - - it("covers the range only once every page of it has landed", async () => { - const pages = [ - { results: [dayOf("2026-08-16", 2)], metadata: { total_pages: 2, page: 1, total_spend: 2 } }, - { results: [dayOf("2026-08-15", 1)], metadata: { total_pages: 2, page: 2, total_spend: 1 } }, - ]; - const fetchFn = vi.fn((_token: string, _start: Date, _end: Date, page: number) => Promise.resolve(pages[page - 1])); - - const { result } = renderHook(() => - usePaginatedDailyActivity({ fetchFn, args: ["tok", start, end, null], enabled: true }), - ); - - expect(result.current.coversRange).toBe(false); - await waitFor(() => expect(result.current.coversRange).toBe(true), { timeout: 5000 }); - }); - - it("never reports a range as covered while the data on screen is empty", async () => { - // Disabling the hook empties the data. Re-enabling it asks for the same args the last - // completed fetch used, so coverage that survives the disable would vouch for nothing. - const seen: Array<{ coversRange: boolean; rows: number }> = []; - const fetchFn = vi.fn(() => Promise.resolve(singlePage)); - - const { result, rerender } = renderHook( - ({ enabled }: { enabled: boolean }) => { - const activity = usePaginatedDailyActivity({ fetchFn, args: ["tok", start, end, null], enabled }); - seen.push({ coversRange: activity.coversRange, rows: activity.data.results.length }); - return activity; - }, - { initialProps: { enabled: true } }, - ); - - await waitFor(() => expect(result.current.coversRange).toBe(true), { timeout: 5000 }); - - rerender({ enabled: false }); - rerender({ enabled: true }); - - await waitFor(() => expect(result.current.coversRange).toBe(true), { timeout: 5000 }); - expect(seen.filter((render) => render.coversRange && render.rows === 0)).toEqual([]); - }); - - it("stops covering the range on the very render the args change, not once an effect catches up", async () => { - // The render after a filter change still holds the previous filter's rows, so resetting - // coverage inside the fetch effect would leave a paint where the export reads them as the - // new range. That paint is the whole thing the gate exists to stop. - const seen: Array<{ filter: string; coversRange: boolean }> = []; - const fetchFn = vi.fn(() => Promise.resolve(singlePage)); - - const { result, rerender } = renderHook( - ({ filter }: { filter: string }) => { - const activity = usePaginatedDailyActivity({ fetchFn, args: ["tok", start, end, filter], enabled: true }); - seen.push({ filter, coversRange: activity.coversRange }); - return activity; - }, - { initialProps: { filter: "team-a" } }, - ); - - await waitFor(() => expect(result.current.coversRange).toBe(true), { timeout: 5000 }); - - rerender({ filter: "team-b" }); - - const rendersForNewFilter = seen.filter((render) => render.filter === "team-b"); - expect(rendersForNewFilter.length).toBeGreaterThan(0); - expect(rendersForNewFilter.map((render) => render.coversRange)).not.toContain(true); - }); -}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.ts deleted file mode 100644 index 1f03f6a4fcb..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.ts +++ /dev/null @@ -1,374 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { - BreakdownMetrics, - DailyData, - KeyMetricWithMetadata, - MetricWithMetadata, - SpendMetrics, -} from "@/components/UsagePage/types"; - -export interface PaginationProgress { - currentPage: number; - totalPages: number; -} - -/** Delay between sequential page fetches (ms) to avoid overloading the backend. */ -const PAGE_FETCH_DELAY_MS = 300; - -/** Number of pages to accumulate before flushing to React state (reduces re-renders). */ -const RENDER_BATCH_SIZE = 3; - -/** The metadata fields returned by the daily activity API that should be summed across pages. */ -const SUMMABLE_METADATA_KEYS = [ - "total_spend", - "total_prompt_tokens", - "total_completion_tokens", - "total_tokens", - "total_api_requests", - "total_successful_requests", - "total_failed_requests", - "total_cache_read_input_tokens", - "total_cache_creation_input_tokens", - "total_flat_cost", - "total_response_time_ms", - "total_timed_requests", -] as const; - -interface DailyActivityResponse { - results: DailyData[]; - metadata: Record; -} - -type FetchPageFn = (...args: any[]) => Promise; - -interface UsePaginatedDailyActivityParams { - /** The API call function (e.g., userDailyActivityCall). */ - fetchFn: FetchPageFn; - /** Arguments to pass to fetchFn: [accessToken, startTime, endTime, ...extraArgs]. Page is injected by the hook at index 3. */ - args: any[]; - /** Whether the hook should fetch. Set to false to disable. */ - enabled: boolean; - /** - * Optional single-shot endpoint returning the whole range at once (e.g. - * teamDailyActivityAggregatedCall). Called with `args` as-is (no page). - * On success pagination is skipped entirely; on failure the hook falls - * back to the paginated flow. - */ - aggregatedFetchFn?: (...args: any[]) => Promise; -} - -interface UsePaginatedDailyActivityReturn { - data: DailyActivityResponse; - loading: boolean; - isFetchingMore: boolean; - progress: PaginationProgress; - cancelled: boolean; - failed: boolean; - coversRange: boolean; - cancel: () => void; -} - -const EMPTY_DATA: DailyActivityResponse = { - results: [], - metadata: { - total_spend: 0, - total_prompt_tokens: 0, - total_completion_tokens: 0, - total_tokens: 0, - total_api_requests: 0, - total_successful_requests: 0, - total_failed_requests: 0, - total_cache_read_input_tokens: 0, - total_cache_creation_input_tokens: 0, - total_response_time_ms: 0, - total_timed_requests: 0, - total_pages: 1, - has_more: false, - page: 1, - }, -}; - -/** - * Combine two pages of metadata. Only keys in SUMMABLE_METADATA_KEYS are added; anything - * else keeps the first page's value, so a total the backend adds later is silently frozen - * at page 1 until it is listed above. Exported so that contract can be tested directly. - */ -export function sumMetadata(a: Record, b: Record): Record { - const result = { ...a }; - for (const key of SUMMABLE_METADATA_KEYS) { - result[key] = (a[key] || 0) + (b[key] || 0); - } - return result; -} - -/** - * Sum the union of numeric metric keys so a metric column added to the backend - * later is summed automatically instead of silently frozen at one page's value - * (the drift hazard SUMMABLE_METADATA_KEYS documents above). - */ -const addMetrics = (a: SpendMetrics, b: SpendMetrics): SpendMetrics => - Object.fromEntries( - Array.from(new Set([...Object.keys(a), ...Object.keys(b)])).map((key) => { - const left = a[key as keyof SpendMetrics]; - const right = b[key as keyof SpendMetrics]; - if (typeof left !== "number" && typeof right !== "number") return [key, left ?? right]; - return [key, (typeof left === "number" ? left : 0) + (typeof right === "number" ? right : 0)]; - }), - ) as unknown as SpendMetrics; - -const mergeBucketMaps = ( - a: Record | undefined, - b: Record | undefined, - mergeEntry: (left: T, right: T) => T, -): Record => { - const left = a ?? {}; - const right = b ?? {}; - return Object.fromEntries( - Array.from(new Set([...Object.keys(left), ...Object.keys(right)])).map((key) => { - const leftEntry = left[key]; - const rightEntry = right[key]; - if (leftEntry === undefined) return [key, rightEntry]; - if (rightEntry === undefined) return [key, leftEntry]; - return [key, mergeEntry(leftEntry, rightEntry)]; - }), - ); -}; - -const mergeKeyMetric = (a: KeyMetricWithMetadata, b: KeyMetricWithMetadata): KeyMetricWithMetadata => ({ - ...a, - metrics: addMetrics(a.metrics, b.metrics), -}); - -const mergeMetricWithMetadata = (a: MetricWithMetadata, b: MetricWithMetadata): MetricWithMetadata => ({ - ...a, - metrics: addMetrics(a.metrics, b.metrics), - api_key_breakdown: mergeBucketMaps(a.api_key_breakdown, b.api_key_breakdown, mergeKeyMetric), -}); - -const mergeBreakdown = (a: BreakdownMetrics, b: BreakdownMetrics): BreakdownMetrics => ({ - models: mergeBucketMaps(a.models, b.models, mergeMetricWithMetadata), - model_groups: mergeBucketMaps(a.model_groups, b.model_groups, mergeMetricWithMetadata), - mcp_servers: mergeBucketMaps(a.mcp_servers, b.mcp_servers, mergeMetricWithMetadata), - providers: mergeBucketMaps(a.providers, b.providers, mergeMetricWithMetadata), - api_keys: mergeBucketMaps(a.api_keys, b.api_keys, mergeKeyMetric), - entities: mergeBucketMaps(a.entities, b.entities, mergeMetricWithMetadata), - ...(a.endpoints || b.endpoints - ? { endpoints: mergeBucketMaps(a.endpoints, b.endpoints, mergeMetricWithMetadata) } - : {}), -}); - -/** - * The backend paginates over raw rows and re-groups per page, so a date whose - * rows span pages arrives as one partial DailyData per page. Merge by date so - * consumers never see the same date twice (LIT-5818: each day rendered as N - * partial bars). Exported so the contract can be tested directly. - */ -export function mergeDailyResults(existing: readonly DailyData[], incoming: readonly DailyData[]): DailyData[] { - return incoming.reduce( - (acc, day) => { - const index = acc.findIndex((existingDay) => existingDay.date === day.date); - if (index === -1) return [...acc, day]; - return acc.map((existingDay, i) => - i === index - ? { - ...existingDay, - metrics: addMetrics(existingDay.metrics, day.metrics), - breakdown: mergeBreakdown(existingDay.breakdown, day.breakdown), - } - : existingDay, - ); - }, - [...existing], - ); -} - -/** - * Hook that auto-paginates daily activity endpoints, updating state in batches - * so charts render progressively. Cancels on unmount, param changes, or - * manual cancel(). - * - * The `args` array should contain every argument the fetchFn expects EXCEPT - * the `page` parameter. The hook injects `page` as the 4th argument (index 3), - * matching the signature of all daily activity calls: - * (accessToken, startTime, endTime, page, ...rest) - */ -export function usePaginatedDailyActivity({ - fetchFn, - args, - enabled, - aggregatedFetchFn, -}: UsePaginatedDailyActivityParams): UsePaginatedDailyActivityReturn { - const [data, setData] = useState(EMPTY_DATA); - const [loading, setLoading] = useState(false); - const [isFetchingMore, setIsFetchingMore] = useState(false); - const [progress, setProgress] = useState({ - currentPage: 0, - totalPages: 0, - }); - const [cancelled, setCancelled] = useState(false); - const [failed, setFailed] = useState(false); - const [completedKey, setCompletedKey] = useState(null); - - const fetchIdRef = useRef(0); - const cancelledRef = useRef(false); - const delayTimerRef = useRef | null>(null); - - // Keep args in a ref so the effect can always read the latest values - // without needing them in the dependency array. - const argsRef = useRef(args); - argsRef.current = args; - - // Stable serialised key so the effect only re-runs when the arg *values* change. - const argsKey = JSON.stringify(args); - - // Stamped like the data itself and compared during render, so the render that follows an arg - // change already reports the new range as uncovered. Clearing it inside the fetch effect would - // be one render too late, leaving a paint where an export reads the previous range's rows. - const coversRange = enabled && completedKey === argsKey; - - const cancel = useCallback(() => { - cancelledRef.current = true; - setCancelled(true); - setIsFetchingMore(false); - if (delayTimerRef.current !== null) { - clearTimeout(delayTimerRef.current); - delayTimerRef.current = null; - } - }, []); - - useEffect(() => { - if (!enabled) { - setData(EMPTY_DATA); - setLoading(false); - setIsFetchingMore(false); - setProgress({ currentPage: 0, totalPages: 0 }); - setCancelled(false); - setFailed(false); - setCompletedKey(null); - return; - } - - const currentFetchId = ++fetchIdRef.current; - cancelledRef.current = false; - setCancelled(false); - setFailed(false); - - const isStale = () => fetchIdRef.current !== currentFetchId || cancelledRef.current; - - /** Cancellable delay that clears itself on cleanup. */ - const delay = (ms: number) => - new Promise((resolve) => { - delayTimerRef.current = setTimeout(() => { - delayTimerRef.current = null; - resolve(); - }, ms); - }); - - const run = async () => { - const currentArgs = argsRef.current; - setLoading(true); - setIsFetchingMore(false); - setProgress({ currentPage: 0, totalPages: 0 }); - - if (aggregatedFetchFn) { - try { - const aggregated = await aggregatedFetchFn(...currentArgs); - if (isStale()) return; - setData(aggregated); - setProgress({ currentPage: 1, totalPages: 1 }); - setLoading(false); - setCompletedKey(argsKey); - return; - } catch (error) { - if (isStale()) return; - console.error("Aggregated daily activity failed, falling back to pagination:", error); - } - } - - try { - // Inject page=1 as the 4th argument. - const argsWithPage = [...currentArgs.slice(0, 3), 1, ...currentArgs.slice(3)]; - const firstPage = await fetchFn(...argsWithPage); - - if (isStale()) return; - - setData(firstPage); - - const totalPages = firstPage.metadata?.total_pages || 1; - - setProgress({ currentPage: 1, totalPages }); - - if (totalPages <= 1) { - setLoading(false); - setCompletedKey(argsKey); - return; - } - - // More pages — start fetching sequentially. - setLoading(false); - setIsFetchingMore(true); - - let accumulatedResults = mergeDailyResults([], firstPage.results); - let accumulatedMetadata = { ...firstPage.metadata }; - - for (let page = 2; page <= totalPages; page++) { - if (isStale()) return; - - // Small delay to avoid overwhelming the backend. - await delay(PAGE_FETCH_DELAY_MS); - - if (isStale()) return; - - const argsForPage = [...currentArgs.slice(0, 3), page, ...currentArgs.slice(3)]; - const pageData = await fetchFn(...argsForPage); - - if (isStale()) return; - - accumulatedResults = mergeDailyResults(accumulatedResults, pageData.results); - accumulatedMetadata = sumMetadata(accumulatedMetadata, pageData.metadata); - accumulatedMetadata.total_pages = totalPages; - accumulatedMetadata.has_more = page < totalPages; - accumulatedMetadata.page = page; - - // Flush accumulated data and progress to React state every - // RENDER_BATCH_SIZE pages (or on the final page) to avoid - // expensive per-page re-renders. Progress and data are updated - // together so the counter never appears to decrement. - const isLastPage = page === totalPages; - const isBatchBoundary = (page - 1) % RENDER_BATCH_SIZE === 0; - if (isLastPage || isBatchBoundary) { - setData({ - results: accumulatedResults, - metadata: accumulatedMetadata, - }); - setProgress({ currentPage: page, totalPages }); - } - } - - setIsFetchingMore(false); - setCompletedKey(argsKey); - } catch (error) { - if (!isStale()) { - console.error("Error fetching daily activity:", error); - setLoading(false); - setIsFetchingMore(false); - setFailed(true); - } - } - }; - - run(); - - return () => { - fetchIdRef.current++; - if (delayTimerRef.current !== null) { - clearTimeout(delayTimerRef.current); - delayTimerRef.current = null; - } - }; - // argsKey is a stable JSON string so the effect only re-fires when arg values change. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [enabled, fetchFn, aggregatedFetchFn, argsKey]); - - return { data, loading, isFetchingMore, progress, cancelled, failed, coversRange, cancel }; -} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.integration.test.tsx index 6a0e55a6dda..676bc7d29eb 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.integration.test.tsx @@ -21,7 +21,6 @@ const mockTeamInfoCall = vi.fn(); const mockUserUpdateUserCall = vi.fn(); const mockFetchMCPServers = vi.fn(); const mockListMCPTools = vi.fn(); -const mockUserDailyActivityCall = vi.fn(); const mockUserDailyActivityAggregatedCall = vi.fn(); const MCP_SERVER = { server_id: "srv-1", server_name: "GitHub MCP", alias: "GitHub MCP" }; @@ -65,8 +64,7 @@ vi.mock("@/components/networking", async (importOriginal) => { formatDate: original.formatDate, serverRootPath: "/", userGetInfoV2: (...args: unknown[]) => mockUserGetInfoV2(...args), - userDailyActivityCall: (...args: unknown[]) => mockUserDailyActivityCall(...args), - userDailyActivityAggregatedCall: (...args: unknown[]) => mockUserDailyActivityAggregatedCall(...args), + dailyActivityAggregatedCall: (...args: unknown[]) => mockUserDailyActivityAggregatedCall(...args), userDeleteCall: vi.fn(), userUpdateUserCall: (...args: unknown[]) => mockUserUpdateUserCall(...args), modelAvailableCall: vi.fn().mockResolvedValue({ data: [] }), @@ -458,7 +456,6 @@ describe("UserInfoView savings", () => { Promise.resolve({ ...MOCK_USER_DATA_NO_TEAMS, user_id: userId }), ); mockUserDailyActivityAggregatedCall.mockReset().mockResolvedValue(savingsResponse([])); - mockUserDailyActivityCall.mockReset().mockResolvedValue(savingsResponse([])); }); afterEach(() => { @@ -472,17 +469,18 @@ describe("UserInfoView savings", () => { const { rerender } = render(); await user.click(await screen.findByRole("tab", { name: "Savings" })); expect(await screen.findByText("No usage recorded for this user in this range.")).toBeInTheDocument(); - expect(mockUserDailyActivityAggregatedCall.mock.calls[0][3]).toBe("user-1"); + expect(mockUserDailyActivityAggregatedCall.mock.calls[0]).toEqual([ + "user", + expect.objectContaining({ entityIds: ["user-1"] }), + ]); mockUserDailyActivityAggregatedCall.mockClear(); - mockUserDailyActivityCall.mockClear(); rerender(); await screen.findAllByText("another-user"); expect(screen.getByRole("tab", { name: "Overview" })).toHaveAttribute("aria-selected", "true"); expect(screen.queryByRole("tab", { name: "Savings" })).not.toBeInTheDocument(); expect(screen.queryByText("No usage recorded for this user in this range.")).not.toBeInTheDocument(); expect(mockUserDailyActivityAggregatedCall).not.toHaveBeenCalled(); - expect(mockUserDailyActivityCall).not.toHaveBeenCalled(); }, ); @@ -506,7 +504,6 @@ describe("UserInfoView savings", () => { render(); const savingsTab = await screen.findByRole("tab", { name: "Savings" }); expect(mockUserDailyActivityAggregatedCall).not.toHaveBeenCalled(); - expect(mockUserDailyActivityCall).not.toHaveBeenCalled(); await user.click(savingsTab); @@ -516,12 +513,12 @@ describe("UserInfoView savings", () => { expect(screen.getByTestId("summary-card-prompt-caching-savings")).toHaveTextContent("$1.00Total"); expect(screen.getByTestId("summary-card-auto-router-savings")).toHaveTextContent("-$3.00"); expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledExactlyOnceWith( - "admin-token", - expect.any(Date), - expect.any(Date), - "user-123", - true, - null, + "user", + expect.objectContaining({ + accessToken: "admin-token", + entityIds: ["user-123"], + includeCurrentUtcDay: true, + }), ); expect(screen.getByTestId("user-savings-scope-note")).toHaveTextContent("JWT-authenticated requests"); await user.click(screen.getByRole("tab", { name: "Per day" })); @@ -544,12 +541,12 @@ describe("UserInfoView savings", () => { expect(await screen.findByTestId("user-savings-empty")).toHaveTextContent("Loading savings"); expect(screen.queryByTestId("summary-card-total-recorded-savings")).not.toBeInTheDocument(); expect(mockUserDailyActivityAggregatedCall).toHaveBeenLastCalledWith( - "admin-token", - expect.any(Date), - expect.any(Date), - "user-456", - true, - null, + "user", + expect.objectContaining({ + accessToken: "admin-token", + entityIds: ["user-456"], + includeCurrentUtcDay: true, + }), ); await act(async () => { nextUser.resolve(savingsResponse([savingsDay("2026-09-19", { autorouter_savings_spend: -7 })])); @@ -596,27 +593,16 @@ describe("UserInfoView savings", () => { expect(await screen.findByTestId("summary-card-total-recorded-savings")).toHaveTextContent("-$7.00"); }); - it("reports an incomplete paginated read as unavailable instead of displaying a partial savings total", async () => { + it("reports a failed read as unavailable instead of displaying a partial savings total", async () => { mockUserDailyActivityAggregatedCall.mockRejectedValue(new Error("aggregated unavailable")); - mockUserDailyActivityCall - .mockResolvedValueOnce({ - results: [savingsDay("2026-09-19", { compression_savings_spend: 42 })], - metadata: { total_pages: 2, has_more: true, page: 1 }, - }) - .mockRejectedValueOnce(new Error("next page unavailable")); const user = userEvent.setup(); render(); await user.click(await screen.findByRole("tab", { name: "Savings" })); expect(await screen.findByRole("alert")).toHaveTextContent("Savings are unavailable for this range"); - expect(mockUserDailyActivityCall).toHaveBeenLastCalledWith( - "admin-token", - expect.any(Date), - expect.any(Date), - 2, - "user-123", - true, - null, + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledWith( + "user", + expect.objectContaining({ entityIds: ["user-123"] }), ); expect(screen.queryByTestId("summary-card-total-recorded-savings")).not.toBeInTheDocument(); expect(screen.queryByText(/No usage recorded/)).not.toBeInTheDocument(); @@ -639,6 +625,5 @@ describe("UserInfoView savings", () => { expect(screen.getByRole("alert")).toHaveTextContent("this user has no ID"); expect(mockUserDailyActivityAggregatedCall).not.toHaveBeenCalled(); - expect(mockUserDailyActivityCall).not.toHaveBeenCalled(); }); }); diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.test.tsx b/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.test.tsx index cb04323e4c9..375acd281af 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.test.tsx +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.test.tsx @@ -1,61 +1,22 @@ -/** - * Tests for EntityUsageExportModal component - * - * Validates core export functionality: - * - Renders modal with correct default state (CSV format, daily scope) - * - User can select export type (daily vs daily_with_models) - * - User can switch format (CSV vs JSON) - * - Export button triggers data generation with correct parameters - * - Modal closes after successful export - */ - import { describe, it, expect, vi, beforeEach } from "vitest"; import { screen } from "@testing-library/react"; import { renderWithProviders } from "../../../tests/test-utils"; import userEvent from "@testing-library/user-event"; import EntityUsageExportModal from "./EntityUsageExportModal"; -// Mock utilities that format/export data so tests stay fast and deterministic -vi.mock("./utils", () => { - return { - handleExportCSV: vi.fn(), - handleExportJSON: vi.fn(), - generateExportData: vi.fn(() => [{ Date: "2025-10-01" }]), - generateMetadata: vi.fn(() => ({ meta: true })), - }; -}); +const downloadBlob = vi.fn(); -// Mock useTeams hook -vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ - useTeams: vi.fn(() => ({ - data: [], - isLoading: false, - error: null, - refetch: vi.fn(), - })), +vi.mock("./utils", () => ({ + downloadBlob: (...args: unknown[]) => downloadBlob(...args), + exportFilename: vi.fn(() => "tag_usage_daily_2025-10-01_2025-10-14.csv"), })); -// JSDOM stubs for download flow used by the modal -// @ts-ignore -global.URL.createObjectURL = vi.fn(() => "blob:mock"); -// @ts-ignore -global.URL.revokeObjectURL = vi.fn(); - describe("EntityUsageExportModal", () => { const baseProps = { isOpen: true, onClose: vi.fn(), entityType: "tag" as const, - spendData: { - results: [], - metadata: { - total_spend: 0, - total_api_requests: 0, - total_successful_requests: 0, - total_failed_requests: 0, - total_tokens: 0, - }, - }, + onExport: vi.fn().mockResolvedValue(new Blob(["data"])), dateRange: { from: new Date("2025-10-01"), to: new Date("2025-10-14") }, selectedFilters: [], customTitle: "Export Tag Usage", @@ -63,55 +24,44 @@ describe("EntityUsageExportModal", () => { beforeEach(() => { vi.clearAllMocks(); + baseProps.onExport.mockResolvedValue(new Blob(["data"])); }); - it("renders default state and exports CSV (daily) successfully", async () => { - /** - * Tests the happy path: user opens modal and exports with defaults. - * Verifies that handleExportCSV is called with correct parameters - * and modal closes after export completes. - */ + it("exports through the onExport callback with the selected type and format, then closes", async () => { const user = userEvent.setup(); - const { handleExportCSV } = await import("./utils"); renderWithProviders(); - // Default primary action reflects CSV export expect(screen.getByRole("button", { name: /Export CSV/i })).toBeInTheDocument(); - - // Click export await user.click(screen.getByRole("button", { name: /Export CSV/i })); - // Verifies export function was invoked with correct parameters - expect(handleExportCSV).toHaveBeenCalledWith(baseProps.spendData, "daily", "Tag", "tag", {}); - - // Modal closes after export + expect(baseProps.onExport).toHaveBeenCalledWith("daily", "csv"); + expect(downloadBlob).toHaveBeenCalledWith(expect.any(Blob), "tag_usage_daily_2025-10-01_2025-10-14.csv"); expect(baseProps.onClose).toHaveBeenCalled(); }); - it("exports with 'day-by-day by tag and model' scope when selected", async () => { - /** - * Tests that user can change export type (scope). - * Verifies handleExportCSV receives 'daily_with_models' scope - * when the second radio option is selected. - */ + it("forwards the export type the user picks", async () => { const user = userEvent.setup(); - const { handleExportCSV } = await import("./utils"); renderWithProviders(); - // Choose the alternate export type - click the label to trigger radio - const dailyModelLabel = screen.getByText(/Day-by-day by tag and model/i); - await user.click(dailyModelLabel); + await user.click(screen.getByRole("radio", { name: /Day-by-day breakdown by tag and key/i })); + await user.click(screen.getByRole("button", { name: /Export CSV/i })); - // Export with default CSV format - const exportBtn = screen.getByRole("button", { name: /Export CSV/i }); - await user.click(exportBtn); - - // Ensure the selected scope flowed through - expect(handleExportCSV).toHaveBeenCalledWith(baseProps.spendData, "daily_with_models", "Tag", "tag", {}); - - // Modal closes after export + expect(baseProps.onExport).toHaveBeenCalledWith("daily_with_keys", "csv"); expect(baseProps.onClose).toHaveBeenCalled(); }); + + it("keeps the modal open and reports the error when onExport rejects", async () => { + const user = userEvent.setup(); + baseProps.onExport.mockRejectedValueOnce(new Error("export failed")); + + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: /Export CSV/i })); + + expect(baseProps.onExport).toHaveBeenCalled(); + expect(downloadBlob).not.toHaveBeenCalled(); + expect(baseProps.onClose).not.toHaveBeenCalled(); + }); }); diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.tsx b/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.tsx index ec688e9fc3d..7044e3a7f31 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.tsx +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.tsx @@ -1,47 +1,36 @@ -import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; -import { createTeamAliasMap } from "@/utils/teamUtils"; import { Loader2 } from "lucide-react"; -import React, { useMemo, useState } from "react"; +import React, { useState } from "react"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import { Skeleton } from "@/components/ui/skeleton"; import { toast } from "@/lib/toast"; import ExportFormatSelector from "./ExportFormatSelector"; import ExportSummary from "./ExportSummary"; import ExportTypeSelector from "./ExportTypeSelector"; -import type { EntityUsageExportModalProps, ExportFormat, ExportScope } from "./types"; -import { handleExportCSV, handleExportJSON } from "./utils"; +import type { EntityUsageExportModalProps, ExportFormat, ExportType } from "./types"; +import { downloadBlob, exportFilename } from "./utils"; const EntityUsageExportModal: React.FC = ({ isOpen, onClose, entityType, - spendData, + onExport, dateRange, - selectedFilters, + selectedFilters = [], customTitle, }) => { const [exportFormat, setExportFormat] = useState("csv"); - const [exportScope, setExportScope] = useState("daily"); + const [exportType, setExportType] = 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; + const handleExport = async () => { setIsExporting(true); try { - if (formatToUse === "csv") { - handleExportCSV(spendData, exportScope, entityLabel, entityType, teamAliasMap); - toast.success(`${entityLabel} usage data exported successfully as CSV`); - } else { - handleExportJSON(spendData, exportScope, entityLabel, entityType, dateRange, selectedFilters, teamAliasMap); - toast.success(`${entityLabel} usage data exported successfully as JSON`); - } + const blob = await onExport(exportType, exportFormat); + downloadBlob(blob, exportFilename(entityType, exportType, exportFormat, dateRange)); + toast.success(`${entityLabel} usage data exported successfully as ${exportFormat.toUpperCase()}`); onClose(); } catch (error) { console.error("Error exporting data:", error); @@ -63,36 +52,17 @@ const EntityUsageExportModal: React.FC = ({ {modalTitle}
- {isLoadingTeams ? ( -
- - - -
- ) : ( - <> - - - - - )} + + +
- {isLoadingTeams ? ( - <> - - - - ) : ( - <> - - - - )} + +
diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/ExportTypeSelector.test.tsx b/ui/litellm-dashboard/src/components/EntityUsageExport/ExportTypeSelector.test.tsx index 6743167bff6..997c048048f 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/ExportTypeSelector.test.tsx +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/ExportTypeSelector.test.tsx @@ -1,6 +1,6 @@ import { renderWithProviders, screen } from "../../../tests/test-utils"; import userEvent from "@testing-library/user-event"; -import { vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import ExportTypeSelector from "./ExportTypeSelector"; describe("ExportTypeSelector", () => { diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/ExportTypeSelector.tsx b/ui/litellm-dashboard/src/components/EntityUsageExport/ExportTypeSelector.tsx index f6fdece83a8..3162f00c056 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/ExportTypeSelector.tsx +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/ExportTypeSelector.tsx @@ -1,15 +1,15 @@ import React from "react"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; -import type { ExportScope, EntityType } from "./types"; +import type { ExportType, EntityType } from "./types"; interface ExportTypeSelectorProps { - value: ExportScope; - onChange: (value: ExportScope) => void; + value: ExportType; + onChange: (value: ExportType) => void; entityType: EntityType; } const ExportTypeSelector: React.FC = ({ value, onChange, entityType }) => { - const allScopes: { value: ExportScope; title: string; description: string }[] = [ + const allScopes: { value: ExportType; title: string; description: string }[] = [ { value: "daily", title: `Day-by-day breakdown by ${entityType}`, @@ -36,7 +36,7 @@ const ExportTypeSelector: React.FC = ({ value, onChange return (
- onChange(next as ExportScope)} className="gap-2"> + onChange(next as ExportType)} className="gap-2"> {scopes.map((scope) => (
@@ -137,7 +133,7 @@ const UsageExportHeader: React.FC = ({ isOpen={isExportModalOpen} onClose={() => setIsExportModalOpen(false)} entityType={entityType} - spendData={spendData} + onExport={onExport} dateRange={dateValue} selectedFilters={selectedFilters} customTitle={customTitle} diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.test.ts b/ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.test.ts deleted file mode 100644 index e39b01a5dea..00000000000 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { getExportBlockedReason, type UsageFetchState } from "./exportBlockedReason"; - -const state = (overrides: Partial = {}): UsageFetchState => ({ - coversRange: true, - cancelled: false, - failed: false, - ...overrides, -}); - -describe("getExportBlockedReason", () => { - it("lets the export through once the data on screen covers the range", () => { - expect(getExportBlockedReason(state())).toBeUndefined(); - }); - - it("blocks whenever the data on screen does not cover the range, which is when a CSV silently under-reports", () => { - expect(getExportBlockedReason(state({ coversRange: false }))).toMatch(/still loading/i); - }); - - it("blocks after a stopped fetch and says a reload is what fixes it", () => { - const reason = getExportBlockedReason(state({ coversRange: false, cancelled: true })); - - expect(reason).toMatch(/stopped/i); - expect(reason).toMatch(/reload/i); - }); - - it("blocks after a failed page and names the failure rather than the stop", () => { - const reason = getExportBlockedReason(state({ coversRange: false, failed: true, cancelled: true })); - - expect(reason).toMatch(/failed to load/i); - expect(reason).not.toMatch(/stopped/i); - }); -}); diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.ts b/ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.ts deleted file mode 100644 index 71408ba8f3f..00000000000 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.ts +++ /dev/null @@ -1,13 +0,0 @@ -export interface UsageFetchState { - coversRange: boolean; - cancelled: boolean; - failed: boolean; -} - -export const getExportBlockedReason = ({ coversRange, cancelled, failed }: UsageFetchState): string | undefined => { - if (failed) return "Some spend data failed to load, so an export would under-report. Reload the page to try again."; - if (cancelled) - return "Loading was stopped before the whole range arrived, so an export would under-report. Reload the page to load it all."; - if (!coversRange) return "Spend data is still loading, so an export would under-report. Wait for it to finish."; - return undefined; -}; diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/types.ts b/ui/litellm-dashboard/src/components/EntityUsageExport/types.ts index 15f193ecc3f..189a14392ec 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/types.ts +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/types.ts @@ -1,67 +1,17 @@ import type { DateRangePickerValue } from "@/components/shared/date_picker_types"; import type { Team } from "@/components/key_team_helpers/key_list"; +import type { DailyActivityEntity, ExportFormat, ExportType } from "@/components/UsagePage/dailyActivityApi"; -export type ExportFormat = "csv" | "json"; -export type ExportScope = "daily" | "daily_with_keys" | "daily_with_models" | "daily_with_users"; -export type EntityType = "tag" | "team" | "organization" | "customer" | "agent" | "user"; - -export interface EntitySpendData { - results: any[]; - metadata: { - total_spend: number; - total_flat_cost?: number; - total_api_requests: number; - total_successful_requests: number; - total_failed_requests: number; - total_tokens: number; - }; -} +export type { ExportFormat, ExportType }; +export type EntityType = DailyActivityEntity; export interface EntityUsageExportModalProps { isOpen: boolean; onClose: () => void; entityType: EntityType; - spendData: EntitySpendData; + onExport: (exportType: ExportType, format: ExportFormat) => Promise; dateRange: DateRangePickerValue; - selectedFilters: string[]; + selectedFilters?: string[]; customTitle?: string; teams?: Team[]; } - -export interface ExportMetadata { - export_date: string; - entity_type: string; - date_range: { - from?: string; - to?: string; - }; - filters_applied: string[] | string; - export_scope: ExportScope; - summary: { - total_spend: number; - total_flat_cost?: number; - total_cost?: number; - total_requests: number; - successful_requests: number; - failed_requests: number; - total_tokens: number; - }; -} - -export interface EntityBreakdown { - metrics: { - spend: number; - prompt_tokens: number; - completion_tokens: number; - total_tokens: number; - api_requests: number; - successful_requests: number; - failed_requests: number; - cache_read_input_tokens: number; - cache_creation_input_tokens: number; - }; - metadata: { - alias: string; - id: string; - }; -} diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/utils.test.ts b/ui/litellm-dashboard/src/components/EntityUsageExport/utils.test.ts index 3f9cf58ec20..172d772e461 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/utils.test.ts +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/utils.test.ts @@ -1,3008 +1,51 @@ -// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from "vitest"; -import type { DateRangePickerValue } from "@/components/shared/date_picker_types"; -import Papa from "papaparse"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { EntitySpendData, ExportScope } from "./types"; -import { - generateDailyData, - generateDailyWithKeysData, - generateDailyWithModelsData, - generateDailyWithUsersData, - generateExportData, - generateMetadata, - getEntityBreakdown, - handleExportCSV, - handleExportJSON, - resolveEntities, -} from "./utils"; +import { downloadBlob, exportFilename } 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); - }), -})); +describe("exportFilename", () => { + it("composes entity, export type, range and format", () => { + const name = exportFilename("team", "daily_with_keys", "csv", { + from: new Date(2025, 0, 5), + to: new Date(2025, 0, 31), + }); -vi.mock("papaparse", () => ({ - default: { - unparse: vi.fn((data: any[]) => "mocked-csv-data"), - }, -})); - -describe("EntityUsageExport utils", () => { - // Entity keys match team_ids because that's how the backend shapes team exports - // (breakdown.entities is keyed by team_id). The fix under test uses the entity key - // directly for display, so the key_alias/team_id in api_key_breakdown metadata is - // no longer consulted — it's retained here only to mirror real payload shape. - const mockSpendData: EntitySpendData = { - results: [ - { - date: "2025-01-01", - breakdown: { - entities: { - "team-1": { - 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", - }, - }, - }, - }, - "team-2": { - 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: { - "team-1": { - 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", - }; - - const usersFixture: EntitySpendData = { - results: [ - { - date: "2025-03-01", - breakdown: { - entities: { - "team-1": { - metrics: { - spend: 16.5, - api_requests: 165, - successful_requests: 156, - failed_requests: 9, - total_tokens: 1650, - prompt_tokens: 940, - completion_tokens: 710, - cache_read_input_tokens: 90, - cache_creation_input_tokens: 60, - }, - api_key_breakdown: { - kA: { - metrics: { - spend: 1.1, - api_requests: 11, - successful_requests: 10, - failed_requests: 1, - total_tokens: 110, - prompt_tokens: 60, - completion_tokens: 50, - cache_read_input_tokens: 6, - cache_creation_input_tokens: 4, - }, - metadata: { - team_id: "team-1", - key_alias: "alice-key", - user_id: "u1", - user_email: "a@x", - }, - }, - kB: { - metrics: { - spend: 2.2, - api_requests: 22, - successful_requests: 20, - failed_requests: 2, - total_tokens: 220, - prompt_tokens: 130, - completion_tokens: 90, - cache_read_input_tokens: 12, - cache_creation_input_tokens: 8, - }, - metadata: { - team_id: "team-1", - user_id: "u1", - user_email: "a@x", - }, - }, - kC: { - metrics: { - spend: 3.3, - api_requests: 33, - successful_requests: 31, - failed_requests: 2, - total_tokens: 330, - prompt_tokens: 190, - completion_tokens: 140, - cache_read_input_tokens: 18, - cache_creation_input_tokens: 12, - }, - metadata: { - team_id: "team-1", - user_id: "u2", - user_email: null, - }, - }, - kD: { - metrics: { - spend: 4.4, - api_requests: 44, - successful_requests: 42, - failed_requests: 2, - total_tokens: 440, - prompt_tokens: 250, - completion_tokens: 190, - cache_read_input_tokens: 24, - cache_creation_input_tokens: 16, - }, - metadata: { - team_id: "team-1", - user_id: null, - }, - }, - kE: { - metrics: { - spend: 5.5, - api_requests: 55, - successful_requests: 53, - failed_requests: 2, - total_tokens: 550, - prompt_tokens: 310, - completion_tokens: 240, - cache_read_input_tokens: 30, - cache_creation_input_tokens: 20, - }, - metadata: { - team_id: "team-1", - user_id: "u3", - key_exists: false, - }, - }, - }, - }, - "team-2": { - metrics: { - spend: 6.6, - api_requests: 66, - successful_requests: 64, - failed_requests: 2, - total_tokens: 660, - prompt_tokens: 370, - completion_tokens: 290, - cache_read_input_tokens: 36, - cache_creation_input_tokens: 24, - }, - api_key_breakdown: { - kF: { - metrics: { - spend: 6.6, - api_requests: 66, - successful_requests: 64, - failed_requests: 2, - total_tokens: 660, - prompt_tokens: 370, - completion_tokens: 290, - cache_read_input_tokens: 36, - cache_creation_input_tokens: 24, - }, - metadata: { - team_id: "team-2", - user_id: "u1", - user_email: "a@x", - }, - }, - }, - }, - }, - }, - }, - { - date: "2025-03-02", - breakdown: { - entities: { - "team-1": { - metrics: { - spend: 7.7, - api_requests: 77, - successful_requests: 75, - failed_requests: 2, - total_tokens: 770, - prompt_tokens: 430, - completion_tokens: 340, - cache_read_input_tokens: 42, - cache_creation_input_tokens: 28, - }, - api_key_breakdown: { - kA: { - metrics: { - spend: 7.7, - api_requests: 77, - successful_requests: 75, - failed_requests: 2, - total_tokens: 770, - prompt_tokens: 430, - completion_tokens: 340, - cache_read_input_tokens: 42, - cache_creation_input_tokens: 28, - }, - metadata: { - team_id: "team-1", - key_alias: "alice-key", - user_id: "u1", - user_email: "a@x", - }, - }, - }, - }, - }, - }, - }, - ], - metadata: { - total_spend: 30.8, - total_api_requests: 308, - total_successful_requests: 295, - total_failed_requests: 13, - total_tokens: 3080, - }, - }; - - beforeEach(() => { - vi.clearAllMocks(); + expect(name).toBe("team_usage_daily_with_keys_2025-01-05_2025-01-31.csv"); }); + it("falls back to 'all' when a bound is missing", () => { + expect(exportFilename("user", "daily_with_users", "json", { from: undefined, to: undefined })).toBe( + "user_usage_daily_with_users_all_all.json", + ); + }); +}); + +describe("downloadBlob", () => { afterEach(() => { + vi.unstubAllGlobals(); 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 entity key as alias when no team alias map is provided", () => { - // Non-team exports (tags, orgs, customers, …) pass no teamAliasMap. - // For teams, this is also the fallback when a team is missing from the map. - const result = getEntityBreakdown(mockSpendData); - const entity1 = result.find((e) => e.metadata.id === "team-1"); - - expect(entity1?.metadata.alias).toBe("team-1"); - }); - - it("should use team alias map to resolve alias from entity key", () => { - const spendDataWithoutAlias: EntitySpendData = { - ...mockSpendData, - results: [ - { - date: "2025-01-01", - breakdown: { - entities: { - "team-1": { - 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: { - "team-1": { - 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"); - expect(result[0]).toHaveProperty("Cache Read Input Tokens"); - expect(result[0]).toHaveProperty("Cache Creation Input Tokens"); - }); - - it("should export exact cache token values per entity per day", () => { - const result = generateDailyData(mockSpendData, "Team", mockTeamAliasMap); - - const day1Team1 = result.find((r) => r.Date === "2025-01-01" && r["Team ID"] === "team-1"); - const day1Team2 = result.find((r) => r.Date === "2025-01-01" && r["Team ID"] === "team-2"); - const day2Team1 = result.find((r) => r.Date === "2025-01-02" && r["Team ID"] === "team-1"); - - expect(day1Team1?.["Cache Read Input Tokens"]).toBe(50); - expect(day1Team1?.["Cache Creation Input Tokens"]).toBe(30); - expect(day1Team2?.["Cache Read Input Tokens"]).toBe(100); - expect(day1Team2?.["Cache Creation Input Tokens"]).toBe(60); - expect(day2Team1?.["Cache Read Input Tokens"]).toBe(75); - expect(day2Team1?.["Cache Creation Input Tokens"]).toBe(45); - }); - - 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 fall back to the entity key when there is no team alias mapping", () => { - // e.g. tag/org/customer exports where teamAliasMap has no entry for the entity, - // or a team that isn't in the alias map — the entity key itself is the label. - const spendDataWithoutAlias: EntitySpendData = { - ...mockSpendData, - results: [ - { - date: "2025-01-01", - breakdown: { - entities: { - "my-tag": { - 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(spendDataWithoutAlias, "Tag"); - const entry = result[0]; - - expect(entry["Tag ID"]).toBe("my-tag"); - expect(entry["Tag"]).toBe("my-tag"); - }); - - 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); - expect(result[0]["Cache Read Input Tokens"]).toBe(0); - expect(result[0]["Cache Creation Input Tokens"]).toBe(0); - }); - }); - - describe("generateDailyWithKeysData", () => { - const mockSpendDataWithKeys: EntitySpendData = { - results: [ - { - date: "2025-01-01", - breakdown: { - entities: { - "team-1": { - 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: { - key1: { - metrics: { - spend: 5.0, - api_requests: 50, - successful_requests: 48, - failed_requests: 2, - total_tokens: 500, - prompt_tokens: 300, - completion_tokens: 200, - }, - metadata: { - team_id: "team-1", - key_alias: "alias-1", - }, - }, - key2: { - metrics: { - spend: 5.5, - api_requests: 50, - successful_requests: 47, - failed_requests: 3, - total_tokens: 500, - prompt_tokens: 300, - completion_tokens: 200, - }, - metadata: { - team_id: "team-1", - key_alias: "alias-2", - }, - }, - }, - }, - "team-2": { - metrics: { - spend: 20.3, - api_requests: 200, - successful_requests: 190, - failed_requests: 10, - total_tokens: 2000, - prompt_tokens: 1200, - completion_tokens: 800, - }, - api_key_breakdown: { - key3: { - metrics: { - spend: 20.3, - api_requests: 200, - successful_requests: 190, - failed_requests: 10, - total_tokens: 2000, - prompt_tokens: 1200, - completion_tokens: 800, - }, - metadata: { - team_id: "team-2", - key_alias: "alias-3", - }, - }, - }, - }, - }, - }, - }, - { - date: "2025-01-02", - breakdown: { - entities: { - "team-1": { - metrics: { - spend: 15.2, - api_requests: 150, - successful_requests: 145, - failed_requests: 5, - total_tokens: 1500, - prompt_tokens: 900, - completion_tokens: 600, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 15.2, - api_requests: 150, - successful_requests: 145, - failed_requests: 5, - total_tokens: 1500, - prompt_tokens: 900, - completion_tokens: 600, - }, - 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, - }, - }; - - it("should generate daily breakdown with key data and correct structure", () => { - const result = generateDailyWithKeysData(mockSpendDataWithKeys, "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("Key Alias"); - expect(result[0]).toHaveProperty("Key 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"); - expect(result[0]).toHaveProperty("Cache Read Input Tokens"); - expect(result[0]).toHaveProperty("Cache Creation Input Tokens"); - }); - - it("should export and aggregate cache token values per key", () => { - const makeDay = (cacheRead: number, cacheCreation: number) => ({ - date: "2025-01-01", - breakdown: { - entities: { - "team-1": { - metrics: { - spend: 5.0, - api_requests: 50, - successful_requests: 50, - failed_requests: 0, - total_tokens: 500, - prompt_tokens: 300, - completion_tokens: 200, - cache_read_input_tokens: cacheRead, - cache_creation_input_tokens: cacheCreation, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 5.0, - api_requests: 50, - successful_requests: 50, - failed_requests: 0, - total_tokens: 500, - prompt_tokens: 300, - completion_tokens: 200, - cache_read_input_tokens: cacheRead, - cache_creation_input_tokens: cacheCreation, - }, - metadata: { - team_id: "team-1", - key_alias: "alias-1", - }, - }, - }, - }, - }, - }, - }); - - const spendDataWithCache: EntitySpendData = { - results: [makeDay(40, 25), makeDay(10, 5)], - metadata: mockSpendDataWithKeys.metadata, - }; - - const result = generateDailyWithKeysData(spendDataWithCache, "Team"); - - expect(result).toHaveLength(1); - expect(result[0]["Cache Read Input Tokens"]).toBe(50); - expect(result[0]["Cache Creation Input Tokens"]).toBe(30); - }); - - it("should sort data by date ascending", () => { - const result = generateDailyWithKeysData(mockSpendDataWithKeys, "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 aggregate metrics for duplicate date-team-key combinations", () => { - const spendDataWithDuplicates: 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, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 5.0, - api_requests: 50, - successful_requests: 48, - failed_requests: 2, - total_tokens: 500, - prompt_tokens: 300, - completion_tokens: 200, - }, - metadata: { - team_id: "team-1", - key_alias: "alias-1", - }, - }, - }, - }, - }, - }, - }, - { - 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: { - key1: { - metrics: { - spend: 5.0, - api_requests: 50, - successful_requests: 47, - failed_requests: 3, - total_tokens: 500, - prompt_tokens: 300, - completion_tokens: 200, - }, - metadata: { - team_id: "team-1", - key_alias: "alias-1", - }, - }, - }, - }, - }, - }, - }, - ], - metadata: { - total_spend: 21.0, - total_api_requests: 200, - total_successful_requests: 190, - total_failed_requests: 10, - total_tokens: 2000, - }, - }; - - const result = generateDailyWithKeysData(spendDataWithDuplicates, "Team"); - const key1Entries = result.filter((r) => r["Key ID"] === "key1"); - - expect(key1Entries).toHaveLength(1); - expect(key1Entries[0].Requests).toBe(100); - expect(key1Entries[0]["Successful Requests"]).toBe(95); - expect(key1Entries[0]["Failed Requests"]).toBe(5); - expect(key1Entries[0]["Total Tokens"]).toBe(1000); - }); - - it("should use team alias when available", () => { - const result = generateDailyWithKeysData(mockSpendDataWithKeys, "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 = generateDailyWithKeysData(mockSpendDataWithKeys, "Team"); - const entryWithoutTeamAlias = result.find((r) => r["Team ID"] === "team-1" && !mockTeamAliasMap[r["Team ID"]]); - - if (entryWithoutTeamAlias) { - expect(entryWithoutTeamAlias["Team"]).toBe("-"); - } - }); - - it("should use key alias when available", () => { - const result = generateDailyWithKeysData(mockSpendDataWithKeys, "Team"); - const key1Entry = result.find((r) => r["Key ID"] === "key1"); - - expect(key1Entry?.["Key Alias"]).toBe("alias-1"); - }); - - it("should use dash when key alias is not available", () => { - const spendDataWithoutKeyAlias: EntitySpendData = { - ...mockSpendDataWithKeys, - 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: { - key1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - prompt_tokens: 600, - completion_tokens: 400, - }, - metadata: { - team_id: "team-1", - }, - }, - }, - }, - }, - }, - }, - ], - metadata: mockSpendDataWithKeys.metadata, - }; - - const result = generateDailyWithKeysData(spendDataWithoutKeyAlias, "Team"); - const key1Entry = result.find((r) => r["Key ID"] === "key1"); - - expect(key1Entry?.["Key Alias"]).toBe("-"); - }); - - it("should use entity id when team id is not available in metadata", () => { - const spendDataWithoutTeamId: EntitySpendData = { - ...mockSpendDataWithKeys, - 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: { - key1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - prompt_tokens: 600, - completion_tokens: 400, - }, - metadata: {}, - }, - }, - }, - }, - }, - }, - ], - metadata: mockSpendDataWithKeys.metadata, - }; - - const result = generateDailyWithKeysData(spendDataWithoutTeamId, "Team"); - const entry = result.find((r) => r["Key ID"] === "key1"); - - expect(entry?.["Team ID"]).toBe("entity1"); - }); - - it("should use dash when team id is not available", () => { - const spendDataWithoutTeamId: EntitySpendData = { - ...mockSpendDataWithKeys, - 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: { - key1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - prompt_tokens: 600, - completion_tokens: 400, - }, - metadata: { - team_id: null, - }, - }, - }, - }, - }, - }, - }, - ], - metadata: mockSpendDataWithKeys.metadata, - }; - - const result = generateDailyWithKeysData(spendDataWithoutTeamId, "Team"); - const entry = result.find((r) => r["Key ID"] === "key1"); - - expect(entry?.["Team ID"]).toBe("entity1"); - }); - - it("should format spend values correctly", () => { - const result = generateDailyWithKeysData(mockSpendDataWithKeys, "Team"); - - expect(result[0]["Spend ($)"]).toBeDefined(); - }); - - it("should handle missing optional token fields", () => { - const spendDataWithMissingTokens: EntitySpendData = { - ...mockSpendDataWithKeys, - 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: { - 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", - }, - }, - }, - }, - }, - }, - }, - ], - metadata: mockSpendDataWithKeys.metadata, - }; - - const result = generateDailyWithKeysData(spendDataWithMissingTokens, "Team"); - const key1Entry = result.find((r) => r["Key ID"] === "key1"); - - expect(key1Entry?.["Prompt Tokens"]).toBe(0); - expect(key1Entry?.["Completion Tokens"]).toBe(0); - expect(key1Entry?.["Cache Read Input Tokens"]).toBe(0); - expect(key1Entry?.["Cache Creation Input Tokens"]).toBe(0); - }); - - it("should handle empty api_key_breakdown", () => { - const spendDataWithEmptyBreakdown: EntitySpendData = { - ...mockSpendDataWithKeys, - 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: mockSpendDataWithKeys.metadata, - }; - - const result = generateDailyWithKeysData(spendDataWithEmptyBreakdown, "Team"); - - expect(result).toHaveLength(0); - }); - - it("should handle multiple keys for same team on same date", () => { - const result = generateDailyWithKeysData(mockSpendDataWithKeys, "Team"); - const team1Entries = result.filter((r) => r["Team ID"] === "team-1" && r.Date === "2025-01-01"); - - expect(team1Entries.length).toBeGreaterThanOrEqual(2); - const keyIds = team1Entries.map((r) => r["Key ID"]); - expect(keyIds).toContain("key1"); - expect(keyIds).toContain("key2"); - }); - - it("should emit key owner columns right after Key ID", () => { - const result = generateDailyWithKeysData(usersFixture, "Team"); - - const columnNames = Object.keys(result[0]); - expect(columnNames[columnNames.indexOf("Key ID") + 1]).toBe("User ID"); - expect(columnNames[columnNames.indexOf("User ID") + 1]).toBe("User Email"); - - const kARow = result.find((r) => r["Key ID"] === "kA" && r.Date === "2025-03-01"); - expect(kARow?.["User ID"]).toBe("u1"); - expect(kARow?.["User Email"]).toBe("a@x"); - expect(kARow?.["Key Alias"]).toBe("alice-key"); - - const kCRow = result.find((r) => r["Key ID"] === "kC"); - expect(kCRow?.["User ID"]).toBe("u2"); - expect(kCRow?.["User Email"]).toBe("-"); - - const kDRow = result.find((r) => r["Key ID"] === "kD"); - expect(kDRow?.["User ID"]).toBe("-"); - expect(kDRow?.["User Email"]).toBe("-"); - }); - }); - - describe("generateDailyWithModelsData", () => { - const mockSpendDataWithModels: EntitySpendData = { - results: [ - { - date: "2025-01-01", - breakdown: { - entities: { - "team-1": { - 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, - }, - 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" }, - }, - }, - }, - "gpt-3.5-turbo": { - metrics: { - spend: 5.5, - api_requests: 50, - successful_requests: 47, - failed_requests: 3, - total_tokens: 500, - }, - api_key_breakdown: { - key2: { - metrics: { - spend: 5.5, - api_requests: 50, - successful_requests: 47, - failed_requests: 3, - total_tokens: 500, - }, - metadata: { team_id: "team-1" }, - }, - }, - }, - }, - }, - }, - ], - 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"); - expect(result[0]).toHaveProperty("Prompt Tokens"); - expect(result[0]).toHaveProperty("Completion Tokens"); - expect(result[0]).toHaveProperty("Cache Read Input Tokens"); - expect(result[0]).toHaveProperty("Cache Creation Input Tokens"); - }); - - it("should export prompt, completion, and cache token values summed across keys for the same model", () => { - const data: EntitySpendData = { - results: [ - { - date: "2025-03-01", - breakdown: { - entities: { - "team-1": { - metrics: { - spend: 5.0, - api_requests: 25, - successful_requests: 25, - failed_requests: 0, - total_tokens: 1050, - prompt_tokens: 750, - completion_tokens: 300, - cache_read_input_tokens: 450, - cache_creation_input_tokens: 200, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 2.0, - api_requests: 10, - successful_requests: 10, - failed_requests: 0, - total_tokens: 700, - }, - metadata: { team_id: "team-1" }, - }, - key2: { - metrics: { - spend: 3.0, - api_requests: 15, - successful_requests: 15, - failed_requests: 0, - total_tokens: 350, - }, - metadata: { team_id: "team-1" }, - }, - }, - }, - }, - models: { - "claude-sonnet-4-5": { - metrics: { - spend: 5.0, - api_requests: 25, - successful_requests: 25, - failed_requests: 0, - total_tokens: 1050, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 2.0, - api_requests: 10, - successful_requests: 10, - failed_requests: 0, - total_tokens: 700, - prompt_tokens: 500, - completion_tokens: 200, - cache_read_input_tokens: 300, - cache_creation_input_tokens: 120, - }, - metadata: { team_id: "team-1" }, - }, - key2: { - metrics: { - spend: 3.0, - api_requests: 15, - successful_requests: 15, - failed_requests: 0, - total_tokens: 350, - prompt_tokens: 250, - completion_tokens: 100, - cache_read_input_tokens: 150, - cache_creation_input_tokens: 80, - }, - metadata: { team_id: "team-1" }, - }, - }, - }, - }, - }, - }, - ], - metadata: { - total_spend: 5.0, - total_api_requests: 25, - total_successful_requests: 25, - total_failed_requests: 0, - total_tokens: 1050, - }, - }; - - const result = generateDailyWithModelsData(data, "Team"); - - expect(result).toHaveLength(1); - expect(result[0].Model).toBe("claude-sonnet-4-5"); - expect(result[0]["Total Tokens"]).toBe(1050); - expect(result[0]["Prompt Tokens"]).toBe(750); - expect(result[0]["Completion Tokens"]).toBe(300); - expect(result[0]["Cache Read Input Tokens"]).toBe(450); - expect(result[0]["Cache Creation Input Tokens"]).toBe(200); - }); - - 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, - }, - 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" }, - }, - }, - }, - }, - }, - }, - ...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 attribute each model only its own per-key spend", () => { - const result = generateDailyWithModelsData(mockSpendDataWithModels, "Team"); - - const gpt4Entry = result.find((r) => r.Model === "gpt-4"); - const gpt35Entry = result.find((r) => r.Model === "gpt-3.5-turbo"); - - expect(gpt4Entry?.["Spend ($)"]).toBe("5.0000"); - expect(gpt4Entry?.Requests).toBe(50); - expect(gpt4Entry?.["Total Tokens"]).toBe(500); - - expect(gpt35Entry?.["Spend ($)"]).toBe("5.5000"); - expect(gpt35Entry?.Requests).toBe(50); - expect(gpt35Entry?.["Total Tokens"]).toBe(500); - }); - - it("should not duplicate a user's spend across every model (regression for LIT overcount)", () => { - // One user, one key, that key used two models. The entity-level api_key_breakdown - // carries the key's total (8.0) across both models; each model's api_key_breakdown - // carries only that model's share (3.0 + 5.0). The per-model rows must sum back to - // the user-day total, not repeat the total once per model. - const data: EntitySpendData = { - results: [ - { - date: "2025-02-14", - breakdown: { - entities: { - user1: { - metrics: { - spend: 8.0, - api_requests: 80, - successful_requests: 78, - failed_requests: 2, - total_tokens: 800, - prompt_tokens: 500, - completion_tokens: 300, - cache_read_input_tokens: 0, - cache_creation_input_tokens: 0, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 8.0, - api_requests: 80, - successful_requests: 78, - failed_requests: 2, - total_tokens: 800, - }, - metadata: { team_id: "team-1" }, - }, - }, - }, - }, - models: { - "claude-3-haiku": { - metrics: { - spend: 3.0, - api_requests: 30, - successful_requests: 29, - failed_requests: 1, - total_tokens: 300, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 3.0, - api_requests: 30, - successful_requests: 29, - failed_requests: 1, - total_tokens: 300, - }, - metadata: { team_id: "team-1" }, - }, - }, - }, - "claude-sonnet-4-5": { - metrics: { - spend: 5.0, - api_requests: 50, - successful_requests: 49, - failed_requests: 1, - total_tokens: 500, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 5.0, - api_requests: 50, - successful_requests: 49, - failed_requests: 1, - total_tokens: 500, - }, - metadata: { team_id: "team-1" }, - }, - }, - }, - }, - }, - }, - ], - metadata: { - total_spend: 8.0, - total_api_requests: 80, - total_successful_requests: 78, - total_failed_requests: 2, - total_tokens: 800, - }, - }; - - const result = generateDailyWithModelsData(data, "User"); - - expect(result).toHaveLength(2); - - const haiku = result.find((r) => r.Model === "claude-3-haiku"); - const sonnet = result.find((r) => r.Model === "claude-sonnet-4-5"); - - expect(haiku?.["Spend ($)"]).toBe("3.0000"); - expect(sonnet?.["Spend ($)"]).toBe("5.0000"); - - const totalSpend = result.reduce((sum, r) => sum + parseFloat(r["Spend ($)"].replace(/,/g, "")), 0); - const totalRequests = result.reduce((sum, r) => sum + r.Requests, 0); - const totalTokens = result.reduce((sum, r) => sum + r["Total Tokens"], 0); - - expect(totalSpend).toBeCloseTo(8.0, 4); - expect(totalRequests).toBe(80); - expect(totalTokens).toBe(800); - }); - - it("should omit models the user never called instead of fanning out", () => { - // A second key (key2) belongs to a different user and is the only caller of - // gpt-3.5-turbo. user1 only used key1 -> gpt-4. user1 must get exactly one row. - const data: EntitySpendData = { - results: [ - { - date: "2025-02-14", - breakdown: { - entities: { - user1: { - metrics: { - spend: 5.0, - api_requests: 50, - successful_requests: 48, - failed_requests: 2, - total_tokens: 500, - prompt_tokens: 300, - completion_tokens: 200, - cache_read_input_tokens: 0, - cache_creation_input_tokens: 0, - }, - 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" }, - }, - }, - }, - }, - models: { - "gpt-4": { - metrics: { - spend: 5.0, - api_requests: 50, - successful_requests: 48, - failed_requests: 2, - total_tokens: 500, - }, - 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" }, - }, - }, - }, - "gpt-3.5-turbo": { - metrics: { - spend: 9.0, - api_requests: 90, - successful_requests: 90, - failed_requests: 0, - total_tokens: 900, - }, - api_key_breakdown: { - key2: { - metrics: { - spend: 9.0, - api_requests: 90, - successful_requests: 90, - failed_requests: 0, - total_tokens: 900, - }, - metadata: { team_id: "team-2" }, - }, - }, - }, - }, - }, - }, - ], - metadata: { - total_spend: 14.0, - total_api_requests: 140, - total_successful_requests: 138, - total_failed_requests: 2, - total_tokens: 1400, - }, - }; - - const result = generateDailyWithModelsData(data, "User"); - - expect(result).toHaveLength(1); - expect(result[0].Model).toBe("gpt-4"); - expect(result[0]["Spend ($)"]).toBe("5.0000"); - }); - - 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 keys data when scope is daily_with_keys", () => { - const mockDataWithKeys: 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: { - key1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - prompt_tokens: 600, - completion_tokens: 400, - }, - metadata: { - team_id: "team-1", - key_alias: "alias-1", - }, - }, - }, - }, - }, - }, - }, - ], - metadata: mockSpendData.metadata, - }; - - const result = generateExportData(mockDataWithKeys, "daily_with_keys", "Team", mockTeamAliasMap); - - expect(result.length).toBeGreaterThan(0); - expect(result[0]).toHaveProperty("Key Alias"); - expect(result[0]).toHaveProperty("Key ID"); - 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, - }, - 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 = 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); - }); - - it("should include total_flat_cost and total_cost in summary when total_flat_cost is present", () => { - const spendWithFlat: EntitySpendData = { - ...mockSpendData, - metadata: { ...mockSpendData.metadata, total_flat_cost: 6.45 }, - }; - const result = generateMetadata("team", mockDateRange, [], "daily", spendWithFlat); - expect(result.summary.total_flat_cost).toBeCloseTo(6.45, 4); - expect(result.summary.total_cost).toBeCloseTo(46.0 + 6.45, 4); - }); - - it("should omit total_flat_cost and total_cost when total_flat_cost is zero", () => { - const zeroFlat = { ...mockSpendData, metadata: { ...mockSpendData.metadata, total_flat_cost: 0 } }; - const result = generateMetadata("team", mockDateRange, [], "daily", zeroFlat); - expect(result.summary.total_flat_cost).toBeUndefined(); - expect(result.summary.total_cost).toBeUndefined(); - }); - }); - - describe("generateDailyData PTU flat cost", () => { - const dayWithFlat: EntitySpendData = { - results: [ - { - date: "2025-01-01", - breakdown: { - entities: { - "team-1": { - metrics: { - spend: 10, - flat_cost: 6.45, - api_requests: 50, - successful_requests: 50, - failed_requests: 0, - total_tokens: 500, - prompt_tokens: 300, - completion_tokens: 200, - cache_read_input_tokens: 0, - cache_creation_input_tokens: 0, - }, - api_key_breakdown: {}, - }, - }, - }, - }, - ], - metadata: { - total_spend: 10, - total_flat_cost: 6.45, - total_api_requests: 50, - total_successful_requests: 50, - total_failed_requests: 0, - total_tokens: 500, - }, - }; - - it("includes Flat Cost ($) and Total Cost ($) columns when total_flat_cost is present", () => { - const rows = generateDailyData(dayWithFlat, "Team", {}); - expect(rows).toHaveLength(1); - expect(rows[0]).toHaveProperty("Flat Cost ($)"); - expect(rows[0]).toHaveProperty("Total Cost ($)"); - expect(rows[0]["Flat Cost ($)"]).toBe("6.4500"); - expect(rows[0]["Total Cost ($)"]).toBe("16.4500"); - }); - - it("does not include Flat Cost / Total Cost columns when total_flat_cost is zero", () => { - const spendWithoutFlat: EntitySpendData = { - ...dayWithFlat, - metadata: { - total_spend: 10, - total_api_requests: 50, - total_successful_requests: 50, - total_failed_requests: 0, - total_tokens: 500, - total_flat_cost: 0, - }, - }; - const rows = generateDailyData(spendWithoutFlat, "User", {}); - expect(rows).toHaveLength(1); - expect(rows[0]).not.toHaveProperty("Flat Cost ($)"); - expect(rows[0]).not.toHaveProperty("Total Cost ($)"); - }); - }); - - 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"); - 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); - - const unparsedRows = vi.mocked(Papa.unparse).mock.calls[0][0] as Record[]; - expect(unparsedRows).toHaveLength(3); - const day1Team1 = unparsedRows.find((r) => r["Date"] === "2025-01-01" && r["Team ID"] === "team-1"); - expect(day1Team1?.["Cache Read Input Tokens"]).toBe(50); - - const exportedBlob = createObjectURLSpy.mock.calls[0][0] as Blob; - expect(exportedBlob.type).toBe("text/csv;charset=utf-8;"); - - expect(createElementSpy).toHaveBeenCalledWith("a"); - const attached = appendChildSpy.mock.calls[0][0] as HTMLAnchorElement; - expect(attached.download).toMatch(/^team_usage_daily_.*\.csv$/); - expect(removeChildSpy).toHaveBeenCalledWith(attached); - }); - - it("should generate correct filename", () => { - const anchorElement = document.createElement("a"); - 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; - }); - - it("should generate the daily_with_users filename and include User ID in the rows", () => { - const anchorElement = document.createElement("a"); - vi.spyOn(document, "createElement").mockReturnValue(anchorElement); - vi.useFakeTimers(); - vi.setSystemTime(new Date("2025-03-01T12:00:00Z")); - - handleExportCSV(usersFixture, "daily_with_users", "Team", "team", mockTeamAliasMap); - vi.useRealTimers(); - - expect(anchorElement.download).toBe("team_usage_daily_with_users_2025-03-01.csv"); - - const unparsedRows = vi.mocked(Papa.unparse).mock.calls[0][0] as Record[]; - expect(unparsedRows[0]).toHaveProperty("User ID"); - }); - }); - - 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"); - 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); - - const exportedBlob = createObjectURLSpy.mock.calls[0][0] as Blob; - expect(exportedBlob.type).toBe("application/json"); - - expect(createElementSpy).toHaveBeenCalledWith("a"); - const attached = appendChildSpy.mock.calls[0][0] as HTMLAnchorElement; - expect(attached.download).toMatch(/^team_usage_daily_.*\.json$/); - expect(removeChildSpy).toHaveBeenCalledWith(attached); - }); - - it("should generate correct filename", () => { - const anchorElement = document.createElement("a"); - 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; - }); - }); - - describe("resolveEntities and aggregated endpoint fallback", () => { - // Simulates the response from /user/daily/activity/aggregated which has - // empty entities but populated api_keys at the breakdown level. - // Derived from mockSpendData: flatten all entities' api_key_breakdowns - // into top-level api_keys, clear entities, and add a second key for team-1 - // to test multi-key grouping. - const aggregatedSpendData: EntitySpendData = { - ...mockSpendData, - results: mockSpendData.results.slice(0, 1).map((day) => ({ - ...day, - breakdown: { - entities: {}, - api_keys: { - ...Object.fromEntries( - Object.values(day.breakdown.entities as Record).flatMap((e: any) => - Object.entries(e.api_key_breakdown || {}), - ), - ), - // Extra key on team-1 to test multi-key-per-team aggregation - key1b: { - metrics: { spend: 5, api_requests: 50, successful_requests: 48, failed_requests: 2, total_tokens: 500 }, - metadata: { team_id: "team-1", key_alias: "staging-key" }, - }, - }, - models: { - "gpt-4": { - metrics: { spend: 35.8, api_requests: 350, total_tokens: 3500 }, - 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" }, - }, - key1b: { - metrics: { - spend: 5, - api_requests: 50, - successful_requests: 48, - failed_requests: 2, - total_tokens: 500, - }, - metadata: { team_id: "team-1" }, - }, - key2: { - metrics: { - spend: 20.3, - api_requests: 200, - successful_requests: 195, - failed_requests: 5, - total_tokens: 2000, - }, - metadata: { team_id: "team-2" }, - }, - }, - }, - }, - }, - })), - }; - - describe("resolveEntities", () => { - it("should return entities when populated", () => { - const breakdown = { - entities: { e1: { metrics: { spend: 1 } } }, - api_keys: { k1: { metrics: { spend: 2 }, metadata: { team_id: "t1" } } }, - }; - const result = resolveEntities(breakdown); - expect(result).toBe(breakdown.entities); - }); - - it("should aggregate api_keys into entities when entities is empty", () => { - const breakdown = aggregatedSpendData.results[0].breakdown; - const result = resolveEntities(breakdown); - - // Two teams: team-1 (key1+key2) and team-2 (key3) - expect(Object.keys(result)).toHaveLength(2); - expect(result["team-1"]).toBeDefined(); - expect(result["team-2"]).toBeDefined(); - - // team-1 spend = 10.5 (key1) + 5 (key1b) - expect(result["team-1"].metrics.spend).toBe(15.5); - expect(result["team-1"].metrics.api_requests).toBe(150); - expect(result["team-1"].metrics.total_tokens).toBe(1500); - - // team-2 spend = 20.3 (key2) - expect(result["team-2"].metrics.spend).toBe(20.3); - expect(result["team-2"].metrics.api_requests).toBe(200); - }); - - it("should use 'Unassigned' for keys without team_id", () => { - const breakdown = { - entities: {}, - api_keys: { - k1: { - metrics: { spend: 7, api_requests: 10, successful_requests: 10, failed_requests: 0, total_tokens: 100 }, - metadata: {}, - }, - }, - }; - const result = resolveEntities(breakdown); - expect(result["Unassigned"]).toBeDefined(); - expect(result["Unassigned"].metrics.spend).toBe(7); - }); - - it("should handle missing or empty api_keys gracefully", () => { - expect(Object.keys(resolveEntities({ entities: {}, api_keys: {} }))).toHaveLength(0); - expect(Object.keys(resolveEntities({ entities: {} }))).toHaveLength(0); - }); - - it("should preserve api_key_breakdown on aggregated entities", () => { - const breakdown = aggregatedSpendData.results[0].breakdown; - const result = resolveEntities(breakdown); - - // team-1 should have key1 and key1b in api_key_breakdown - expect(Object.keys(result["team-1"].api_key_breakdown)).toEqual(["key1", "key1b"]); - // team-2 should have key2 - expect(Object.keys(result["team-2"].api_key_breakdown)).toEqual(["key2"]); - }); - }); - - describe("getEntityBreakdown with aggregated data", () => { - it("should produce breakdown from api_keys when entities is empty", () => { - const result = getEntityBreakdown(aggregatedSpendData); - expect(result.length).toBeGreaterThan(0); - - // Sorted by spend desc: team-2 (20.3) then team-1 (15.5) - expect(result[0].metrics.spend).toBe(20.3); - expect(result[1].metrics.spend).toBe(15.5); - }); - }); - - describe("generateDailyData with aggregated data", () => { - it("should produce rows from api_keys when entities is empty", () => { - const result = generateDailyData(aggregatedSpendData, "Team"); - expect(result.length).toBeGreaterThan(0); - expect(result[0]).toHaveProperty("Date"); - expect(result[0]).toHaveProperty("Team"); - }); - }); - - describe("generateDailyWithKeysData with aggregated data", () => { - it("should produce rows from api_keys when entities is empty", () => { - const result = generateDailyWithKeysData(aggregatedSpendData, "Team"); - expect(result.length).toBeGreaterThan(0); - - // Should have 3 key rows (key1, key1b, key2) - expect(result).toHaveLength(3); - const keyIds = result.map((r) => r["Key ID"]); - expect(keyIds).toContain("key1"); - expect(keyIds).toContain("key1b"); - expect(keyIds).toContain("key2"); - }); - }); - - describe("generateDailyWithModelsData with aggregated data", () => { - it("should produce rows from api_keys when entities is empty", () => { - const result = generateDailyWithModelsData(aggregatedSpendData, "Team"); - expect(result.length).toBeGreaterThan(0); - expect(result[0]).toHaveProperty("Model"); - - // team-1 = key1 (10.5) + key1b (5) on gpt-4; team-2 = key2 (20.3) on gpt-4. - // Spend must aggregate per team-key, not repeat the model total per team. - const team1 = result.find((r) => r["Team ID"] === "team-1"); - const team2 = result.find((r) => r["Team ID"] === "team-2"); - expect(team1?.["Spend ($)"]).toBe("15.5000"); - expect(team2?.["Spend ($)"]).toBe("20.3000"); - }); - }); - }); - - describe("display name resolution from entity metadata", () => { - const entityMetrics = { - spend: 12.25, - api_requests: 40, - successful_requests: 39, - failed_requests: 1, - total_tokens: 900, - prompt_tokens: 500, - completion_tokens: 400, - cache_read_input_tokens: 20, - cache_creation_input_tokens: 10, - }; - - const makeSpendData = (entity: string, metadata?: Record): EntitySpendData => ({ - results: [ - { - date: "2025-04-01", - breakdown: { - entities: { - [entity]: { - metrics: entityMetrics, - metadata, - api_key_breakdown: { - key1: { - metrics: entityMetrics, - metadata: { key_alias: "prod-key" }, - }, - }, - }, - }, - }, - }, - ], - metadata: mockSpendData.metadata, - }); - - it("should export the user email as the entity label and keep the raw user id in the id column", () => { - const result = generateDailyData( - makeSpendData("user-123", { user_email: "ada@example.com", user_alias: "Ada" }), - "User", - ); - - expect(result).toHaveLength(1); - expect(result[0]["User"]).toBe("ada@example.com"); - expect(result[0]["User ID"]).toBe("user-123"); - }); - - it("should fall back to the user alias when the user has no email", () => { - const nullEmail = generateDailyData( - makeSpendData("user-123", { user_email: null, user_alias: "Ada Lovelace" }), - "User", - ); - const missingEmail = generateDailyData(makeSpendData("user-123", { user_alias: "Ada Lovelace" }), "User"); - - expect(nullEmail[0]["User"]).toBe("Ada Lovelace"); - expect(missingEmail[0]["User"]).toBe("Ada Lovelace"); - }); - - it("should fall back to the raw entity key when the entity carries no metadata", () => { - const noMetadata = generateDailyData(makeSpendData("my-tag"), "Tag"); - const emptyMetadata = generateDailyData(makeSpendData("customer-9", {}), "Customer"); - const blankNames = generateDailyData(makeSpendData("user-123", { user_email: null, user_alias: null }), "User"); - - expect(noMetadata[0]["Tag"]).toBe("my-tag"); - expect(emptyMetadata[0]["Customer"]).toBe("customer-9"); - expect(blankNames[0]["User"]).toBe("user-123"); - }); - - it("should prefer the team alias map over any alias in entity metadata", () => { - const result = generateDailyData( - makeSpendData("team-1", { team_alias: "Stale Alias", user_email: "ada@example.com" }), - "Team", - mockTeamAliasMap, - ); - - expect(result[0]["Team"]).toBe("Team One"); - }); - - it("should use the team alias from entity metadata when the alias map has no entry for the team", () => { - const result = generateDailyData( - makeSpendData("team-9", { team_alias: "Team Nine", user_email: "ada@example.com" }), - "Team", - mockTeamAliasMap, - ); - - expect(result[0]["Team"]).toBe("Team Nine"); - }); - - it("should resolve metadata.alias to the user email in getEntityBreakdown", () => { - const withEmail = getEntityBreakdown( - makeSpendData("user-123", { user_email: "ada@example.com", user_alias: "Ada" }), - ); - const withoutEmail = getEntityBreakdown(makeSpendData("user-123", { user_alias: "Ada" })); - - expect(withEmail[0].metadata.alias).toBe("ada@example.com"); - expect(withEmail[0].metadata.id).toBe("user-123"); - expect(withoutEmail[0].metadata.alias).toBe("Ada"); - }); - - it("should resolve the user email on every key row of the keys scope", () => { - const spendData: EntitySpendData = { - results: [ - { - date: "2025-04-01", - breakdown: { - entities: { - "user-123": { - metrics: entityMetrics, - metadata: { user_email: "ada@example.com", user_alias: "Ada" }, - api_key_breakdown: { - key1: { metrics: entityMetrics, metadata: { key_alias: "prod-key" } }, - key2: { metrics: entityMetrics, metadata: { key_alias: "dev-key" } }, - }, - }, - }, - }, - }, - ], - metadata: mockSpendData.metadata, - }; - - const result = generateDailyWithKeysData(spendData, "User"); - - expect(result).toHaveLength(2); - expect(result.map((r) => r["User"])).toEqual(["ada@example.com", "ada@example.com"]); - expect(result.map((r) => r["User ID"])).toEqual(["user-123", "user-123"]); - expect(result.find((r) => r["Key ID"] === "key1")?.["Key Alias"]).toBe("prod-key"); - expect(result.find((r) => r["Key ID"] === "key2")?.["Key Alias"]).toBe("dev-key"); - }); - - it("should resolve each entity's own email in the models scope", () => { - const spendData: EntitySpendData = { - results: [ - { - date: "2025-04-01", - breakdown: { - entities: { - "user-a": { - metrics: entityMetrics, - metadata: { user_email: "ada@example.com", user_alias: "Ada" }, - api_key_breakdown: { key1: { metrics: entityMetrics, metadata: {} } }, - }, - "user-b": { - metrics: entityMetrics, - metadata: { user_email: null, user_alias: "Grace" }, - api_key_breakdown: { key2: { metrics: entityMetrics, metadata: {} } }, - }, - }, - models: { - "claude-sonnet-4-5": { - metrics: entityMetrics, - api_key_breakdown: { - key1: { metrics: entityMetrics, metadata: {} }, - key2: { metrics: entityMetrics, metadata: {} }, - }, - }, - }, - }, - }, - ], - metadata: mockSpendData.metadata, - }; - - const result = generateDailyWithModelsData(spendData, "User"); - - expect(result).toHaveLength(2); - expect(result.every((r) => r.Model === "claude-sonnet-4-5")).toBe(true); - expect(result.find((r) => r["User ID"] === "user-a")?.["User"]).toBe("ada@example.com"); - expect(result.find((r) => r["User ID"] === "user-b")?.["User"]).toBe("Grace"); - }); - }); - - describe("generateDailyWithUsersData", () => { - it("should reconcile spend with daily_with_keys and daily per date and team", () => { - const byUser = generateDailyWithUsersData(usersFixture, "Team"); - const byKey = generateDailyWithKeysData(usersFixture, "Team"); - const daily = generateDailyData(usersFixture, "Team"); - - expect(byUser.length).toBeGreaterThan(0); - expect(byKey.length).toBeGreaterThan(0); - expect(daily.length).toBeGreaterThan(0); - - const sumSpend = (rows: any[]): Record => { - const totals: Record = {}; - rows.forEach((r) => { - const bucket = `${r.Date}|${r["Team ID"]}`; - totals[bucket] = (totals[bucket] || 0) + Number(r["Spend ($)"]); - }); - return totals; - }; - - const userTotals = sumSpend(byUser); - const keyTotals = sumSpend(byKey); - - daily.forEach((row) => { - const bucket = `${row.Date}|${row["Team ID"]}`; - expect(userTotals[bucket]).toBeCloseTo(Number(row["Spend ($)"]), 4); - expect(keyTotals[bucket]).toBeCloseTo(Number(row["Spend ($)"]), 4); - }); - }); - - it("should roll multiple keys owned by one user in a team into a single row", () => { - const rows = generateDailyWithUsersData(usersFixture, "Team"); - const matches = rows.filter((r) => r.Date === "2025-03-01" && r["Team ID"] === "team-1" && r["User ID"] === "u1"); - - expect(matches).toHaveLength(1); - const row = matches[0]; - expect(row.Keys).toBe(2); - expect(row["User Email"]).toBe("a@x"); - expect(row["Spend ($)"]).toBe("3.3000"); - expect(row.Requests).toBe(33); - expect(row["Successful Requests"]).toBe(30); - expect(row["Failed Requests"]).toBe(3); - expect(row["Total Tokens"]).toBe(330); - expect(row["Prompt Tokens"]).toBe(190); - expect(row["Completion Tokens"]).toBe(140); - expect(row["Cache Read Input Tokens"]).toBe(18); - expect(row["Cache Creation Input Tokens"]).toBe(12); - }); - - it("should bucket keys with no owner into an Unassigned row without dropping spend", () => { - const rows = generateDailyWithUsersData(usersFixture, "Team"); - const row = rows.find( - (r) => r.Date === "2025-03-01" && r["Team ID"] === "team-1" && r["User ID"] === "Unassigned", - ); - - expect(row).toBeDefined(); - expect(row?.["User Email"]).toBe("-"); - expect(Number(row?.["Spend ($)"])).toBeCloseTo(4.4, 4); - }); - - it("should keep different users in the same team as separate rows", () => { - const rows = generateDailyWithUsersData(usersFixture, "Team"); - const teamRows = rows.filter((r) => r.Date === "2025-03-01" && r["Team ID"] === "team-1"); - - const u1Rows = teamRows.filter((r) => r["User ID"] === "u1"); - const u2Rows = teamRows.filter((r) => r["User ID"] === "u2"); - expect(u1Rows).toHaveLength(1); - expect(u2Rows).toHaveLength(1); - expect(Number(u2Rows[0]["Spend ($)"])).toBeCloseTo(3.3, 4); - }); - - it("should keep the same user in different teams as separate rows", () => { - const rows = generateDailyWithUsersData(usersFixture, "Team"); - const u1Rows = rows.filter((r) => r.Date === "2025-03-01" && r["User ID"] === "u1"); - - expect(u1Rows).toHaveLength(2); - const team1Row = u1Rows.find((r) => r["Team ID"] === "team-1"); - const team2Row = u1Rows.find((r) => r["Team ID"] === "team-2"); - expect(team1Row?.Keys).toBe(2); - expect(team2Row?.Keys).toBe(1); - expect(Number(team2Row?.["Spend ($)"])).toBeCloseTo(6.6, 4); - }); - - it("should show a dash email when the user has none", () => { - const rows = generateDailyWithUsersData(usersFixture, "Team"); - const row = rows.find((r) => r.Date === "2025-03-01" && r["Team ID"] === "team-1" && r["User ID"] === "u3"); - - expect(row).toBeDefined(); - expect(row?.["User Email"]).toBe("-"); - }); - - it("should still attribute a deleted key to its user", () => { - const rows = generateDailyWithUsersData(usersFixture, "Team"); - const row = rows.find((r) => r.Date === "2025-03-01" && r["Team ID"] === "team-1" && r["User ID"] === "u3"); - - expect(row).toBeDefined(); - expect(Number(row?.["Spend ($)"])).toBeCloseTo(5.5, 4); - expect(row?.Requests).toBe(55); - }); - - it("should group rows under team_id on the aggregated endpoint shape", () => { - const aggregatedFixture: EntitySpendData = { - results: [ - { - date: "2025-03-01", - breakdown: { - entities: {}, - api_keys: { - kA: { - metrics: { - spend: 1.1, - api_requests: 11, - successful_requests: 10, - failed_requests: 1, - total_tokens: 110, - prompt_tokens: 60, - completion_tokens: 50, - cache_read_input_tokens: 6, - cache_creation_input_tokens: 4, - }, - metadata: { team_id: "team-1", user_id: "u1", user_email: "a@x" }, - }, - kF: { - metrics: { - spend: 6.6, - api_requests: 66, - successful_requests: 64, - failed_requests: 2, - total_tokens: 660, - prompt_tokens: 370, - completion_tokens: 290, - cache_read_input_tokens: 36, - cache_creation_input_tokens: 24, - }, - metadata: { team_id: "team-2", user_id: "u2" }, - }, - }, - }, - }, - ], - metadata: usersFixture.metadata, - }; - - const rows = generateDailyWithUsersData(aggregatedFixture, "Team"); - - expect(rows).toHaveLength(2); - const team1Row = rows.find((r) => r["Team ID"] === "team-1"); - const team2Row = rows.find((r) => r["Team ID"] === "team-2"); - expect(team1Row?.["User ID"]).toBe("u1"); - expect(team2Row?.["User ID"]).toBe("u2"); - expect(Number(team1Row?.["Spend ($)"])).toBeCloseTo(1.1, 4); - expect(Number(team2Row?.["Spend ($)"])).toBeCloseTo(6.6, 4); - }); - - it("should emit the exact column order and sort by date ascending", () => { - const rows = generateDailyWithUsersData(usersFixture, "Team"); - - expect(Object.keys(rows[0])).toEqual([ - "Date", - "Team", - "Team ID", - "User ID", - "User Email", - "Keys", - "Spend ($)", - "Requests", - "Successful Requests", - "Failed Requests", - "Total Tokens", - "Prompt Tokens", - "Completion Tokens", - "Cache Read Input Tokens", - "Cache Creation Input Tokens", - ]); - - const dates = rows.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 dispatch daily_with_users through generateExportData", () => { - expect(generateExportData(usersFixture, "daily_with_users", "Team")).toEqual( - generateDailyWithUsersData(usersFixture, "Team"), - ); - }); - - it("should keep owners separate when entity and user ids contain underscores", () => { - const collisionFixture: EntitySpendData = { - results: [ - { - date: "2025-03-01", - breakdown: { - entities: { - team_1: { - metrics: { spend: 1, api_requests: 1, total_tokens: 10 }, - api_key_breakdown: { - kX: { - metrics: { spend: 1, api_requests: 1, total_tokens: 10 }, - metadata: { team_id: "team_1", user_id: "u1" }, - }, - }, - }, - team: { - metrics: { spend: 2, api_requests: 2, total_tokens: 20 }, - api_key_breakdown: { - kY: { - metrics: { spend: 2, api_requests: 2, total_tokens: 20 }, - metadata: { team_id: "team", user_id: "1_u1" }, - }, - }, - }, - }, - }, - }, - ], - metadata: usersFixture.metadata, - }; - - const rows = generateDailyWithUsersData(collisionFixture, "Team"); - - expect(rows).toHaveLength(2); - const team1Row = rows.find((r) => r["Team ID"] === "team_1"); - expect(team1Row?.["User ID"]).toBe("u1"); - expect(team1Row?.Keys).toBe(1); - expect(team1Row?.["Spend ($)"]).toBe("1.0000"); - const teamRow = rows.find((r) => r["Team ID"] === "team"); - expect(teamRow?.["User ID"]).toBe("1_u1"); - expect(teamRow?.Keys).toBe(1); - expect(teamRow?.["Spend ($)"]).toBe("2.0000"); - }); - - it("should leave daily and daily_with_models output without user columns", () => { - const daily = generateDailyData(usersFixture, "Team"); - expect(daily[0]).not.toHaveProperty("User ID"); - - const modelsFixture: EntitySpendData = { - results: [ - { - date: "2025-03-01", - breakdown: { - entities: { - "team-1": { - metrics: { - spend: 1.1, - api_requests: 11, - successful_requests: 10, - failed_requests: 1, - total_tokens: 110, - prompt_tokens: 60, - completion_tokens: 50, - }, - api_key_breakdown: { - kA: { - metrics: { - spend: 1.1, - api_requests: 11, - successful_requests: 10, - failed_requests: 1, - total_tokens: 110, - }, - metadata: { team_id: "team-1", user_id: "u1", user_email: "a@x" }, - }, - }, - }, - }, - models: { - "gpt-4o": { - metrics: { spend: 1.1, api_requests: 11, total_tokens: 110 }, - api_key_breakdown: { - kA: { - metrics: { - spend: 1.1, - api_requests: 11, - successful_requests: 10, - failed_requests: 1, - total_tokens: 110, - }, - metadata: {}, - }, - }, - }, - }, - }, - }, - ], - metadata: usersFixture.metadata, - }; - - const modelRows = generateDailyWithModelsData(modelsFixture, "Team"); - expect(modelRows).toHaveLength(1); - expect(Object.keys(modelRows[0])).toEqual([ - "Date", - "Team", - "Team ID", - "Model", - "Spend ($)", - "Requests", - "Successful", - "Failed", - "Total Tokens", - "Prompt Tokens", - "Completion Tokens", - "Cache Read Input Tokens", - "Cache Creation Input Tokens", - ]); - }); + it("triggers an anchor click carrying the filename and revokes the object URL", () => { + const click = vi.fn(); + const revoke = vi.fn(); + const anchor = { href: "", download: "", click, remove: vi.fn() }; + const createElement = vi.fn(() => anchor); + const appendChild = vi.fn((node: unknown) => node); + const removeChild = vi.fn((node: unknown) => node); + const fakeURL = { ...URL, createObjectURL: vi.fn(() => "blob:mock"), revokeObjectURL: revoke }; + vi.stubGlobal("URL", fakeURL); + vi.stubGlobal("window", { URL: fakeURL }); + vi.stubGlobal("document", { + createElement, + body: { appendChild, removeChild }, + }); + + downloadBlob(new Blob(["data"]), "team_usage_daily_2025-01-01_2025-01-31.csv"); + + expect(createElement).toHaveBeenCalledWith("a"); + expect(anchor.download).toBe("team_usage_daily_2025-01-01_2025-01-31.csv"); + expect(anchor.href).toBe("blob:mock"); + expect(click).toHaveBeenCalledOnce(); + expect(revoke).toHaveBeenCalledWith("blob:mock"); }); }); diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts b/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts index 95ce584cc89..0f2768c8417 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts @@ -1,497 +1,23 @@ -import { formatNumberWithCommas } from "@/utils/dataUtils"; import type { DateRangePickerValue } from "@/components/shared/date_picker_types"; -import Papa from "papaparse"; -import { keyActivityLabel } from "@/components/UsagePage/keyActivityLabel"; -import type { EntityBreakdown, EntitySpendData, EntityType, ExportMetadata, ExportScope } from "./types"; +import type { EntityType, ExportFormat, ExportType } from "./types"; -const resolveEntityDisplay = ( - entity: string, - teamAliasMap: Record, - entityMetadata?: Record, -): { id: string; alias: string } => ({ - id: entity, - alias: - teamAliasMap[entity] || - entityMetadata?.team_alias || - entityMetadata?.user_email || - entityMetadata?.user_alias || - entity, -}); +const fileDay = (date: Date | undefined): string => + date + ? `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}` + : "all"; -// Mirrors backend SpendMetrics fields (litellm/types/activity_tracking.py). -// If the backend adds a field, add it here too. -const METRIC_KEYS = [ - "spend", - "api_requests", - "successful_requests", - "failed_requests", - "total_tokens", - "prompt_tokens", - "completion_tokens", - "cache_read_input_tokens", - "cache_creation_input_tokens", -] as const; - -// When breakdown.entities is empty (aggregated endpoint), reconstruct entities -// from breakdown.api_keys by grouping on metadata.team_id. -const aggregateApiKeysIntoEntities = (breakdown: Record): Record => { - const apiKeys = breakdown.api_keys; - if (!apiKeys || Object.keys(apiKeys).length === 0) return {}; - - const grouped: Record = {}; - - for (const [keyId, keyData] of Object.entries(apiKeys)) { - const teamId = keyData?.metadata?.team_id || "Unassigned"; - if (!grouped[teamId]) { - grouped[teamId] = { - metrics: Object.fromEntries(METRIC_KEYS.map((k) => [k, 0])), - api_key_breakdown: {}, - }; - } - const m = grouped[teamId].metrics; - const km = keyData?.metrics || {}; - for (const k of METRIC_KEYS) { - m[k] += km[k] || 0; - } - grouped[teamId].api_key_breakdown[keyId] = keyData; - } - - return grouped; -}; - -// Returns breakdown.entities if populated, otherwise falls back to -// reconstructing entities from breakdown.api_keys. -export const resolveEntities = (breakdown: Record): Record => { - const entities = breakdown.entities; - if (entities && Object.keys(entities).length > 0) return entities; - return aggregateApiKeysIntoEntities(breakdown); -}; - -export const getEntityBreakdown = ( - spendData: EntitySpendData, - teamAliasMap: Record = {}, -): EntityBreakdown[] => { - const entitySpend: { [key: string]: EntityBreakdown } = {}; - - spendData.results.forEach((day) => { - Object.entries(resolveEntities(day.breakdown)).forEach(([entity, data]: [string, any]) => { - const { id, alias } = resolveEntityDisplay(entity, teamAliasMap, data.metadata); - - if (!entitySpend[entity]) { - entitySpend[entity] = { - metrics: { - spend: 0, - prompt_tokens: 0, - completion_tokens: 0, - total_tokens: 0, - api_requests: 0, - successful_requests: 0, - failed_requests: 0, - cache_read_input_tokens: 0, - cache_creation_input_tokens: 0, - }, - metadata: { - alias, - id, - }, - }; - } - entitySpend[entity].metrics.spend += data.metrics.spend; - entitySpend[entity].metrics.api_requests += data.metrics.api_requests; - entitySpend[entity].metrics.successful_requests += data.metrics.successful_requests; - entitySpend[entity].metrics.failed_requests += data.metrics.failed_requests; - entitySpend[entity].metrics.total_tokens += data.metrics.total_tokens; - entitySpend[entity].metrics.prompt_tokens += data.metrics.prompt_tokens || 0; - entitySpend[entity].metrics.completion_tokens += data.metrics.completion_tokens || 0; - entitySpend[entity].metrics.cache_read_input_tokens += data.metrics.cache_read_input_tokens || 0; - entitySpend[entity].metrics.cache_creation_input_tokens += data.metrics.cache_creation_input_tokens || 0; - }); - }); - - return Object.values(entitySpend).sort((a, b) => b.metrics.spend - a.metrics.spend); -}; - -// total_flat_cost defaults to 0 on every entity response, so only a non-zero value -// means a PTU-configured team actually accrued flat cost worth exporting. -const hasFlatCost = (spendData: EntitySpendData): boolean => (spendData.metadata.total_flat_cost ?? 0) > 0; - -export const generateDailyData = ( - spendData: EntitySpendData, - entityLabel: string, - teamAliasMap: Record = {}, -): any[] => { - const dailyBreakdown: any[] = []; - const includeFlatCost = hasFlatCost(spendData); - - spendData.results.forEach((day) => { - Object.entries(resolveEntities(day.breakdown)).forEach(([entity, data]: [string, any]) => { - const { id, alias } = resolveEntityDisplay(entity, teamAliasMap, data.metadata); - - const row: Record = { - Date: day.date, - [entityLabel]: alias, - [`${entityLabel} ID`]: id, - "Spend ($)": formatNumberWithCommas(data.metrics.spend, 4), - }; - if (includeFlatCost) { - const flatCost = data.metrics.flat_cost || 0; - row["Flat Cost ($)"] = formatNumberWithCommas(flatCost, 4); - row["Total Cost ($)"] = formatNumberWithCommas((data.metrics.spend || 0) + flatCost, 4); - } - row.Requests = data.metrics.api_requests; - row["Successful Requests"] = data.metrics.successful_requests; - row["Failed Requests"] = data.metrics.failed_requests; - row["Total Tokens"] = data.metrics.total_tokens; - row["Prompt Tokens"] = data.metrics.prompt_tokens || 0; - row["Completion Tokens"] = data.metrics.completion_tokens || 0; - row["Cache Read Input Tokens"] = data.metrics.cache_read_input_tokens || 0; - row["Cache Creation Input Tokens"] = data.metrics.cache_creation_input_tokens || 0; - dailyBreakdown.push(row); - }); - }); - - return dailyBreakdown.sort((a, b) => new Date(a.Date).getTime() - new Date(b.Date).getTime()); -}; - -export const generateDailyWithKeysData = ( - spendData: EntitySpendData, - entityLabel: string, - teamAliasMap: Record = {}, -): any[] => { - // Aggregate by unique (Date, Entity ID, Key ID) combination to prevent duplicates - const aggregatedData: { - [key: string]: { - Date: string; - entityId: string; - entityAlias: string; - keyId: string; - keyAlias: string | null; - userId: string | null; - userEmail: string | null; - metrics: { - spend: number; - api_requests: number; - successful_requests: number; - failed_requests: number; - total_tokens: number; - prompt_tokens: number; - completion_tokens: number; - cache_read_input_tokens: number; - cache_creation_input_tokens: number; - }; - }; - } = {}; - - spendData.results.forEach((day) => { - Object.entries(resolveEntities(day.breakdown)).forEach(([entity, data]: [string, any]) => { - const { id: entityId, alias: entityAlias } = resolveEntityDisplay(entity, teamAliasMap, data.metadata); - const apiKeyBreakdown = data.api_key_breakdown || {}; - - // Iterate through each API key in the breakdown - Object.entries(apiKeyBreakdown).forEach(([keyId, keyData]: [string, any]) => { - const keyAlias = keyActivityLabel(keyData?.metadata, "") || null; - - // Create unique key for aggregation: Date_EntityID_KeyID - const uniqueKey = `${day.date}_${entityId}_${keyId}`; - - if (!aggregatedData[uniqueKey]) { - // First time seeing this (Date, Entity ID, Key ID) combination - aggregatedData[uniqueKey] = { - Date: day.date, - entityId, - entityAlias, - keyId, - keyAlias, - userId: keyData?.metadata?.user_id || null, - userEmail: keyData?.metadata?.user_email || null, - metrics: { - spend: keyData.metrics?.spend || 0, - api_requests: keyData.metrics?.api_requests || 0, - successful_requests: keyData.metrics?.successful_requests || 0, - failed_requests: keyData.metrics?.failed_requests || 0, - total_tokens: keyData.metrics?.total_tokens || 0, - prompt_tokens: keyData.metrics?.prompt_tokens || 0, - completion_tokens: keyData.metrics?.completion_tokens || 0, - cache_read_input_tokens: keyData.metrics?.cache_read_input_tokens || 0, - cache_creation_input_tokens: keyData.metrics?.cache_creation_input_tokens || 0, - }, - }; - } else { - // Aggregate metrics for existing entry - aggregatedData[uniqueKey].metrics.spend += keyData.metrics?.spend || 0; - aggregatedData[uniqueKey].metrics.api_requests += keyData.metrics?.api_requests || 0; - aggregatedData[uniqueKey].metrics.successful_requests += keyData.metrics?.successful_requests || 0; - aggregatedData[uniqueKey].metrics.failed_requests += keyData.metrics?.failed_requests || 0; - aggregatedData[uniqueKey].metrics.total_tokens += keyData.metrics?.total_tokens || 0; - aggregatedData[uniqueKey].metrics.prompt_tokens += keyData.metrics?.prompt_tokens || 0; - aggregatedData[uniqueKey].metrics.completion_tokens += keyData.metrics?.completion_tokens || 0; - aggregatedData[uniqueKey].metrics.cache_read_input_tokens += keyData.metrics?.cache_read_input_tokens || 0; - aggregatedData[uniqueKey].metrics.cache_creation_input_tokens += - keyData.metrics?.cache_creation_input_tokens || 0; - } - }); - }); - }); - - // Convert aggregated data to array format - const dailyKeyBreakdown = Object.values(aggregatedData).map((item) => ({ - Date: item.Date, - [entityLabel]: item.entityAlias, - [`${entityLabel} ID`]: item.entityId, - "Key Alias": item.keyAlias || "-", - "Key ID": item.keyId, - ...(entityLabel === "User" ? {} : { "User ID": item.userId || "-", "User Email": item.userEmail || "-" }), - "Spend ($)": formatNumberWithCommas(item.metrics.spend, 4), - Requests: item.metrics.api_requests, - "Successful Requests": item.metrics.successful_requests, - "Failed Requests": item.metrics.failed_requests, - "Total Tokens": item.metrics.total_tokens, - "Prompt Tokens": item.metrics.prompt_tokens, - "Completion Tokens": item.metrics.completion_tokens, - "Cache Read Input Tokens": item.metrics.cache_read_input_tokens, - "Cache Creation Input Tokens": item.metrics.cache_creation_input_tokens, - })); - - return dailyKeyBreakdown.sort((a, b) => new Date(a.Date).getTime() - new Date(b.Date).getTime()); -}; - -export const generateDailyWithUsersData = ( - spendData: EntitySpendData, - entityLabel: string, - teamAliasMap: Record = {}, -): any[] => { - const aggregatedData: { - [key: string]: { - Date: string; - entityId: string; - entityAlias: string; - userId: string; - userEmail: string | null; - keyIds: Set; - metrics: Record<(typeof METRIC_KEYS)[number], number>; - }; - } = {}; - - spendData.results.forEach((day) => { - Object.entries(resolveEntities(day.breakdown)).forEach(([entity, data]: [string, any]) => { - const { id: entityId, alias: entityAlias } = resolveEntityDisplay(entity, teamAliasMap, data.metadata); - Object.entries(data.api_key_breakdown || {}).forEach(([keyId, keyData]: [string, any]) => { - const userId = keyData?.metadata?.user_id || "Unassigned"; - const uniqueKey = JSON.stringify([day.date, entityId, userId]); - if (!aggregatedData[uniqueKey]) { - aggregatedData[uniqueKey] = { - Date: day.date, - entityId, - entityAlias, - userId, - userEmail: null, - keyIds: new Set(), - metrics: Object.fromEntries(METRIC_KEYS.map((k) => [k, 0])) as Record<(typeof METRIC_KEYS)[number], number>, - }; - } - const bucket = aggregatedData[uniqueKey]; - bucket.userEmail = bucket.userEmail || keyData?.metadata?.user_email || null; - bucket.keyIds.add(keyId); - for (const k of METRIC_KEYS) { - bucket.metrics[k] += keyData?.metrics?.[k] || 0; - } - }); - }); - }); - - return Object.values(aggregatedData) - .map((item) => ({ - Date: item.Date, - [entityLabel]: item.entityAlias, - [`${entityLabel} ID`]: item.entityId, - "User ID": item.userId, - "User Email": item.userEmail || "-", - Keys: item.keyIds.size, - "Spend ($)": formatNumberWithCommas(item.metrics.spend, 4), - Requests: item.metrics.api_requests, - "Successful Requests": item.metrics.successful_requests, - "Failed Requests": item.metrics.failed_requests, - "Total Tokens": item.metrics.total_tokens, - "Prompt Tokens": item.metrics.prompt_tokens, - "Completion Tokens": item.metrics.completion_tokens, - "Cache Read Input Tokens": item.metrics.cache_read_input_tokens, - "Cache Creation Input Tokens": item.metrics.cache_creation_input_tokens, - })) - .sort((a, b) => new Date(a.Date).getTime() - new Date(b.Date).getTime()); -}; - -export const generateDailyWithModelsData = ( - spendData: EntitySpendData, - entityLabel: string, - teamAliasMap: Record = {}, -): any[] => { - const dailyModelBreakdown: any[] = []; - - spendData.results.forEach((day) => { - const dailyEntityModels: { [key: string]: { [key: string]: any } } = {}; - const dailyEntityMetadata: { [key: string]: Record | undefined } = {}; - - Object.entries(resolveEntities(day.breakdown)).forEach(([entity, entityData]: [string, any]) => { - if (!dailyEntityModels[entity]) { - dailyEntityModels[entity] = {}; - } - dailyEntityMetadata[entity] = entityData.metadata; - - Object.entries(day.breakdown.models || {}).forEach(([model, modelData]: [string, any]) => { - const entityApiKeys = entityData.api_key_breakdown || {}; - const modelApiKeys = modelData.api_key_breakdown || {}; - - Object.keys(entityApiKeys).forEach((apiKey) => { - const keyMetrics = modelApiKeys[apiKey]?.metrics; - if (!keyMetrics) return; - - if (!dailyEntityModels[entity][model]) { - dailyEntityModels[entity][model] = { - spend: 0, - requests: 0, - successful: 0, - failed: 0, - tokens: 0, - promptTokens: 0, - completionTokens: 0, - cacheReadInputTokens: 0, - cacheCreationInputTokens: 0, - }; - } - dailyEntityModels[entity][model].spend += keyMetrics.spend || 0; - dailyEntityModels[entity][model].requests += keyMetrics.api_requests || 0; - dailyEntityModels[entity][model].successful += keyMetrics.successful_requests || 0; - dailyEntityModels[entity][model].failed += keyMetrics.failed_requests || 0; - dailyEntityModels[entity][model].tokens += keyMetrics.total_tokens || 0; - dailyEntityModels[entity][model].promptTokens += keyMetrics.prompt_tokens || 0; - dailyEntityModels[entity][model].completionTokens += keyMetrics.completion_tokens || 0; - dailyEntityModels[entity][model].cacheReadInputTokens += keyMetrics.cache_read_input_tokens || 0; - dailyEntityModels[entity][model].cacheCreationInputTokens += keyMetrics.cache_creation_input_tokens || 0; - }); - }); - }); - - Object.entries(dailyEntityModels).forEach(([entity, models]) => { - const { id, alias } = resolveEntityDisplay(entity, teamAliasMap, dailyEntityMetadata[entity]); - - Object.entries(models).forEach(([model, metrics]: [string, any]) => { - dailyModelBreakdown.push({ - Date: day.date, - [entityLabel]: alias, - [`${entityLabel} ID`]: id, - Model: model, - "Spend ($)": formatNumberWithCommas(metrics.spend, 4), - Requests: metrics.requests, - Successful: metrics.successful, - Failed: metrics.failed, - "Total Tokens": metrics.tokens, - "Prompt Tokens": metrics.promptTokens, - "Completion Tokens": metrics.completionTokens, - "Cache Read Input Tokens": metrics.cacheReadInputTokens, - "Cache Creation Input Tokens": metrics.cacheCreationInputTokens, - }); - }); - }); - }); - - return dailyModelBreakdown.sort((a, b) => new Date(a.Date).getTime() - new Date(b.Date).getTime()); -}; - -export const generateExportData = ( - spendData: EntitySpendData, - exportScope: ExportScope, - entityLabel: string, - teamAliasMap: Record = {}, -): any[] => { - switch (exportScope) { - case "daily": - return generateDailyData(spendData, entityLabel, teamAliasMap); - case "daily_with_keys": - return generateDailyWithKeysData(spendData, entityLabel, teamAliasMap); - case "daily_with_models": - return generateDailyWithModelsData(spendData, entityLabel, teamAliasMap); - case "daily_with_users": - return generateDailyWithUsersData(spendData, entityLabel, teamAliasMap); - default: - return generateDailyData(spendData, entityLabel, teamAliasMap); - } -}; - -export const generateMetadata = ( +export const exportFilename = ( entityType: EntityType, + exportType: ExportType, + format: ExportFormat, dateRange: DateRangePickerValue, - selectedFilters: string[], - exportScope: ExportScope, - spendData: EntitySpendData, -): ExportMetadata => { - const summary: ExportMetadata["summary"] = { - total_spend: spendData.metadata.total_spend, - total_requests: spendData.metadata.total_api_requests, - successful_requests: spendData.metadata.total_successful_requests, - failed_requests: spendData.metadata.total_failed_requests, - total_tokens: spendData.metadata.total_tokens, - }; - if (hasFlatCost(spendData)) { - const flatCost = spendData.metadata.total_flat_cost ?? 0; - summary.total_flat_cost = flatCost; - summary.total_cost = spendData.metadata.total_spend + flatCost; - } - return { - export_date: new Date().toISOString(), - entity_type: entityType, - date_range: { - from: dateRange.from?.toISOString(), - to: dateRange.to?.toISOString(), - }, - filters_applied: selectedFilters.length > 0 ? selectedFilters : "None", - export_scope: exportScope, - summary, - }; -}; +): string => `${entityType}_usage_${exportType}_${fileDay(dateRange.from)}_${fileDay(dateRange.to)}.${format}`; -export const handleExportCSV = ( - spendData: EntitySpendData, - exportScope: ExportScope, - entityLabel: string, - entityType: EntityType, - teamAliasMap: Record = {}, -): void => { - const data = generateExportData(spendData, exportScope, entityLabel, teamAliasMap); - const csv = Papa.unparse(data); - const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); +export const downloadBlob = (blob: Blob, filename: string): void => { const url = window.URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; - const fileName = `${entityType}_usage_${exportScope}_${new Date().toISOString().split("T")[0]}.csv`; - a.download = fileName; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - window.URL.revokeObjectURL(url); -}; - -export const handleExportJSON = ( - spendData: EntitySpendData, - exportScope: ExportScope, - entityLabel: string, - entityType: EntityType, - dateRange: DateRangePickerValue, - selectedFilters: string[], - teamAliasMap: Record = {}, -): void => { - const data = generateExportData(spendData, exportScope, entityLabel, teamAliasMap); - const metadata = generateMetadata(entityType, dateRange, selectedFilters, exportScope, spendData); - const exportObject = { - metadata, - data, - }; - const jsonString = JSON.stringify(exportObject, null, 2); - const blob = new Blob([jsonString], { type: "application/json" }); - const url = window.URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - const fileName = `${entityType}_usage_${exportScope}_${new Date().toISOString().split("T")[0]}.json`; - a.download = fileName; + a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.integration.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.integration.test.tsx new file mode 100644 index 00000000000..891690700e3 --- /dev/null +++ b/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.integration.test.tsx @@ -0,0 +1,674 @@ +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { components } from "@/lib/http/schema"; +import { type DailyActivityKeyPageResponse, type KeyActivityRow, type KeySpendActivityRow } from "../dailyActivityApi"; +import type { ModelActivityData } from "../types"; +import KeyActivityPanel from "./KeyActivityPanel"; + +let triggerIntersection: (() => void) | undefined; + +const intersectSentinel = async () => { + await waitFor(() => expect(triggerIntersection).toBeDefined()); + await act(async () => { + triggerIntersection?.(); + }); +}; + +class TestIntersectionObserver implements IntersectionObserver { + readonly root: Element | Document | null = null; + readonly rootMargin = ""; + readonly thresholds: readonly number[] = []; + + constructor(private readonly callback: IntersectionObserverCallback) {} + + observe(target: Element): void { + triggerIntersection = () => + this.callback( + [ + { + boundingClientRect: target.getBoundingClientRect(), + intersectionRect: target.getBoundingClientRect(), + intersectionRatio: 1, + isIntersecting: true, + rootBounds: null, + target, + time: 0, + }, + ], + this, + ); + } + + unobserve(): void {} + + disconnect(): void { + triggerIntersection = undefined; + } + + takeRecords(): IntersectionObserverEntry[] { + return []; + } +} + +const metrics: components["schemas"]["SpendMetrics"] = { + api_requests: 2, + autorouter_savings_spend: 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + completion_tokens: 3, + compression_saved_tokens: 0, + compression_savings_spend: 0, + failed_requests: 0, + flat_cost: 0, + gateway_injected_caching_savings_spend: 0, + prompt_caching_savings_spend: 0, + prompt_tokens: 4, + spend: 1.25, + successful_requests: 2, + timed_requests: 0, + total_response_time_ms: 0, + total_tokens: 7, +}; + +const pageRow = (apiKey: string): KeySpendActivityRow => ({ + api_key: apiKey, + metrics: { + api_requests: 2, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + completion_tokens: 3, + failed_requests: 0, + prompt_tokens: 4, + spend: 1.25, + successful_requests: 2, + total_tokens: 7, + }, + metadata: { key_alias: apiKey, team_id: null }, +}); + +const searchRow = (apiKey: string, alias: string): KeyActivityRow => ({ + api_key: apiKey, + metrics, + metadata: { key_alias: alias, team_id: null }, +}); + +const pageResponse = (apiKeys: KeySpendActivityRow[], total: number, offset = 0): DailyActivityKeyPageResponse => ({ + api_keys: apiKeys, + total_api_keys: total, + offset, + limit: 50, +}); + +const summary: ModelActivityData = { + label: "Overall Usage", + total_requests: 200, + total_successful_requests: 198, + total_failed_requests: 2, + total_cache_read_input_tokens: 0, + total_cache_creation_input_tokens: 0, + total_tokens: 700, + prompt_tokens: 400, + completion_tokens: 300, + total_spend: 500, + total_response_time_ms: 0, + total_timed_requests: 0, + top_models: [], + daily_data: [ + { + date: "2026-09-27", + metrics: { + prompt_tokens: 4, + completion_tokens: 3, + total_tokens: 7, + api_requests: 2, + spend: 1.25, + successful_requests: 2, + failed_requests: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + }, + ], +}; + +const detail = (apiKey: string): ModelActivityData => ({ + label: apiKey, + total_requests: 2, + total_successful_requests: 2, + total_failed_requests: 0, + total_cache_read_input_tokens: 0, + total_cache_creation_input_tokens: 0, + total_tokens: 7, + prompt_tokens: 4, + completion_tokens: 3, + total_spend: 1.25, + total_response_time_ms: 0, + total_timed_requests: 0, + top_models: [ + { model: "gpt-4o-mini", spend: 1.25, requests: 2, successful_requests: 2, failed_requests: 0, tokens: 7 }, + ], + daily_data: [ + { + date: "2026-09-27", + metrics: { + prompt_tokens: 4, + completion_tokens: 3, + total_tokens: 7, + api_requests: 2, + spend: 1.25, + successful_requests: 2, + failed_requests: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + avg_response_time_ms: null, + }, + }, + ], +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + triggerIntersection = undefined; +}); + +beforeEach(() => { + vi.stubGlobal("IntersectionObserver", TestIntersectionObserver); +}); + +describe("KeyActivityPanel", () => { + it("loads the first page, appends the next page, and keeps limit copy out of the UI", async () => { + let resolveNextPage: (response: DailyActivityKeyPageResponse) => void = () => {}; + const nextPage = new Promise((resolve) => { + resolveNextPage = resolve; + }); + const firstPageRows = Array.from({ length: 50 }, (_, index) => pageRow(`key-${index}`)); + const fetchKeyPage = vi.fn((offset: number, _limit: number) => + offset === 0 ? Promise.resolve(pageResponse(firstPageRows, 52)) : nextPage, + ); + + render( + , + ); + + expect(await screen.findByRole("button", { name: /key-49/ })).toBeInTheDocument(); + expect(screen.getByText("52 keys")).toBeInTheDocument(); + expect(screen.getByText("$500.00")).toBeInTheDocument(); + expect(fetchKeyPage).toHaveBeenCalledWith(0, 50); + expect(screen.queryByText(/limit|truncat|highest-spend|load top/i)).not.toBeInTheDocument(); + + await intersectSentinel(); + expect(fetchKeyPage).toHaveBeenCalledWith(50, 50); + expect(screen.getByText("Loading more keys...")).toBeInTheDocument(); + + await act(async () => { + resolveNextPage(pageResponse([pageRow("key-50"), pageRow("key-51")], 52, 50)); + await nextPage; + }); + expect(await screen.findByRole("button", { name: /key-51/ })).toBeInTheDocument(); + expect(screen.queryByText("Loading more keys...")).not.toBeInTheDocument(); + }); + + it("fetches full detail on first expansion and renders charts with daily data", async () => { + const fetchKeyPage = vi.fn().mockResolvedValue(pageResponse([pageRow("key-chart")], 1)); + let resolveDetail: (metrics: ModelActivityData) => void = () => {}; + const detailResponse = new Promise((resolve) => { + resolveDetail = resolve; + }); + const fetchKeyDetail = vi.fn().mockReturnValue(detailResponse); + render( + , + ); + + fireEvent.click(await screen.findByRole("button", { name: /key-chart/ })); + + expect(fetchKeyDetail).toHaveBeenCalledWith("key-chart"); + expect(await screen.findByText("Loading key details...")).toBeInTheDocument(); + await act(async () => { + resolveDetail(detail("key-chart")); + await detailResponse; + }); + expect(await screen.findByText("Spend per day")).toBeInTheDocument(); + expect(screen.getByText("Requests per day")).toBeInTheDocument(); + expect(screen.queryByText("No data")).not.toBeInTheDocument(); + }); + + it("shows a detail error with Retry when the detail request fails and refetches on retry", async () => { + const fetchKeyPage = vi.fn().mockResolvedValue(pageResponse([pageRow("key-broken")], 1)); + const fetchKeyDetail = vi.fn().mockRejectedValueOnce(new Error("boom")).mockResolvedValueOnce(detail("key-broken")); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + render( + , + ); + + fireEvent.click(await screen.findByRole("button", { name: /key-broken/ })); + + expect(await screen.findByText(/Could not load key details\./)).toBeInTheDocument(); + expect(fetchKeyDetail).toHaveBeenCalledTimes(1); + expect(screen.queryByText("Spend per day")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + + expect(fetchKeyDetail).toHaveBeenCalledTimes(2); + expect(fetchKeyDetail).toHaveBeenLastCalledWith("key-broken"); + expect(await screen.findByText("Spend per day")).toBeInTheDocument(); + expect(screen.queryByText(/Could not load key details\./)).not.toBeInTheDocument(); + consoleError.mockRestore(); + }); + + it("shows a loader instead of zero totals while the summary is in flight", async () => { + const fetchKeyPage = vi.fn().mockResolvedValue(pageResponse([pageRow("key-local")], 1)); + const zeroSummary: ModelActivityData = { ...summary, total_spend: 0, total_requests: 0, total_tokens: 0 }; + const props = { + fetchKeyPage, + fetchKeyDetail: vi.fn(), + searchKeys: vi.fn().mockResolvedValue({ api_keys: [] }), + teams: [], + }; + const { rerender } = render(); + + expect(await screen.findByRole("button", { name: /key-local/ })).toBeInTheDocument(); + expect(screen.getByText("Loading chart data...")).toBeInTheDocument(); + expect(screen.queryByText("Overall Usage")).not.toBeInTheDocument(); + expect(screen.queryByText("$0.00")).not.toBeInTheDocument(); + + rerender(); + + expect(screen.queryByText("Loading chart data...")).not.toBeInTheDocument(); + expect(screen.getByText("Overall Usage")).toBeInTheDocument(); + expect(screen.getAllByText("$500.00").length).toBeGreaterThan(0); + }); + + it("loads details for remote search results and merges local matches", async () => { + vi.useFakeTimers(); + const fetchKeyPage = vi.fn().mockResolvedValue(pageResponse([pageRow("key-local-remote")], 2)); + const fetchKeyDetail = vi.fn().mockResolvedValue(detail("key-remote")); + const searchKeys = vi.fn().mockResolvedValue({ + api_keys: [searchRow("key-remote", "remote server result")], + }); + render( + , + ); + + fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "remote" } }); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + vi.useRealTimers(); + expect(searchKeys).toHaveBeenCalledWith("remote"); + expect(screen.getByText("2 matching keys")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /key-local-remote/ })).toBeInTheDocument(); + const remoteButton = screen.getByRole("button", { name: /remote server result/ }); + fireEvent.click(remoteButton); + + expect(fetchKeyDetail).toHaveBeenCalledWith("key-remote"); + expect(await screen.findByText("Spend per day")).toBeInTheDocument(); + expect(screen.queryByText("No data")).not.toBeInTheDocument(); + }); + + it("shows an error with Retry when the first page fails and recovers on retry", async () => { + let resolveRetry: (page: DailyActivityKeyPageResponse) => void = () => undefined; + const fetchKeyPage = vi + .fn() + .mockRejectedValueOnce(new Error("boom")) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRetry = resolve; + }), + ); + render( + , + ); + + expect(await screen.findByText("Could not load keys for this range.")).toBeInTheDocument(); + expect(screen.queryByText("0 keys")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + + expect(screen.getByText("Loading keys...")).toBeInTheDocument(); + expect(screen.queryByText("Could not load keys for this range.")).not.toBeInTheDocument(); + expect(fetchKeyPage).toHaveBeenCalledTimes(2); + expect(fetchKeyPage).toHaveBeenNthCalledWith(2, 0, 50); + + await act(async () => { + resolveRetry(pageResponse([pageRow("key-after-retry")], 1)); + }); + expect(await screen.findByRole("button", { name: /key-after-retry/ })).toBeInTheDocument(); + expect(screen.queryByText("Loading keys...")).not.toBeInTheDocument(); + }); + + it("stops auto-retrying after a failed next page until Retry is clicked", async () => { + const firstPageRows = Array.from({ length: 50 }, (_, index) => pageRow(`key-${index}`)); + const fetchKeyPage = vi + .fn() + .mockResolvedValueOnce(pageResponse(firstPageRows, 52)) + .mockRejectedValueOnce(new Error("boom")) + .mockResolvedValue(pageResponse([pageRow("key-50"), pageRow("key-51")], 52, 50)); + render( + , + ); + + expect(await screen.findByRole("button", { name: /key-49/ })).toBeInTheDocument(); + await intersectSentinel(); + expect(await screen.findByText("Could not load more keys.")).toBeInTheDocument(); + expect(fetchKeyPage).toHaveBeenCalledTimes(2); + + await act(async () => { + triggerIntersection?.(); + }); + expect(fetchKeyPage).toHaveBeenCalledTimes(2); + + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(await screen.findByRole("button", { name: /key-51/ })).toBeInTheDocument(); + expect(fetchKeyPage).toHaveBeenCalledTimes(3); + expect(fetchKeyPage).toHaveBeenNthCalledWith(3, 50, 50); + }); + + it("hides the next-page Retry while searching and refetches the failed page once the search is cleared", async () => { + const firstPageRows = Array.from({ length: 50 }, (_, index) => pageRow(`key-${index}`)); + const fetchKeyPage = vi + .fn() + .mockResolvedValueOnce(pageResponse(firstPageRows, 52)) + .mockRejectedValueOnce(new Error("boom")) + .mockResolvedValue(pageResponse([pageRow("key-50"), pageRow("key-51")], 52, 50)); + render( + , + ); + + expect(await screen.findByRole("button", { name: /key-49/ })).toBeInTheDocument(); + await intersectSentinel(); + expect(await screen.findByText("Could not load more keys.")).toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "key-1" } }); + expect(screen.queryByText("Could not load more keys.")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Retry" })).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Clear key search" })); + expect(await screen.findByText("Could not load more keys.")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(await screen.findByRole("button", { name: /key-51/ })).toBeInTheDocument(); + expect(fetchKeyPage).toHaveBeenCalledTimes(3); + expect(fetchKeyPage).toHaveBeenNthCalledWith(3, 50, 50); + }); + + it("shows server search matches even when the first key page failed", async () => { + vi.useFakeTimers(); + const fetchKeyPage = vi.fn().mockRejectedValue(new Error("boom")); + const searchKeys = vi.fn().mockResolvedValue({ + api_keys: [searchRow("key-remote", "remote server result")], + }); + render( + , + ); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(screen.getByText("Could not load keys for this range.")).toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "remote" } }); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + vi.useRealTimers(); + + expect(searchKeys).toHaveBeenCalledWith("remote"); + expect(screen.getByRole("button", { name: /remote server result/ })).toBeInTheDocument(); + expect(screen.getByText("1 matching keys")).toBeInTheDocument(); + expect(screen.queryByText("Could not load keys for this range.")).not.toBeInTheDocument(); + }); + + it("keeps the loader and the first page error visible for a short query instead of No keys match", async () => { + let resolveFirstPage: (page: DailyActivityKeyPageResponse) => void = () => undefined; + const fetchKeyPage = vi.fn().mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirstPage = resolve; + }), + ); + render( + , + ); + + fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "a" } }); + expect(screen.getByText("Loading keys...")).toBeInTheDocument(); + expect(screen.queryByText(/No keys match/)).not.toBeInTheDocument(); + expect(screen.queryByText("0 matching keys")).not.toBeInTheDocument(); + + await act(async () => { + resolveFirstPage(pageResponse([pageRow("key-alpha")], 1)); + }); + expect(await screen.findByRole("button", { name: /key-alpha/ })).toBeInTheDocument(); + expect(screen.getByText("1 matching keys")).toBeInTheDocument(); + + cleanup(); + const failingFetch = vi.fn().mockRejectedValue(new Error("boom")); + render( + , + ); + expect(await screen.findByText("Could not load keys for this range.")).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "a" } }); + expect(screen.getByText("Could not load keys for this range.")).toBeInTheDocument(); + expect(screen.queryByText(/No keys match/)).not.toBeInTheDocument(); + expect(screen.queryByText("0 matching keys")).not.toBeInTheDocument(); + }); + + it("does not restart an in-flight search when the parent passes a new teams array", async () => { + vi.useFakeTimers(); + const fetchKeyPage = vi.fn().mockResolvedValue(pageResponse([pageRow("key-local")], 1)); + let resolveSearch: (response: { api_keys: KeyActivityRow[] }) => void = () => undefined; + const searchKeys = vi.fn().mockImplementation( + () => + new Promise<{ api_keys: KeyActivityRow[] }>((resolve) => { + resolveSearch = resolve; + }), + ); + const props = { + summary, + fetchKeyPage, + fetchKeyDetail: vi.fn().mockResolvedValue(detail("key-remote")), + searchKeys, + }; + const { rerender } = render(); + + fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "remote" } }); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + expect(searchKeys).toHaveBeenCalledTimes(1); + + rerender(); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + expect(searchKeys).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveSearch({ api_keys: [searchRow("key-remote", "remote server result")] }); + }); + vi.useRealTimers(); + expect(await screen.findByRole("button", { name: /remote server result/ })).toBeInTheDocument(); + expect(screen.getByText("1 matching keys")).toBeInTheDocument(); + }); + + it("keeps paging past a page of already loaded keys by server offset instead of loaded row count", async () => { + const fetchKeyPage = vi + .fn() + .mockResolvedValueOnce(pageResponse([pageRow("key-1"), pageRow("key-2")], 5)) + .mockResolvedValueOnce(pageResponse([pageRow("key-1"), pageRow("key-2")], 5, 2)) + .mockResolvedValueOnce(pageResponse([pageRow("key-5")], 5, 4)); + render( + , + ); + expect(await screen.findByRole("button", { name: /key-2/ })).toBeInTheDocument(); + + await intersectSentinel(); + expect(fetchKeyPage).toHaveBeenNthCalledWith(2, 2, 50); + await intersectSentinel(); + expect(fetchKeyPage).toHaveBeenNthCalledWith(3, 4, 50); + expect(await screen.findByRole("button", { name: /key-5/ })).toBeInTheDocument(); + expect(fetchKeyPage).toHaveBeenCalledTimes(3); + expect(screen.getAllByRole("button", { name: /key-/ })).toHaveLength(3); + }); + + it("stops requesting more keys when a later page is empty even though the total says more exist", async () => { + const fetchKeyPage = vi + .fn() + .mockResolvedValueOnce(pageResponse([pageRow("key-1"), pageRow("key-2")], 3)) + .mockResolvedValue(pageResponse([], 3, 2)); + render( + , + ); + expect(await screen.findByRole("button", { name: /key-2/ })).toBeInTheDocument(); + + await intersectSentinel(); + await act(async () => { + triggerIntersection?.(); + }); + expect(fetchKeyPage).toHaveBeenCalledTimes(2); + expect(fetchKeyPage).toHaveBeenLastCalledWith(2, 50); + expect(triggerIntersection).toBeUndefined(); + expect(screen.getByText("3 keys")).toBeInTheDocument(); + }); + + it("shows a search error with Retry search instead of No keys match when the search fails", async () => { + vi.useFakeTimers(); + const fetchKeyPage = vi.fn().mockResolvedValue(pageResponse([pageRow("key-local")], 1)); + const searchKeys = vi + .fn() + .mockRejectedValueOnce(new Error("boom")) + .mockResolvedValue({ api_keys: [searchRow("key-remote", "remote server result")] }); + render( + , + ); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "remote" } }); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + expect(searchKeys).toHaveBeenCalledTimes(1); + expect(screen.getByText(/Could not search keys\./)).toBeInTheDocument(); + expect(screen.queryByText(/No keys match/)).not.toBeInTheDocument(); + expect(screen.queryByText("0 matching keys")).not.toBeInTheDocument(); + expect(screen.getByText("Search failed")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Retry search" })); + expect(screen.getByText("Searching...")).toBeInTheDocument(); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + vi.useRealTimers(); + + expect(searchKeys).toHaveBeenCalledTimes(2); + expect(screen.getByRole("button", { name: /remote server result/ })).toBeInTheDocument(); + expect(screen.queryByText(/Could not search keys\./)).not.toBeInTheDocument(); + }); + + it("discards stale pages and clears loaded keys when the scope changes", async () => { + let resolveOldPage: (response: DailyActivityKeyPageResponse) => void = () => {}; + const oldPage = new Promise((resolve) => { + resolveOldPage = resolve; + }); + const firstScopeFetch = vi.fn().mockReturnValue(oldPage); + const secondScopeFetch = vi.fn().mockResolvedValue(pageResponse([pageRow("new-scope-key")], 1)); + const props = { + summary, + fetchKeyDetail: vi.fn().mockResolvedValue(detail("new-scope-key")), + searchKeys: vi.fn().mockResolvedValue({ api_keys: [] }), + teams: [], + }; + const { rerender } = render(); + rerender(); + + expect(await screen.findByRole("button", { name: /new-scope-key/ })).toBeInTheDocument(); + await act(async () => { + resolveOldPage(pageResponse([pageRow("old-scope-key")], 1)); + await oldPage; + }); + expect(screen.queryByRole("button", { name: /old-scope-key/ })).not.toBeInTheDocument(); + expect(secondScopeFetch).toHaveBeenCalledWith(0, 50); + }); +}); diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.test.tsx deleted file mode 100644 index 693ac20a360..00000000000 --- a/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.test.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; - -import type { ModelActivityData } from "../types"; -import KeyActivityPanel from "./KeyActivityPanel"; - -vi.mock("@/components/activity_metrics", () => ({ - ActivityMetrics: ({ modelMetrics }: { modelMetrics: Record }) => ( -
    - {Object.keys(modelMetrics).map((hash) => ( -
  • {hash}
  • - ))} -
- ), -})); - -function activity(label: string, user_email: string | null, user_id: string | null): ModelActivityData { - return { - label, - key_metadata: { key_alias: label, team_id: "team-1", user_id, user_email }, - total_requests: 1, - total_successful_requests: 1, - total_failed_requests: 0, - total_cache_read_input_tokens: 0, - total_cache_creation_input_tokens: 0, - total_tokens: 10, - prompt_tokens: 5, - completion_tokens: 5, - total_spend: 0.01, - top_api_keys: [], - top_models: [], - daily_data: [], - }; -} - -const keyMetrics: Record = { - "hash-alice": activity("alice-key", "alice@example.com", "user-alice"), - "hash-bob": activity("bob-key", "bob@example.com", "user-bob"), -}; - -describe("KeyActivityPanel", () => { - it("renders every key and the full count before searching", () => { - render(); - expect(screen.getByTestId("rendered-keys")).toHaveTextContent("hash-alicehash-bob"); - expect(screen.getByText("Showing 2 of 2 keys")).toBeInTheDocument(); - }); - - it("narrows the rendered keys to those matching the user email", () => { - render(); - fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "bob@example.com" } }); - expect(screen.getByTestId("rendered-keys")).toHaveTextContent("hash-bob"); - expect(screen.getByTestId("rendered-keys")).not.toHaveTextContent("hash-alice"); - expect(screen.getByText("Showing 1 of 2 keys")).toBeInTheDocument(); - }); - - it("shows an empty state instead of zeroed metrics when nothing matches", () => { - render(); - fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "carol" } }); - expect(screen.queryByTestId("rendered-keys")).not.toBeInTheDocument(); - expect(screen.getByText('No keys match "carol" in this date range')).toBeInTheDocument(); - }); - - it("clears the search and restores every key", () => { - render(); - fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "user-alice" } }); - expect(screen.getByTestId("rendered-keys")).toHaveTextContent("hash-alice"); - fireEvent.click(screen.getByLabelText("Clear key search")); - expect(screen.getByLabelText("Search keys")).toHaveValue(""); - expect(screen.getByTestId("rendered-keys")).toHaveTextContent("hash-alicehash-bob"); - }); -}); diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.tsx index 8287a04d0c7..47226b1c2e4 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.tsx @@ -1,27 +1,397 @@ import { Search, X } from "lucide-react"; -import React, { useMemo, useState } from "react"; +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { ActivityMetrics } from "@/components/activity_metrics"; +import { ActivityMetrics, ModelCollapsible, ModelSection } from "@/components/activity_metrics"; +import type { Team } from "@/components/key_team_helpers/key_list"; +import { ChartLoader } from "@/components/shared/chart_loader"; import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group"; +import type { + DailyActivityKeyPageResponse, + DailyActivityKeySearchResponse, + KeyActivityRow, + KeySpendActivityRow, +} from "../dailyActivityApi"; import { filterKeyActivity } from "../keyActivityFilter"; +import { keyActivityRowsToMetrics } from "./keySearch"; +import { mergeKeyActivityPages } from "../keyActivityData"; import type { ModelActivityData } from "../types"; +const PAGE_SIZE = 50; +const SEARCH_DEBOUNCE_MS = 300; +const MIN_SEARCH_LENGTH = 2; + +type FetchKeyPage = (offset: number, limit: number) => Promise; +type FetchKeyDetail = (apiKey: string) => Promise; +type SearchKeys = (search: string) => Promise; + interface KeyActivityPanelProps { - keyMetrics: Record; + summary: ModelActivityData; + summaryLoading?: boolean; + fetchKeyPage: FetchKeyPage; + fetchKeyDetail: FetchKeyDetail; + searchKeys: SearchKeys; + teams: Team[]; hidePromptCachingMetrics?: boolean; } -const KeyActivityPanel: React.FC = ({ keyMetrics, hidePromptCachingMetrics = false }) => { - const [query, setQuery] = useState(""); - const filtered = useMemo(() => filterKeyActivity(keyMetrics, query), [keyMetrics, query]); - const totalKeys = Object.keys(keyMetrics).length; - const shownKeys = Object.keys(filtered).length; - const isFiltering = query.trim() !== ""; +interface KeyPageState { + scope: FetchKeyPage; + rows: KeySpendActivityRow[]; + total: number; + nextOffset: number; + hasMore: boolean; + loading: boolean; + loadingMore: boolean; + failed: boolean; +} + +type KeyDetailState = + | { status: "loading" } + | { status: "failed" } + | { status: "loaded"; metrics: ModelActivityData | undefined }; + +const keyCountText = (searching: boolean, isFiltering: boolean, shownKeys: number, total: number): string => { + if (searching) return "Searching..."; + if (isFiltering) return `${shownKeys.toLocaleString()} matching keys`; + return `${total.toLocaleString()} keys`; +}; + +interface KeyDetailContentProps { + apiKey: string; + detail: KeyDetailState | undefined; + hidePromptCachingMetrics: boolean; + onRetry: () => void; +} + +const KeyDetailContent: React.FC = ({ apiKey, detail, hidePromptCachingMetrics, onRetry }) => { + if (detail?.status === "loading") { + return

Loading key details...

; + } + if (detail?.status === "failed") { + return ( +

+ Could not load key details.{" "} + +

+ ); + } + if (detail?.status !== "loaded" || detail.metrics === undefined) { + return

No daily details available

; + } + return ( + + ); +}; + +const KeyActivityPanel: React.FC = ({ + summary, + summaryLoading = false, + fetchKeyPage, + fetchKeyDetail, + searchKeys, + teams, + hidePromptCachingMetrics = false, +}) => { + const [queryState, setQueryState] = useState<{ scope: FetchKeyPage; value: string } | null>(null); + const query = queryState?.scope === fetchKeyPage ? queryState.value : ""; + const updateQuery = useCallback((value: string) => setQueryState({ scope: fetchKeyPage, value }), [fetchKeyPage]); + const [pageState, setPageState] = useState(null); + const [detailState, setDetailState] = useState<{ + scope: FetchKeyPage; + details: Record; + } | null>(null); + const [searchResult, setSearchResult] = useState<{ + scope: FetchKeyPage; + term: string; + searchKeys: SearchKeys; + rows: KeyActivityRow[]; + failed: boolean; + } | null>(null); + const [retryToken, setRetryToken] = useState(0); + const [searchRetryToken, setSearchRetryToken] = useState(0); + const searchIdRef = useRef(0); + const pageRequestIdRef = useRef(0); + const loadingMoreRef = useRef(false); + const activeFetcherRef = useRef(fetchKeyPage); + const sentinelRef = useRef(null); + + useLayoutEffect(() => { + activeFetcherRef.current = fetchKeyPage; + }, [fetchKeyPage]); + + useEffect(() => { + const requestId = ++pageRequestIdRef.current; + loadingMoreRef.current = false; + void fetchKeyPage(0, PAGE_SIZE) + .then((page) => { + if (pageRequestIdRef.current !== requestId || activeFetcherRef.current !== fetchKeyPage) return; + const loadedPageState = { + scope: fetchKeyPage, + rows: page.api_keys, + total: page.total_api_keys, + nextOffset: page.api_keys.length, + hasMore: page.api_keys.length > 0 && page.api_keys.length < page.total_api_keys, + loading: false, + loadingMore: false, + failed: false, + }; + setPageState(loadedPageState); + }) + .catch((error: unknown) => { + if (pageRequestIdRef.current !== requestId || activeFetcherRef.current !== fetchKeyPage) return; + console.error("Key activity page request failed:", error); + const failedPageState = { + scope: fetchKeyPage, + rows: [], + total: 0, + nextOffset: 0, + hasMore: false, + loading: false, + loadingMore: false, + failed: true, + }; + setPageState(failedPageState); + }); + return () => { + if (pageRequestIdRef.current === requestId) pageRequestIdRef.current += 1; + }; + }, [fetchKeyPage, retryToken]); + + const currentPageState = pageState?.scope === fetchKeyPage ? pageState : null; + const pageRows = useMemo(() => currentPageState?.rows ?? [], [currentPageState]); + const loading = currentPageState?.loading ?? true; + const loadingMore = currentPageState?.loadingMore ?? false; + const hasMore = currentPageState?.hasMore ?? false; + const total = currentPageState?.total ?? 0; + const failed = currentPageState?.failed ?? false; + const pageMetrics = useMemo(() => keyActivityRowsToMetrics(pageRows, teams), [pageRows, teams]); + + const loadMore = useCallback(() => { + if (currentPageState === null) return; + if (currentPageState.loading || currentPageState.loadingMore || !currentPageState.hasMore) return; + if (query.trim() !== "" || loadingMoreRef.current) return; + const requestId = pageRequestIdRef.current; + const offset = currentPageState.nextOffset; + loadingMoreRef.current = true; + setPageState((current) => (current?.scope === fetchKeyPage ? { ...current, loadingMore: true } : current)); + void fetchKeyPage(offset, PAGE_SIZE) + .then((page) => { + if (pageRequestIdRef.current !== requestId || activeFetcherRef.current !== fetchKeyPage) return; + setPageState((current) => { + if (current?.scope !== fetchKeyPage) return current; + const merged = mergeKeyActivityPages(current.rows, page.api_keys, page.total_api_keys, offset); + return { + ...current, + rows: merged.rows, + total: page.total_api_keys, + nextOffset: merged.nextOffset, + hasMore: merged.hasMore, + loadingMore: false, + failed: false, + }; + }); + }) + .catch((error: unknown) => { + if (pageRequestIdRef.current !== requestId || activeFetcherRef.current !== fetchKeyPage) return; + console.error("Key activity page request failed:", error); + setPageState((current) => + current?.scope === fetchKeyPage ? { ...current, loadingMore: false, failed: true } : current, + ); + }) + .finally(() => { + if (pageRequestIdRef.current === requestId) loadingMoreRef.current = false; + }); + }, [currentPageState, fetchKeyPage, query]); + + useEffect(() => { + const sentinel = sentinelRef.current; + if (sentinel === null || currentPageState === null) return; + if (currentPageState.failed) return; + if (loading || loadingMore || !hasMore) return; + if (query.trim() !== "") return; + const observer = new IntersectionObserver((entries) => { + if (entries.some((entry) => entry.isIntersecting)) loadMore(); + }); + observer.observe(sentinel); + return () => observer.disconnect(); + }, [currentPageState, hasMore, loadMore, loading, loadingMore, query]); + + const trimmedQuery = query.trim(); + const searchTerm = trimmedQuery.length >= MIN_SEARCH_LENGTH ? trimmedQuery : null; + useEffect(() => { + if (searchTerm === null) return; + const searchId = ++searchIdRef.current; + const timer = setTimeout(() => { + void searchKeys(searchTerm) + .then((response) => { + if (searchIdRef.current !== searchId || activeFetcherRef.current !== fetchKeyPage) return; + const result = { scope: fetchKeyPage, term: searchTerm, searchKeys, rows: response.api_keys, failed: false }; + setSearchResult(result); + }) + .catch((error: unknown) => { + if (searchIdRef.current !== searchId || activeFetcherRef.current !== fetchKeyPage) return; + console.error("Key activity search failed:", error); + const failedResult = { scope: fetchKeyPage, term: searchTerm, searchKeys, rows: [], failed: true }; + setSearchResult(failedResult); + }); + }, SEARCH_DEBOUNCE_MS); + return () => { + clearTimeout(timer); + if (searchIdRef.current === searchId) searchIdRef.current += 1; + }; + }, [fetchKeyPage, searchKeys, searchRetryToken, searchTerm]); + + const currentSearch = + searchResult?.scope === fetchKeyPage && searchResult.term === searchTerm && searchResult.searchKeys === searchKeys + ? searchResult + : null; + const searching = searchTerm !== null && currentSearch === null; + const searchFailed = currentSearch?.failed ?? false; + const searchRows = currentSearch?.rows; + const searchMetrics = useMemo(() => keyActivityRowsToMetrics(searchRows ?? [], teams), [searchRows, teams]); + const localFiltered = useMemo(() => filterKeyActivity(pageMetrics, query), [pageMetrics, query]); + const filteredMetrics = useMemo(() => ({ ...searchMetrics, ...localFiltered }), [searchMetrics, localFiltered]); + const shownKeys = Object.keys(filteredMetrics).length; + const isFiltering = trimmedQuery.length > 0; + const visibleDetails = detailState?.scope === fetchKeyPage ? detailState.details : {}; + const keyCountLabel = + isFiltering && searchFailed && !searching && shownKeys === 0 + ? "Search failed" + : keyCountText(searching, isFiltering, shownKeys, total); + + const retryFirstPage = () => { + setPageState({ + scope: fetchKeyPage, + rows: [], + total: 0, + nextOffset: 0, + hasMore: false, + loading: true, + loadingMore: false, + failed: false, + }); + setRetryToken((token) => token + 1); + }; + const retrySearch = () => { + setSearchResult(null); + setSearchRetryToken((token) => token + 1); + }; + const retryNextPage = () => { + setPageState((current) => (current?.scope === fetchKeyPage ? { ...current, failed: false } : current)); + loadMore(); + }; + const firstPageError = ( +

+ Could not load keys for this range.{" "} + +

+ ); + const emptyListBody = (() => { + if (failed) return firstPageError; + if (!loading) return null; + return

Loading keys...

; + })(); + const keyList = Object.entries(filteredMetrics).map(([apiKey, metrics]) => { + const detail = visibleDetails[apiKey]; + return ( + loadKeyDetail(apiKey)} + header={ +
+ {metrics.label} + + + $ + {metrics.total_spend.toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} + + {metrics.total_requests.toLocaleString()} requests + +
+ } + > + loadKeyDetail(apiKey)} + /> +
+ ); + }); + const firstPageFailed = failed && pageRows.length === 0; + const showNextPageRetry = failed && !firstPageFailed && !isFiltering; + const localOnly = searchTerm === null; + const pageUnavailable = localOnly && pageRows.length === 0 && (loading || firstPageFailed); + const listBody = pageUnavailable || (!isFiltering && pageRows.length === 0) ? emptyListBody : keyList; + const noMatches = isFiltering && !searching && !pageUnavailable && shownKeys === 0; + const showSearchErrorNote = !noMatches && searchFailed && !searching; + const searchRetryButton = ( + + ); + const emptyFilterBody = searchFailed ? ( +

+ Could not search keys. {searchRetryButton} +

+ ) : ( +

+ No keys match "{trimmedQuery}" in this date range +

+ ); + + const loadKeyDetail = useCallback( + (apiKey: string) => { + const existingDetails = detailState?.scope === fetchKeyPage ? detailState.details : {}; + const existing = existingDetails[apiKey]; + if (existing !== undefined && existing.status !== "failed") return; + setDetailState((current) => { + const currentDetails = current?.scope === fetchKeyPage ? current.details : {}; + return { scope: fetchKeyPage, details: { ...currentDetails, [apiKey]: { status: "loading" } } }; + }); + void fetchKeyDetail(apiKey) + .then((metrics) => { + setDetailState((current) => { + if (current?.scope !== fetchKeyPage) return current; + return { scope: fetchKeyPage, details: { ...current.details, [apiKey]: { status: "loaded", metrics } } }; + }); + }) + .catch((error: unknown) => { + console.error("Key activity detail request failed:", error); + setDetailState((current) => { + if (current?.scope !== fetchKeyPage) return current; + return { + scope: fetchKeyPage, + details: { ...current.details, [apiKey]: { status: "failed" } }, + }; + }); + }); + }, + [detailState, fetchKeyDetail, fetchKeyPage], + ); return (
-
+ {summaryLoading ? ( + + ) : ( + + )} +
@@ -30,27 +400,36 @@ const KeyActivityPanel: React.FC = ({ keyMetrics, hidePro aria-label="Search keys" placeholder="Search by key alias, key hash, user ID, or email" value={query} - onChange={(e) => setQuery(e.target.value)} + onChange={(event) => updateQuery(event.target.value)} /> {isFiltering && ( - setQuery("")}> + updateQuery("")}> )} - - Showing {shownKeys.toLocaleString()} of {totalKeys.toLocaleString()} keys - + {!pageUnavailable && ( + + {keyCountLabel} + + )}
- {isFiltering && totalKeys > 0 && shownKeys === 0 ? ( -

- No keys match "{query.trim()}" in this date range -

- ) : ( - + {noMatches ? emptyFilterBody :
{listBody}
} + {showSearchErrorNote && ( +

Could not search all keys. {searchRetryButton}

)} + {loadingMore &&

Loading more keys...

} + {showNextPageRetry && ( +

+ Could not load more keys.{" "} + +

+ )} + {!isFiltering && ); }; diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/keySearch.ts b/ui/litellm-dashboard/src/components/UsagePage/components/keySearch.ts new file mode 100644 index 00000000000..fc0772f159c --- /dev/null +++ b/ui/litellm-dashboard/src/components/UsagePage/components/keySearch.ts @@ -0,0 +1,33 @@ +import type { Team } from "@/components/key_team_helpers/key_list"; +import { toKeyMetadata, type KeyActivityRow, type KeySpendActivityRow } from "../dailyActivityApi"; +import { formatKeyLabel } from "@/components/activity_metrics"; +import type { ModelActivityData } from "../types"; + +export const keyActivityRowsToMetrics = ( + rows: readonly (KeyActivityRow | KeySpendActivityRow)[], + teams: Team[], +): Record => + Object.fromEntries( + rows.map((row) => { + const metadata = toKeyMetadata(row.metadata); + const metrics: ModelActivityData = { + label: formatKeyLabel({ metadata }, row.api_key, teams), + key_metadata: metadata, + total_requests: row.metrics.api_requests, + total_successful_requests: row.metrics.successful_requests, + total_failed_requests: row.metrics.failed_requests, + total_cache_read_input_tokens: row.metrics.cache_read_input_tokens, + total_cache_creation_input_tokens: row.metrics.cache_creation_input_tokens, + total_tokens: row.metrics.total_tokens, + prompt_tokens: row.metrics.prompt_tokens, + completion_tokens: row.metrics.completion_tokens, + total_spend: row.metrics.spend, + total_response_time_ms: + "total_response_time_ms" in row.metrics ? row.metrics.total_response_time_ms : undefined, + total_timed_requests: "timed_requests" in row.metrics ? row.metrics.timed_requests : undefined, + top_models: [], + daily_data: [], + }; + return [row.api_key, metrics]; + }), + ); diff --git a/ui/litellm-dashboard/src/components/UsagePage/dailyActivityApi.ts b/ui/litellm-dashboard/src/components/UsagePage/dailyActivityApi.ts new file mode 100644 index 00000000000..36c2837d9ac --- /dev/null +++ b/ui/litellm-dashboard/src/components/UsagePage/dailyActivityApi.ts @@ -0,0 +1,101 @@ +import type { components } from "@/lib/http/schema"; +import type { BreakdownMetrics, DailyData, KeyMetadata, KeyMetricWithMetadata, MetricWithMetadata } from "./types"; + +export type DailyActivityEntity = "user" | "team" | "tag" | "organization" | "customer" | "agent"; +export type DailyActivityAggregatedResponse = components["schemas"]["SpendAnalyticsPaginatedResponse"]; +export type DailyActivityMetadata = components["schemas"]["DailySpendMetadata"]; +export type ExportType = components["schemas"]["ExportType"]; +export type ExportFormat = "csv" | "json"; + +export type KeyActivityRow = components["schemas"]["KeyActivityRow"]; +export type KeySpendActivityRow = components["schemas"]["KeySpendActivityRow"]; +export type DailyActivityKeySearchResponse = components["schemas"]["DailyActivityKeySearchResponse"]; +export type DailyActivityKeyPageResponse = components["schemas"]["DailyActivityKeyPageResponse"]; +export type ModelTopKeysResponse = components["schemas"]["ModelTopKeysResponse"]; +export type CacheLeakageKeysResponse = components["schemas"]["CacheLeakageKeysResponse"]; + +export interface DailyActivityRequest { + accessToken: string; + startTime: Date; + endTime: Date; + entityIds?: readonly string[] | null; + excludeEntityIds?: readonly string[]; + apiKey?: string | null; + model?: string | null; + includeCurrentUtcDay?: boolean; + apiKeyLimit?: number; +} + +export const EMPTY_DAILY_ACTIVITY_METADATA: DailyActivityMetadata = { + has_more: false, + page: 1, + total_pages: 1, + total_spend: 0, + total_flat_cost: 0, + total_api_requests: 0, + total_successful_requests: 0, + total_failed_requests: 0, + total_tokens: 0, + total_prompt_tokens: 0, + total_completion_tokens: 0, + total_cache_read_input_tokens: 0, + total_cache_creation_input_tokens: 0, + total_compression_saved_tokens: 0, + total_compression_savings_spend: 0, + total_prompt_caching_savings_spend: 0, + total_gateway_injected_caching_savings_spend: 0, + total_autorouter_savings_spend: 0, + total_response_time_ms: 0, + total_timed_requests: 0, +}; + +export const EMPTY_DAILY_ACTIVITY_RESPONSE: DailyActivityAggregatedResponse = { + results: [], + metadata: EMPTY_DAILY_ACTIVITY_METADATA, +}; + +type SchemaMetricWithMetadata = components["schemas"]["MetricWithMetadata"]; +type SchemaKeyMetricWithMetadata = components["schemas"]["KeyMetricWithMetadata"]; + +const toKeyMetric = (entry: SchemaKeyMetricWithMetadata): KeyMetricWithMetadata => ({ + metrics: entry.metrics, + metadata: toKeyMetadata(entry.metadata), +}); + +export const toKeyMetadata = (metadata: components["schemas"]["KeyMetadata"] | undefined): KeyMetadata => ({ + key_alias: metadata?.key_alias ?? null, + team_id: metadata?.team_id ?? null, + user_id: metadata?.user_id, + user_email: metadata?.user_email, + key_exists: metadata?.key_exists, +}); + +const toMetric = (entry: SchemaMetricWithMetadata): MetricWithMetadata => ({ + metrics: entry.metrics, + metadata: entry.metadata ?? {}, + api_key_breakdown: Object.fromEntries( + Object.entries(entry.api_key_breakdown ?? {}).map(([key, value]) => [key, toKeyMetric(value)]), + ), +}); + +const toMetricMap = ( + map: { [key: string]: SchemaMetricWithMetadata } | undefined, +): { [key: string]: MetricWithMetadata } => + Object.fromEntries(Object.entries(map ?? {}).map(([key, value]) => [key, toMetric(value)])); + +export const toDailyData = (response: DailyActivityAggregatedResponse): DailyData[] => + (response.results ?? []).map((day) => { + const breakdown = day.breakdown; + const normalized: BreakdownMetrics = { + models: toMetricMap(breakdown?.models), + model_groups: toMetricMap(breakdown?.model_groups), + mcp_servers: toMetricMap(breakdown?.mcp_servers), + providers: toMetricMap(breakdown?.providers), + api_keys: Object.fromEntries( + Object.entries(breakdown?.api_keys ?? {}).map(([key, value]) => [key, toKeyMetric(value)]), + ), + entities: toMetricMap(breakdown?.entities), + endpoints: toMetricMap(breakdown?.endpoints), + }; + return { date: day.date, metrics: day.metrics, breakdown: normalized }; + }); diff --git a/ui/litellm-dashboard/src/components/UsagePage/keyActivityData.test.ts b/ui/litellm-dashboard/src/components/UsagePage/keyActivityData.test.ts new file mode 100644 index 00000000000..b35ea72896a --- /dev/null +++ b/ui/litellm-dashboard/src/components/UsagePage/keyActivityData.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from "vitest"; + +import type { components } from "@/lib/http/schema"; +import { + EMPTY_DAILY_ACTIVITY_METADATA, + type DailyActivityAggregatedResponse, + type KeySpendActivityRow, + toDailyData, +} from "./dailyActivityApi"; +import { keyDetailFromResponse, mergeKeyActivityPages, overallUsageMetrics } from "./keyActivityData"; + +const completeMetrics: components["schemas"]["SpendMetrics"] = { + api_requests: 2, + autorouter_savings_spend: 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + completion_tokens: 3, + compression_saved_tokens: 0, + compression_savings_spend: 0, + failed_requests: 0, + flat_cost: 0, + gateway_injected_caching_savings_spend: 0, + prompt_caching_savings_spend: 0, + prompt_tokens: 4, + spend: 1.25, + successful_requests: 2, + timed_requests: 0, + total_response_time_ms: 0, + total_tokens: 7, +}; + +const apiKeyActivity = { + metrics: completeMetrics, + metadata: { key_alias: "visible-key", team_id: null }, +}; + +const aggregatedResponse: DailyActivityAggregatedResponse = { + metadata: { + ...EMPTY_DAILY_ACTIVITY_METADATA, + total_api_requests: 200, + total_successful_requests: 198, + total_failed_requests: 2, + total_tokens: 700, + total_prompt_tokens: 400, + total_completion_tokens: 300, + total_spend: 500, + }, + results: [ + { + date: "2026-09-27", + metrics: completeMetrics, + breakdown: { + api_keys: { "key-hash": apiKeyActivity }, + models: { + "gpt-4o-mini": { + metrics: completeMetrics, + metadata: {}, + api_key_breakdown: { "key-hash": apiKeyActivity }, + }, + }, + }, + }, + ], +}; + +const pageRow = (api_key: string): KeySpendActivityRow => ({ + api_key, + metrics: { + api_requests: 1, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + completion_tokens: 2, + failed_requests: 0, + prompt_tokens: 3, + spend: 1, + successful_requests: 1, + total_tokens: 5, + }, + metadata: { key_alias: api_key, team_id: null }, +}); + +describe("key activity data", () => { + it("builds overall totals from metadata and daily data from complete daily metrics", () => { + const summary = overallUsageMetrics( + toDailyData(aggregatedResponse), + aggregatedResponse.metadata ?? EMPTY_DAILY_ACTIVITY_METADATA, + ); + + expect(summary.total_requests).toBe(200); + expect(summary.total_spend).toBe(500); + expect(summary.daily_data).toStrictEqual([ + { + date: "2026-09-27", + metrics: { + prompt_tokens: 4, + completion_tokens: 3, + total_tokens: 7, + api_requests: 2, + spend: 1.25, + successful_requests: 2, + failed_requests: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + avg_response_time_ms: null, + }, + }, + ]); + }); + + it("appends pages without duplicate keys and compares the server offset to the total", () => { + const merged = mergeKeyActivityPages( + [pageRow("key-a"), pageRow("key-b")], + [pageRow("key-b"), pageRow("key-c")], + 5, + 2, + ); + + expect(merged.rows.map((row) => row.api_key)).toStrictEqual(["key-a", "key-b", "key-c"]); + expect(merged.nextOffset).toBe(4); + expect(merged.hasMore).toBe(true); + expect(mergeKeyActivityPages(merged.rows, [pageRow("key-d")], 5, 4).hasMore).toBe(false); + }); + + it("advances past a page of already loaded keys and stops only on an empty page", () => { + const current = [pageRow("key-a"), pageRow("key-b")]; + const duplicates = mergeKeyActivityPages(current, [pageRow("key-a"), pageRow("key-b")], 5, 2); + expect(duplicates).toEqual({ rows: current, nextOffset: 4, hasMore: true }); + expect(mergeKeyActivityPages(current, [], 5, 2)).toEqual({ rows: current, nextOffset: 2, hasMore: false }); + }); + + it("builds full daily details and top models for the requested key", () => { + const detail = keyDetailFromResponse(aggregatedResponse, "key-hash", []); + + expect(detail?.total_requests).toBe(2); + expect(detail?.daily_data).toStrictEqual([ + { + date: "2026-09-27", + metrics: { + prompt_tokens: 4, + completion_tokens: 3, + total_tokens: 7, + api_requests: 2, + spend: 1.25, + successful_requests: 2, + failed_requests: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + avg_response_time_ms: null, + }, + }, + ]); + expect(detail?.top_models.map((model) => model.model)).toStrictEqual(["gpt-4o-mini"]); + }); +}); diff --git a/ui/litellm-dashboard/src/components/UsagePage/keyActivityData.ts b/ui/litellm-dashboard/src/components/UsagePage/keyActivityData.ts new file mode 100644 index 00000000000..4b2e8c573f2 --- /dev/null +++ b/ui/litellm-dashboard/src/components/UsagePage/keyActivityData.ts @@ -0,0 +1,61 @@ +import type { Team } from "@/components/key_team_helpers/key_list"; +import { processActivityData } from "@/components/activity_metrics"; +import type { DailyActivityAggregatedResponse, DailyActivityMetadata, KeySpendActivityRow } from "./dailyActivityApi"; +import { toDailyData } from "./dailyActivityApi"; +import type { ModelActivityData } from "./types"; + +export const overallUsageMetrics = ( + results: ReturnType, + metadata: DailyActivityMetadata, +): ModelActivityData => ({ + label: "Overall Usage", + total_requests: metadata.total_api_requests, + total_successful_requests: metadata.total_successful_requests, + total_failed_requests: metadata.total_failed_requests, + total_cache_read_input_tokens: metadata.total_cache_read_input_tokens, + total_cache_creation_input_tokens: metadata.total_cache_creation_input_tokens, + total_tokens: metadata.total_tokens, + prompt_tokens: metadata.total_prompt_tokens, + completion_tokens: metadata.total_completion_tokens, + total_spend: metadata.total_spend, + total_response_time_ms: metadata.total_response_time_ms, + total_timed_requests: metadata.total_timed_requests, + top_models: [], + daily_data: results.map((day) => ({ + date: day.date, + metrics: { + prompt_tokens: day.metrics.prompt_tokens, + completion_tokens: day.metrics.completion_tokens, + total_tokens: day.metrics.total_tokens, + api_requests: day.metrics.api_requests, + spend: day.metrics.spend, + successful_requests: day.metrics.successful_requests, + failed_requests: day.metrics.failed_requests, + cache_read_input_tokens: day.metrics.cache_read_input_tokens, + cache_creation_input_tokens: day.metrics.cache_creation_input_tokens, + avg_response_time_ms: + day.metrics.timed_requests && day.metrics.timed_requests > 0 + ? (day.metrics.total_response_time_ms ?? 0) / day.metrics.timed_requests + : null, + }, + })), +}); + +export const mergeKeyActivityPages = ( + current: readonly KeySpendActivityRow[], + next: readonly KeySpendActivityRow[], + total: number, + offset: number, +): { rows: KeySpendActivityRow[]; nextOffset: number; hasMore: boolean } => { + const rows: KeySpendActivityRow[] = Array.from( + new Map([...current, ...next].map((row) => [row.api_key, row])).values(), + ); + const nextOffset = offset + next.length; + return { rows, nextOffset, hasMore: next.length > 0 && nextOffset < total }; +}; + +export const keyDetailFromResponse = ( + response: DailyActivityAggregatedResponse, + apiKey: string, + teams: Team[], +): ModelActivityData | undefined => processActivityData({ results: toDailyData(response) }, "api_keys", teams)[apiKey]; diff --git a/ui/litellm-dashboard/src/components/UsagePage/keyActivityFilter.test.ts b/ui/litellm-dashboard/src/components/UsagePage/keyActivityFilter.test.ts index ce181f6b0c6..fbf45139e95 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/keyActivityFilter.test.ts +++ b/ui/litellm-dashboard/src/components/UsagePage/keyActivityFilter.test.ts @@ -16,7 +16,6 @@ function activity(label: string, key_metadata?: KeyMetadata): ModelActivityData prompt_tokens: 5, completion_tokens: 5, total_spend: 0.01, - top_api_keys: [], top_models: [], daily_data: [], }; diff --git a/ui/litellm-dashboard/src/components/UsagePage/types.ts b/ui/litellm-dashboard/src/components/UsagePage/types.ts index 6d0e0642ba2..06f1856a5a6 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/types.ts +++ b/ui/litellm-dashboard/src/components/UsagePage/types.ts @@ -54,16 +54,6 @@ export interface KeyMetadata { tags?: { tag: string; usage: number }[]; } -export interface TopApiKeyData { - api_key: string; - key_alias: string | null; - team_id: string | null; - user: string | null; - spend: number; - requests: number; - tokens: number; -} - export interface TopModelData { model: string; spend: number; @@ -87,7 +77,6 @@ export interface ModelActivityData { total_spend: number; total_response_time_ms?: number; total_timed_requests?: number; - top_api_keys: TopApiKeyData[]; top_models: TopModelData[]; daily_data: { date: string; diff --git a/ui/litellm-dashboard/src/components/activity_metrics.test.tsx b/ui/litellm-dashboard/src/components/activity_metrics.test.tsx index 569f69adb56..8ce18884718 100644 --- a/ui/litellm-dashboard/src/components/activity_metrics.test.tsx +++ b/ui/litellm-dashboard/src/components/activity_metrics.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import React from "react"; import { beforeAll, describe, expect, it, vi } from "vitest"; import { ActivityMetrics, formatKeyLabel, processActivityData, ResponseTimeTooltip } from "./activity_metrics"; @@ -120,7 +120,6 @@ const createMockModelActivityData = (label: string, overrides: Partial { expect(tokenElements.length).toBeGreaterThan(0); }); - it("should not display Top Virtual Keys section when model has no top_api_keys", () => { - render(); - expect(screen.queryByText("Top Virtual Keys by Spend")).not.toBeInTheDocument(); + it("only fetches top keys for sections that have been expanded", async () => { + const fetchTopApiKeys = vi.fn().mockResolvedValue({ + api_keys: [ + { + api_key: "key-123", + metrics: { ...EMPTY_SPEND_METRICS, spend: 50.25, api_requests: 25, total_tokens: 12500 }, + metadata: { key_alias: "Test Key", team_id: "team1" }, + }, + ], + }); + render( + , + ); + + expect(await screen.findAllByText("Test Key")).toHaveLength(1); + expect(fetchTopApiKeys).toHaveBeenCalledTimes(1); + expect(fetchTopApiKeys).toHaveBeenCalledWith("gpt-4"); + + fireEvent.click(screen.getAllByText("GPT-3.5")[0]); + + await waitFor(() => expect(fetchTopApiKeys).toHaveBeenCalledWith("gpt-3.5")); + expect(await screen.findAllByText("Test Key")).toHaveLength(2); + expect(fetchTopApiKeys).toHaveBeenCalledTimes(2); }); - it("should display top API keys section when present", () => { - const modelWithTopKeys: Record = { - "gpt-4": { - ...mockModelMetrics["gpt-4"], - top_api_keys: [ - { - api_key: "key-123", - key_alias: "Test Key", - team_id: "team1", - user: "owner@example.com", - spend: 50.25, - requests: 25, - tokens: 12500, - }, - { - api_key: "key-456", - key_alias: "Owner Alias", - team_id: null, - user: "Owner Alias", - spend: 40.25, - requests: 20, - tokens: 10000, - }, - ], - }, - }; + it("renders the keys the model_top_keys route returns", async () => { + const fetchTopApiKeys = vi.fn().mockResolvedValue({ + api_keys: [ + { + api_key: "key-123", + metrics: { ...EMPTY_SPEND_METRICS, spend: 50.25, api_requests: 25, total_tokens: 12500 }, + metadata: { key_alias: "Test Key", team_id: "team1", user_email: "owner@example.com" }, + }, + { + api_key: "key-456", + metrics: { ...EMPTY_SPEND_METRICS, spend: 40.25, api_requests: 20, total_tokens: 10000 }, + metadata: { key_alias: "Owner Alias", team_id: null, user_id: "Owner Alias" }, + }, + { + api_key: "key-7890123456", + metrics: { ...EMPTY_SPEND_METRICS, spend: 30, api_requests: 10, total_tokens: 5000 }, + metadata: { key_alias: null, team_id: null, user_id: "owner-id-3" }, + }, + ], + }); + render(); - render(); - expect(screen.getByText("Top Virtual Keys by Spend")).toBeInTheDocument(); - expect(screen.getByText("Test Key")).toBeInTheDocument(); + expect(await screen.findByText("Top Virtual Keys by Spend")).toBeInTheDocument(); + expect(await screen.findByText("Test Key")).toBeInTheDocument(); + expect(screen.getByText(/Team: team1/)).toBeInTheDocument(); expect(screen.getByText("User: owner@example.com")).toBeInTheDocument(); expect(screen.getByText("Owner Alias")).toBeInTheDocument(); expect(screen.queryByText("User: Owner Alias")).not.toBeInTheDocument(); + expect(screen.getByText(/key-789012/)).toBeInTheDocument(); + expect(screen.getByText("User: owner-id-3")).toBeInTheDocument(); + expect(fetchTopApiKeys).toHaveBeenCalledTimes(1); }); - it("should display API key hash when alias is missing", () => { - const modelWithTopKeys: Record = { - "gpt-4": { - ...mockModelMetrics["gpt-4"], - top_api_keys: [ - { - api_key: "key-1234567890", - key_alias: null, - team_id: null, - user: null, - spend: 50.25, - requests: 25, - tokens: 12500, - }, - ], - }, - }; + it("refetches and shows loading again when the fetcher identity changes on an expanded model", async () => { + const firstFetch = vi.fn().mockResolvedValue({ + api_keys: [ + { + api_key: "key-old", + metrics: { ...EMPTY_SPEND_METRICS, spend: 10, api_requests: 5, total_tokens: 100 }, + metadata: { key_alias: "Old Scope Key", team_id: null }, + }, + ], + }); + const secondFetch = vi.fn().mockResolvedValue({ + api_keys: [ + { + api_key: "key-new", + metrics: { ...EMPTY_SPEND_METRICS, spend: 20, api_requests: 7, total_tokens: 200 }, + metadata: { key_alias: "New Scope Key", team_id: null }, + }, + ], + }); + const { rerender } = render(); - render(); - expect(screen.getByText(/key-123456/)).toBeInTheDocument(); + expect(await screen.findByText("Old Scope Key")).toBeInTheDocument(); + expect(firstFetch).toHaveBeenCalledWith("gpt-4"); + + rerender(); + + expect(screen.getByText("Loading top keys...")).toBeInTheDocument(); + expect(screen.queryByText("Old Scope Key")).not.toBeInTheDocument(); + expect(await screen.findByText("New Scope Key")).toBeInTheDocument(); + expect(secondFetch).toHaveBeenCalledWith("gpt-4"); }); - it("should display team information for top API keys", () => { - const modelWithTopKeys: Record = { - "gpt-4": { - ...mockModelMetrics["gpt-4"], - top_api_keys: [ + it("shows an error with Retry when the top keys request fails and refetches on retry", async () => { + const fetchTopApiKeys = vi + .fn() + .mockRejectedValueOnce(new Error("boom")) + .mockResolvedValueOnce({ + api_keys: [ { - api_key: "key-123", - key_alias: "Test Key", - team_id: "team1", - user: null, - spend: 50.25, - requests: 25, - tokens: 12500, + api_key: "key-retry", + metrics: { ...EMPTY_SPEND_METRICS, spend: 5, api_requests: 1, total_tokens: 10 }, + metadata: { key_alias: "Retried Key", team_id: null }, }, ], - }, - }; + }); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + render(); - render(); - expect(screen.getByText(/Team: team1/)).toBeInTheDocument(); + expect(await screen.findByText(/Could not load top keys\./)).toBeInTheDocument(); + expect(screen.getByText("Top Virtual Keys by Spend")).toBeInTheDocument(); + expect(fetchTopApiKeys).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + + expect(screen.getByText("Loading top keys...")).toBeInTheDocument(); + expect(await screen.findByText("Retried Key")).toBeInTheDocument(); + expect(fetchTopApiKeys).toHaveBeenCalledTimes(2); + expect(fetchTopApiKeys).toHaveBeenLastCalledWith("gpt-4"); + expect(screen.queryByText(/Could not load top keys\./)).not.toBeInTheDocument(); + consoleError.mockRestore(); + }); + + it("hides the top keys section without a fetcher", () => { + render(); + expect(screen.queryByText("Top Virtual Keys by Spend")).not.toBeInTheDocument(); }); it("should display Model Usage when model has top_models", () => { @@ -386,7 +431,6 @@ describe("ActivityMetrics", () => { render(); - // Only the highest-spend section is expanded initially, so only its body is mounted. const sectionsMounted = () => screen.getAllByText("Spend per day").length; expect(sectionsMounted()).toBe(1); @@ -788,8 +832,6 @@ describe("processActivityData", () => { expect(Object.keys(result).sort()).toEqual(["gpt-5.2", "gpt-5.2-eu"]); expect(result["gpt-5.2-eu"].label).toBe("gpt-5.2-eu"); expect(result["gpt-5.2-eu"].total_spend).toBe(7); - expect(result["gpt-5.2-eu"].top_api_keys).toHaveLength(1); - expect(result["gpt-5.2-eu"].top_api_keys[0].key_alias).toBe("eu-key"); expect(result["gpt-5.2"].total_spend).toBe(3); expect(result["gpt-5.2"].total_requests).toBe(3); }); @@ -1107,151 +1149,6 @@ describe("processActivityData", () => { }; const result = processActivityData(dailyActivityWithBreakdown, "models"); - - expect(result["gpt-4"].top_api_keys).toHaveLength(2); - expect(result["gpt-4"].top_api_keys[0].spend).toBe(60.0); - expect(result["gpt-4"].top_api_keys[0].api_key).toBe("key-1"); - expect(result["gpt-4"].top_api_keys[1].spend).toBe(40.5); - expect(result["gpt-4"].top_api_keys.map(({ api_key, user }) => [api_key, user])).toEqual([ - ["key-1", "owner-1@example.com"], - ["key-2", "owner-id-2"], - ]); - }); - - it("should limit top_api_keys to 5 entries", () => { - const dailyActivityWithManyKeys: { results: DailyData[] } = { - results: [ - { - date: "2025-01-01", - metrics: { - spend: 100.5, - prompt_tokens: 30000, - completion_tokens: 20000, - total_tokens: 50000, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - cache_read_input_tokens: 1000, - cache_creation_input_tokens: 500, - }, - breakdown: { - models: { - "gpt-4": { - metrics: { - spend: 100.5, - prompt_tokens: 30000, - completion_tokens: 20000, - total_tokens: 50000, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - cache_read_input_tokens: 1000, - cache_creation_input_tokens: 500, - }, - metadata: {}, - api_key_breakdown: { - "key-1": { - metrics: { - spend: 20.0, - prompt_tokens: 6000, - completion_tokens: 4000, - total_tokens: 10000, - api_requests: 20, - successful_requests: 19, - failed_requests: 1, - cache_read_input_tokens: 200, - cache_creation_input_tokens: 100, - }, - metadata: { key_alias: "key-1", team_id: null }, - }, - "key-2": { - metrics: { - spend: 19.0, - prompt_tokens: 5700, - completion_tokens: 3800, - total_tokens: 9500, - api_requests: 19, - successful_requests: 18, - failed_requests: 1, - cache_read_input_tokens: 190, - cache_creation_input_tokens: 95, - }, - metadata: { key_alias: "key-2", team_id: null }, - }, - "key-3": { - metrics: { - spend: 18.0, - prompt_tokens: 5400, - completion_tokens: 3600, - total_tokens: 9000, - api_requests: 18, - successful_requests: 17, - failed_requests: 1, - cache_read_input_tokens: 180, - cache_creation_input_tokens: 90, - }, - metadata: { key_alias: "key-3", team_id: null }, - }, - "key-4": { - metrics: { - spend: 17.0, - prompt_tokens: 5100, - completion_tokens: 3400, - total_tokens: 8500, - api_requests: 17, - successful_requests: 16, - failed_requests: 1, - cache_read_input_tokens: 170, - cache_creation_input_tokens: 85, - }, - metadata: { key_alias: "key-4", team_id: null }, - }, - "key-5": { - metrics: { - spend: 16.0, - prompt_tokens: 4800, - completion_tokens: 3200, - total_tokens: 8000, - api_requests: 16, - successful_requests: 15, - failed_requests: 1, - cache_read_input_tokens: 160, - cache_creation_input_tokens: 80, - }, - metadata: { key_alias: "key-5", team_id: null }, - }, - "key-6": { - metrics: { - spend: 15.0, - prompt_tokens: 4500, - completion_tokens: 3000, - total_tokens: 7500, - api_requests: 15, - successful_requests: 14, - failed_requests: 1, - cache_read_input_tokens: 150, - cache_creation_input_tokens: 75, - }, - metadata: { key_alias: "key-6", team_id: null }, - }, - }, - }, - }, - model_groups: {}, - mcp_servers: {}, - providers: {}, - api_keys: {}, - entities: {}, - }, - }, - ], - }; - - const result = processActivityData(dailyActivityWithManyKeys, "models"); - - expect(result["gpt-4"].top_api_keys).toHaveLength(5); - expect(result["gpt-4"].top_api_keys[0].spend).toBe(20.0); - expect(result["gpt-4"].top_api_keys[4].spend).toBe(16.0); }); it("should return empty object when results array is empty", () => { @@ -1368,8 +1265,6 @@ describe("processActivityData", () => { }; const result = processActivityData(dailyActivityWithBreakdown, "api_keys", MOCK_TEAMS); - - expect(result["key-1"].top_api_keys).toEqual([]); }); it("should handle missing cache tokens gracefully", () => { diff --git a/ui/litellm-dashboard/src/components/activity_metrics.tsx b/ui/litellm-dashboard/src/components/activity_metrics.tsx index 5ddb89703ea..e7712a99c71 100644 --- a/ui/litellm-dashboard/src/components/activity_metrics.tsx +++ b/ui/litellm-dashboard/src/components/activity_metrics.tsx @@ -13,16 +13,20 @@ import { resolveTeamAliasFromTeamID } from "@/utils/teamUtils"; import { Card, CardContent } from "@/components/ui/card"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { ChevronDown } from "lucide-react"; -import React, { useState } from "react"; +import React, { useRef, useState } from "react"; import { Team } from "./key_team_helpers/key_list"; import KeyModelUsageView from "./UsagePage/components/KeyModelUsageView"; import { keyActivityLabel } from "./UsagePage/keyActivityLabel"; -import { DailyData, KeyMetricWithMetadata, ModelActivityData, TopApiKeyData, TopModelData } from "./UsagePage/types"; +import type { ModelTopKeysResponse } from "./UsagePage/dailyActivityApi"; +import { DailyData, KeyMetricWithMetadata, ModelActivityData, TopModelData } from "./UsagePage/types"; import { averageResponseTimeMs, formatResponseTime, valueFormatter } from "./UsagePage/utils/value_formatters"; interface ActivityMetricsProps { modelMetrics: Record; + summaryMetrics?: ModelActivityData; + summaryTitle?: string; hidePromptCachingMetrics?: boolean; + fetchTopApiKeys?: (model: string) => Promise; } const modelAverageResponseTimeMs = (metrics: ModelActivityData): number | null => @@ -37,14 +41,142 @@ export const ResponseTimeTooltip = ({ active, payload, label }: ChartTooltipProp /> ); -const ModelSection = ({ +const ModelTopKeys = ({ + modelName, + fetchTopApiKeys, +}: { + modelName: string; + fetchTopApiKeys: (model: string) => Promise; +}) => { + interface ModelTopKeyRow { + api_key: string; + key_alias: string | null; + team_id: string | null; + user: string | null; + spend: number; + requests: number; + tokens: number; + } + const [settled, setSettled] = useState<{ + modelName: string; + fetchTopApiKeys: (model: string) => Promise; + rows: ModelTopKeyRow[]; + failed: boolean; + } | null>(null); + const [retryToken, setRetryToken] = useState(0); + + React.useEffect(() => { + let cancelled = false; + fetchTopApiKeys(modelName) + .then((response) => { + if (cancelled) return; + setSettled({ + modelName, + fetchTopApiKeys, + rows: response.api_keys.map((row) => ({ + api_key: row.api_key, + key_alias: row.metadata.key_alias ?? null, + team_id: row.metadata.team_id ?? null, + user: row.metadata.user_email ?? row.metadata.user_id ?? null, + spend: row.metrics.spend, + requests: row.metrics.api_requests, + tokens: row.metrics.total_tokens, + })), + failed: false, + }); + }) + .catch((error) => { + if (cancelled) return; + console.error(`Failed to fetch top keys for ${modelName}:`, error); + setSettled({ modelName, fetchTopApiKeys, rows: [], failed: true }); + }); + return () => { + cancelled = true; + }; + }, [modelName, fetchTopApiKeys, retryToken]); + + const current = settled?.modelName === modelName && settled.fetchTopApiKeys === fetchTopApiKeys ? settled : null; + + if (current === null) { + return ( + + +

Top Virtual Keys by Spend

+

Loading top keys...

+
+
+ ); + } + + if (current.failed) { + return ( + + +

Top Virtual Keys by Spend

+

+ Could not load top keys.{" "} + +

+
+
+ ); + } + + const rows = current.rows; + if (rows.length === 0) return null; + + return ( + + +

Top Virtual Keys by Spend

+
+
+ {rows.map((keyData) => { + const keyLabel = keyData.key_alias || `${keyData.api_key.substring(0, 10)}...`; + return ( +
+
+

{keyLabel}

+ {keyData.team_id &&

Team: {keyData.team_id}

} + {keyData.user && keyData.user !== keyLabel && ( +

User: {keyData.user}

+ )} +
+
+

${formatNumberWithCommas(keyData.spend, 2)}

+

+ {keyData.requests.toLocaleString()} requests | {keyData.tokens.toLocaleString()} tokens +

+
+
+ ); + })} +
+
+
+
+ ); +}; + +export const ModelSection = ({ modelName, metrics, hidePromptCachingMetrics = false, + fetchTopApiKeys, }: { modelName: string; metrics: ModelActivityData; hidePromptCachingMetrics?: boolean; + fetchTopApiKeys?: (model: string) => Promise; }) => { return (
@@ -96,37 +228,7 @@ const ModelSection = ({
- {metrics.top_api_keys && metrics.top_api_keys.length > 0 && ( - - -

Top Virtual Keys by Spend

-
-
- {metrics.top_api_keys.map((keyData) => { - const keyLabel = keyData.key_alias || `${keyData.api_key.substring(0, 10)}...`; - return ( -
-
-

{keyLabel}

- {keyData.team_id &&

Team: {keyData.team_id}

} - {keyData.user && keyData.user !== keyLabel && ( -

User: {keyData.user}

- )} -
-
-

${formatNumberWithCommas(keyData.spend, 2)}

-

- {keyData.requests.toLocaleString()} requests | {keyData.tokens.toLocaleString()} tokens -

-
-
- ); - })} -
-
-
-
- )} + {fetchTopApiKeys && } {metrics.top_models && metrics.top_models.length > 0 && } @@ -272,24 +374,33 @@ const ModelSection = ({ ); }; -const ModelCollapsible = ({ +export const ModelCollapsible = ({ defaultOpen, header, children, + onFirstOpen, }: { defaultOpen: boolean; header: React.ReactNode; children: React.ReactNode; + onFirstOpen?: () => void; }) => { const [open, setOpen] = useState(defaultOpen); const [everOpened, setEverOpened] = useState(defaultOpen); + const firstOpenRef = useRef(defaultOpen); return ( { setOpen(next); - if (next) setEverOpened(true); + if (next) { + setEverOpened(true); + if (!firstOpenRef.current) { + firstOpenRef.current = true; + onFirstOpen?.(); + } + } }} className="border-b last:border-b-0" > @@ -306,7 +417,13 @@ const ModelCollapsible = ({ ); }; -export const ActivityMetrics: React.FC = ({ modelMetrics, hidePromptCachingMetrics = false }) => { +export const ActivityMetrics: React.FC = ({ + modelMetrics, + summaryMetrics, + summaryTitle = "Overall Usage", + hidePromptCachingMetrics = false, + fetchTopApiKeys, +}) => { const modelNames = Object.keys(modelMetrics).sort((a, b) => { if (a === "") return 1; if (b === "") return -1; @@ -374,42 +491,44 @@ export const ActivityMetrics: React.FC = ({ modelMetrics, }); // Convert daily_data object to array and sort by date - const sortedDailyData = Object.entries(totalMetrics.daily_data) - .map(([date, metrics]) => ({ date, metrics })) - .sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); + const sortedDailyData = + summaryMetrics?.daily_data ?? + Object.entries(totalMetrics.daily_data) + .map(([date, metrics]) => ({ date, metrics })) + .sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); + const totalRequests = summaryMetrics?.total_requests ?? totalMetrics.total_requests; + const totalSuccessfulRequests = summaryMetrics?.total_successful_requests ?? totalMetrics.total_successful_requests; + const totalTokens = summaryMetrics?.total_tokens ?? totalMetrics.total_tokens; + const totalSpend = summaryMetrics?.total_spend ?? totalMetrics.total_spend; return (
{/* Global Summary */}
-

Overall Usage

+

{summaryTitle}

Total Requests

-

{totalMetrics.total_requests.toLocaleString()}

+

{totalRequests.toLocaleString()}

Total Successful Requests

-

- {totalMetrics.total_successful_requests.toLocaleString()} -

+

{totalSuccessfulRequests.toLocaleString()}

Total Tokens

-

{totalMetrics.total_tokens.toLocaleString()}

+

{totalTokens.toLocaleString()}

Total Spend

-

- ${formatNumberWithCommas(totalMetrics.total_spend, 2)} -

+

${formatNumberWithCommas(totalSpend, 2)}

@@ -463,40 +582,49 @@ export const ActivityMetrics: React.FC = ({ modelMetrics,
{/* Individual Model Sections */} -
- {modelNames.map((modelName) => ( - -

- {modelMetrics[modelName].label || "Unknown Item"} -

-
- ${formatNumberWithCommas(modelMetrics[modelName].total_spend, 2)} - {modelMetrics[modelName].total_requests.toLocaleString()} requests - {modelAverageResponseTimeMs(modelMetrics[modelName]) != null && ( - {formatResponseTime(modelAverageResponseTimeMs(modelMetrics[modelName]))} avg response - )} + {modelNames.length > 0 && ( +
+ {modelNames.map((modelName) => ( + +

+ {modelMetrics[modelName].label || "Unknown Item"} +

+
+ ${formatNumberWithCommas(modelMetrics[modelName].total_spend, 2)} + {modelMetrics[modelName].total_requests.toLocaleString()} requests + {modelAverageResponseTimeMs(modelMetrics[modelName]) != null && ( + + {formatResponseTime(modelAverageResponseTimeMs(modelMetrics[modelName]))} avg response + + )} +
-
- } - > - -
- ))} -
+ } + > + + + ))} +
+ )}
); }; // Helper function to format key label -export const formatKeyLabel = (modelData: KeyMetricWithMetadata, model: string, teams: Team[]): string => { +export const formatKeyLabel = ( + modelData: Pick, + model: string, + teams: Team[], +): string => { const keyAlias = keyActivityLabel(modelData.metadata, `key-hash-${model}`); const teamId = modelData.metadata.team_id; if (teamId) { @@ -536,7 +664,6 @@ export const processActivityData = ( total_cache_creation_input_tokens: 0, total_response_time_ms: 0, total_timed_requests: 0, - top_api_keys: [], top_models: [], daily_data: [], }; @@ -576,42 +703,6 @@ export const processActivityData = ( }); }); - // Process Virtual Key breakdowns for each metric (skip if key is 'api_keys' to avoid duplication) - if (key !== "api_keys") { - Object.entries(modelMetrics).forEach(([model, _]) => { - const apiKeyBreakdown: Record = {}; - - // Aggregate Virtual Key data across all days - dailyActivity.results.forEach((day) => { - const modelData = day.breakdown[key]?.[model]; - if (modelData && "api_key_breakdown" in modelData) { - Object.entries(modelData.api_key_breakdown || {}).forEach(([apiKey, keyData]) => { - if (!apiKeyBreakdown[apiKey]) { - apiKeyBreakdown[apiKey] = { - api_key: apiKey, - key_alias: keyActivityLabel(keyData.metadata, "") || null, - team_id: keyData.metadata.team_id, - user: keyData.metadata.user_email ?? keyData.metadata.user_id ?? null, - spend: 0, - requests: 0, - tokens: 0, - }; - } - - apiKeyBreakdown[apiKey].spend += keyData.metrics.spend; - apiKeyBreakdown[apiKey].requests += keyData.metrics.api_requests; - apiKeyBreakdown[apiKey].tokens += keyData.metrics.total_tokens; - }); - } - }); - - // Sort by spend and take top 5 - modelMetrics[model].top_api_keys = Object.values(apiKeyBreakdown) - .sort((a, b) => b.spend - a.spend) - .slice(0, 5); - }); - } - // Process Model breakdowns for each API key (only when key is 'api_keys') if (key === "api_keys") { Object.entries(modelMetrics).forEach(([apiKeyHash, _]) => { diff --git a/ui/litellm-dashboard/src/components/chat/UsagePanel.tsx b/ui/litellm-dashboard/src/components/chat/UsagePanel.tsx index b6c6b78fe0b..e839890a357 100644 --- a/ui/litellm-dashboard/src/components/chat/UsagePanel.tsx +++ b/ui/litellm-dashboard/src/components/chat/UsagePanel.tsx @@ -3,7 +3,7 @@ import React, { useState } from "react"; import { BarChart3 } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; -import { userDailyActivityAggregatedCall } from "../networking"; +import { dailyActivityAggregatedCall } from "../networking"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; @@ -95,7 +95,15 @@ const UsagePanel: React.FC = ({ accessToken, userId }) => { const { data, isLoading } = useQuery({ queryKey: [USAGE_QUERY_KEY, accessToken, userId, timeRange], - queryFn: () => userDailyActivityAggregatedCall(accessToken, start, end, userId), + queryFn: () => { + const request = { + accessToken, + startTime: start, + endTime: end, + entityIds: userId ? [userId] : null, + }; + return dailyActivityAggregatedCall("user", request); + }, enabled: !!accessToken, }); diff --git a/ui/litellm-dashboard/src/components/networking.dailyActivity.test.ts b/ui/litellm-dashboard/src/components/networking.dailyActivity.test.ts new file mode 100644 index 00000000000..6821d26fb33 --- /dev/null +++ b/ui/litellm-dashboard/src/components/networking.dailyActivity.test.ts @@ -0,0 +1,224 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + cacheLeakageKeysCall, + dailyActivityAggregatedCall, + dailyActivityExportCall, + dailyActivityKeySearchCall, + dailyActivityModelTopKeysCall, +} from "./networking"; +import type { DailyActivityEntity, DailyActivityRequest } from "./UsagePage/dailyActivityApi"; + +const originalFetch = global.fetch; + +const captureFetch = () => { + const mockFetch = vi.fn().mockImplementation( + async () => + new Response(JSON.stringify({ results: [], metadata: {}, api_keys: [] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + global.fetch = mockFetch; + return mockFetch; +}; + +const requestedUrl = (mockFetch: ReturnType, callIndex = 0): URL => + new URL(String(mockFetch.mock.calls[callIndex][0]), "http://example.com"); + +const start = new Date("2025-01-05T00:00:00Z"); +const end = new Date("2025-01-31T00:00:00Z"); + +const req = (overrides: Partial = {}): DailyActivityRequest => ({ + accessToken: "sk-key", + startTime: start, + endTime: end, + ...overrides, +}); + +afterEach(() => { + global.fetch = originalFetch; +}); + +describe("dailyActivityAggregatedCall", () => { + it.each<[DailyActivityEntity, string]>([ + ["user", "user_id"], + ["team", "team_ids"], + ["tag", "tags"], + ["organization", "organization_ids"], + ["customer", "end_user_ids"], + ["agent", "agent_ids"], + ])("GETs /%s/daily/activity/aggregated with %s filters", async (entity, param) => { + const mockFetch = captureFetch(); + + await dailyActivityAggregatedCall(entity, req({ entityIds: ["e1", "e2"] })); + + const url = requestedUrl(mockFetch); + expect(url.pathname).toBe(`/${entity}/daily/activity/aggregated`); + const expected = entity === "user" ? "e1" : "e1,e2"; + expect(url.searchParams.get(param)).toBe(expected); + expect(url.searchParams.get("start_date")).toBe("2025-01-05"); + expect(url.searchParams.get("end_date")).toBe("2025-01-31"); + expect(url.searchParams.has("timezone")).toBe(true); + expect(url.searchParams.has("page")).toBe(false); + expect(url.searchParams.has("page_size")).toBe(false); + }); + + it("omits the entity filter when no ids are given", async () => { + const mockFetch = captureFetch(); + + await dailyActivityAggregatedCall("team", req({ entityIds: null })); + + const url = requestedUrl(mockFetch); + expect(url.searchParams.has("team_ids")).toBe(false); + expect(url.searchParams.has("exclude_team_ids")).toBe(false); + }); + + it.each<[DailyActivityEntity, string]>([ + ["team", "exclude_team_ids"], + ["organization", "exclude_organization_ids"], + ["customer", "exclude_end_user_ids"], + ["agent", "exclude_agent_ids"], + ])("sends exclude ids comma-joined under %s", async (entity, param) => { + const mockFetch = captureFetch(); + + await dailyActivityAggregatedCall( + entity, + req({ entityIds: ["e1"], excludeEntityIds: ["litellm-dashboard", "other"] }), + ); + + expect(requestedUrl(mockFetch).searchParams.get(param)).toBe("litellm-dashboard,other"); + }); + + it.each<[DailyActivityEntity]>([["tag"], ["user"]])("emits no exclude param for %s", async (entity) => { + const mockFetch = captureFetch(); + + await dailyActivityAggregatedCall(entity, req({ entityIds: ["e1"], excludeEntityIds: ["litellm-dashboard"] })); + + const params = [...requestedUrl(mockFetch).searchParams.keys()]; + expect(params.some((key) => key.startsWith("exclude_"))).toBe(false); + }); + + it("keeps an empty api_key as a filter rather than widening the read", async () => { + const mockFetch = captureFetch(); + + await dailyActivityAggregatedCall("user", req({ apiKey: "" })); + + expect(requestedUrl(mockFetch).searchParams.get("api_key")).toBe(""); + }); + + it("omits api_key entirely when none is given", async () => { + const mockFetch = captureFetch(); + + await dailyActivityAggregatedCall("user", req({ apiKey: null })); + + expect(requestedUrl(mockFetch).searchParams.has("api_key")).toBe(false); + }); + + it("sends include_current_utc_day only when set", async () => { + const mockFetch = captureFetch(); + + await dailyActivityAggregatedCall("user", req({ includeCurrentUtcDay: true })); + + expect(requestedUrl(mockFetch).searchParams.get("include_current_utc_day")).toBe("true"); + }); + + it("sends api_key_limit only when provided", async () => { + const mockFetch = captureFetch(); + + await dailyActivityAggregatedCall("user", req()); + await dailyActivityAggregatedCall("user", req({ apiKeyLimit: 250 })); + + expect(requestedUrl(mockFetch, 0).searchParams.has("api_key_limit")).toBe(false); + expect(requestedUrl(mockFetch, 1).searchParams.get("api_key_limit")).toBe("250"); + }); +}); + +describe("dailyActivityKeySearchCall", () => { + it("GETs the search route with the search term", async () => { + const mockFetch = captureFetch(); + + await dailyActivityKeySearchCall("team", req({ entityIds: ["t1"] }), "alice"); + + const url = requestedUrl(mockFetch); + expect(url.pathname).toBe("/team/daily/activity/aggregated/search"); + expect(url.searchParams.get("search")).toBe("alice"); + expect(url.searchParams.get("team_ids")).toBe("t1"); + expect(url.searchParams.has("limit")).toBe(false); + }); + + it("sends the optional limit", async () => { + const mockFetch = captureFetch(); + + await dailyActivityKeySearchCall("team", req(), "alice", 50); + + expect(requestedUrl(mockFetch).searchParams.get("limit")).toBe("50"); + }); +}); + +describe("dailyActivityModelTopKeysCall", () => { + it("GETs model_top_keys with model_group and by_model_group", async () => { + const mockFetch = captureFetch(); + + await dailyActivityModelTopKeysCall("user", req(), "gpt-4o", true); + + const url = requestedUrl(mockFetch); + expect(url.pathname).toBe("/user/daily/activity/aggregated/model_top_keys"); + expect(url.searchParams.get("model_group")).toBe("gpt-4o"); + expect(url.searchParams.get("by_model_group")).toBe("true"); + expect(url.searchParams.has("limit")).toBe(false); + }); + + it("sends by_model_group=false for the models view", async () => { + const mockFetch = captureFetch(); + + await dailyActivityModelTopKeysCall("user", req(), "gpt-4o", false); + + expect(requestedUrl(mockFetch).searchParams.get("by_model_group")).toBe("false"); + }); + + it("sends the optional limit", async () => { + const mockFetch = captureFetch(); + + await dailyActivityModelTopKeysCall("user", req(), "gpt-4o", true, 25); + + expect(requestedUrl(mockFetch).searchParams.get("limit")).toBe("25"); + }); +}); + +describe("dailyActivityExportCall", () => { + it("GETs the export route with export_type and format", async () => { + const mockFetch = captureFetch(); + + await dailyActivityExportCall("organization", req({ entityIds: ["o1"] }), "daily_with_keys", "csv"); + + const url = requestedUrl(mockFetch); + expect(url.pathname).toBe("/organization/daily/activity/export"); + expect(url.searchParams.get("export_type")).toBe("daily_with_keys"); + expect(url.searchParams.get("format")).toBe("csv"); + expect(url.searchParams.get("organization_ids")).toBe("o1"); + }); +}); + +describe("cacheLeakageKeysCall", () => { + it("GETs the user cache_leakage_keys route with the user scope", async () => { + const mockFetch = captureFetch(); + + await cacheLeakageKeysCall(req({ entityIds: ["u1"], apiKey: "hash-1", includeCurrentUtcDay: true })); + + const url = requestedUrl(mockFetch); + expect(url.pathname).toBe("/user/daily/activity/aggregated/cache_leakage_keys"); + expect(url.searchParams.get("user_id")).toBe("u1"); + expect(url.searchParams.get("api_key")).toBe("hash-1"); + expect(url.searchParams.get("include_current_utc_day")).toBe("true"); + expect(url.searchParams.has("limit")).toBe(false); + }); + + it("sends the optional limit", async () => { + const mockFetch = captureFetch(); + + await cacheLeakageKeysCall(req(), 75); + + expect(requestedUrl(mockFetch).searchParams.get("limit")).toBe("75"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/networking.test.ts b/ui/litellm-dashboard/src/components/networking.test.ts index b964231804e..d5e0b21251e 100644 --- a/ui/litellm-dashboard/src/components/networking.test.ts +++ b/ui/litellm-dashboard/src/components/networking.test.ts @@ -157,60 +157,6 @@ describe("modelInfoCall", () => { }); }); -describe("daily activity helpers", () => { - const startTime = new Date("2025-02-12T00:00:00.000Z"); - const endTime = new Date("2025-02-19T00:00:00.000Z"); - let currentFetch: typeof global.fetch; - - const setupSuccessfulFetch = () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: true, - json: vi.fn().mockResolvedValue({ data: [] }), - } as any); - global.fetch = mockFetch as any; - return mockFetch; - }; - - beforeEach(() => { - vi.clearAllMocks(); - currentFetch = global.fetch; - }); - - afterEach(() => { - global.fetch = currentFetch; - }); - - it("appends tag list when tags argument is provided", async () => { - const mockFetch = setupSuccessfulFetch(); - - await Networking.tagDailyActivityCall("token", startTime, endTime, 2, ["alpha", "beta"]); - - expect(mockFetch).toHaveBeenCalledOnce(); - const calledUrl = mockFetch.mock.calls[0][0] as string; - const parsed = new URL(calledUrl, "http://example.com"); - - expect(parsed.pathname).toBe("/tag/daily/activity"); - expect(parsed.searchParams.get("tags")).toBe("alpha,beta"); - }); - - it("always includes exclude_team_ids but only adds team_ids when given", async () => { - const mockFetchWithoutTeams = setupSuccessfulFetch(); - - await Networking.teamDailyActivityCall("token", startTime, endTime, 1, null); - const urlWithoutTeams = new URL(mockFetchWithoutTeams.mock.calls[0][0] as string, "http://example.com"); - - expect(urlWithoutTeams.searchParams.get("exclude_team_ids")).toBe("litellm-dashboard"); - expect(urlWithoutTeams.searchParams.has("team_ids")).toBe(false); - - const mockFetchWithTeams = setupSuccessfulFetch(); - await Networking.teamDailyActivityCall("token", startTime, endTime, 3, ["team-a", "team-b"]); - const urlWithTeams = new URL(mockFetchWithTeams.mock.calls[0][0] as string, "http://example.com"); - - expect(urlWithTeams.searchParams.get("team_ids")).toBe("team-a,team-b"); - expect(urlWithTeams.searchParams.get("exclude_team_ids")).toBe("litellm-dashboard"); - }); -}); - describe("UI config and public endpoints", () => { const originalFetch = global.fetch; @@ -772,88 +718,6 @@ describe("getAutoRouterClassifierDefaultPromptCall", () => { }); }); -describe("daily activity api_key filter", () => { - const originalFetch = global.fetch; - - const captureFetch = () => { - const mockFetch = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ results: [], metadata: {} }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }), - ); - global.fetch = mockFetch; - return mockFetch; - }; - - const requestedUrl = (mockFetch: ReturnType): string => String(mockFetch.mock.calls[0][0]); - - const start = new Date("2025-01-01T00:00:00Z"); - const end = new Date("2025-01-31T00:00:00Z"); - - afterEach(() => { - global.fetch = originalFetch; - }); - - it("sends the key hash as api_key from the paginated caller", async () => { - const mockFetch = captureFetch(); - - await Networking.userDailyActivityCall("sk-key", start, end, 1, null, false, "hash-abc"); - - expect(requestedUrl(mockFetch)).toContain("api_key=hash-abc"); - }); - - it("sends the key hash as api_key from the aggregated caller", async () => { - const mockFetch = captureFetch(); - - await Networking.userDailyActivityAggregatedCall("sk-key", start, end, null, false, "hash-abc"); - - expect(requestedUrl(mockFetch)).toContain("api_key=hash-abc"); - }); - - // The two wrappers serialize the same optional filters through different transports, so an - // absent key has to drop the param in both. Dropping it on one side and sending it empty on - // the other would widen a key-scoped read into an unscoped one. - it.each([ - ["paginated", () => Networking.userDailyActivityCall("sk-key", start, end, 1, null, false, null)], - ["aggregated", () => Networking.userDailyActivityAggregatedCall("sk-key", start, end, null, false, null)], - ])("omits api_key entirely from the %s caller when no key is given", async (_label, call) => { - const mockFetch = captureFetch(); - - await call(); - - expect(requestedUrl(mockFetch)).not.toContain("api_key"); - }); - - // An empty key must not be coerced into "no filter". Dropping it would turn a key-scoped read - // into a proxy-wide one and report every key's savings as this key's, so both callers send it - // through and let the filter match nothing instead. - it.each([ - ["paginated", () => Networking.userDailyActivityCall("sk-key", start, end, 1, null, false, "")], - ["aggregated", () => Networking.userDailyActivityAggregatedCall("sk-key", start, end, null, false, "")], - ])("keeps an empty api_key as a filter rather than widening the %s read", async (_label, call) => { - const mockFetch = captureFetch(); - - await call(); - - expect(requestedUrl(mockFetch)).toContain("api_key="); - }); - - // user_id rides the same two transports and widens the same way, so it gets the same guard. - // The aggregated caller used to drop "" via `||`; without this the two filters could drift - // apart again on one side only. - it.each([ - ["paginated", () => Networking.userDailyActivityCall("sk-key", start, end, 1, "", false, null)], - ["aggregated", () => Networking.userDailyActivityAggregatedCall("sk-key", start, end, "", false, null)], - ])("keeps an empty user_id as a filter rather than widening the %s read", async (_label, call) => { - const mockFetch = captureFetch(); - - await call(); - - expect(requestedUrl(mockFetch)).toContain("user_id="); - }); -}); - describe("userListCall search serialization", () => { const originalFetch = global.fetch; diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index da902d4a9e2..20037c9b12b 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -121,7 +121,19 @@ import { deriveErrorMessage, extractProxyErrorMessage, unwrapProxyErrorMessage, + type QueryParams, } from "@/lib/http/client"; +import type { + CacheLeakageKeysResponse, + DailyActivityAggregatedResponse, + DailyActivityEntity, + DailyActivityKeyPageResponse, + DailyActivityKeySearchResponse, + DailyActivityRequest, + ExportFormat, + ExportType, + ModelTopKeysResponse, +} from "./UsagePage/dailyActivityApi"; import { resolveApiBase } from "@/lib/http/resolveApiBase"; import { registerAuthHeaderNameGetter, @@ -1281,192 +1293,110 @@ export const transformRequestCall = async (accessToken: string, request: object) } }; -type DailyActivityQueryValue = string | number | string[] | null | undefined; - -const DEFAULT_DAILY_ACTIVITY_PAGE_SIZE = "1000"; - -const appendDailyActivityQueryParam = (params: URLSearchParams, key: string, value: DailyActivityQueryValue) => { - if (value === null || value === undefined) { - return; - } - - if (Array.isArray(value)) { - if (value.length > 0) { - params.append(key, value.join(",")); - } - return; - } - - params.append(key, `${value}`); +const ENTITY_ID_QUERY_PARAM: Record = { + user: "user_id", + team: "team_ids", + tag: "tags", + organization: "organization_ids", + customer: "end_user_ids", + agent: "agent_ids", }; -const buildDailyActivityUrl = ( - endpoint: string, - startTime: Date, - endTime: Date, - page: number, - extraQueryParams?: Record, -) => { - const resolvedEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}`; - const baseUrl = proxyBaseUrl ? `${proxyBaseUrl}${resolvedEndpoint}` : resolvedEndpoint; - - const params = new URLSearchParams(); - params.append("start_date", formatDate(startTime)); - params.append("end_date", formatDate(endTime)); - params.append("page_size", DEFAULT_DAILY_ACTIVITY_PAGE_SIZE); - params.append("page", page.toString()); - // Send timezone offset so backend can adjust date range for UTC storage - params.append("timezone", new Date().getTimezoneOffset().toString()); - - if (extraQueryParams) { - Object.entries(extraQueryParams).forEach(([key, value]) => { - appendDailyActivityQueryParam(params, key, value); - }); - } - - const queryString = params.toString(); - return queryString ? `${baseUrl}?${queryString}` : baseUrl; +const EXCLUDE_ENTITY_ID_QUERY_PARAM: Partial> = { + team: "exclude_team_ids", + organization: "exclude_organization_ids", + customer: "exclude_end_user_ids", + agent: "exclude_agent_ids", }; -type DailyActivityCallOptions = { - accessToken: string; - endpoint: string; - startTime: Date; - endTime: Date; - page?: number; - extraQueryParams?: Record; +const dailyActivityQuery = ( + entity: DailyActivityEntity, + req: DailyActivityRequest, + extra: QueryParams = {}, +): QueryParams => { + const entityIds = req.entityIds ?? undefined; + const excludeEntityIds = req.excludeEntityIds; + const joinedEntityIds = entityIds && entityIds.length > 0 ? entityIds.join(",") : undefined; + const entityIdValue = entity === "user" ? entityIds?.[0] : joinedEntityIds; + const excludeParam = EXCLUDE_ENTITY_ID_QUERY_PARAM[entity]; + return { + start_date: formatDate(req.startTime), + end_date: formatDate(req.endTime), + model: req.model, + api_key: req.apiKey, + [ENTITY_ID_QUERY_PARAM[entity]]: entityIdValue, + ...(excludeParam + ? { [excludeParam]: excludeEntityIds && excludeEntityIds.length > 0 ? excludeEntityIds.join(",") : undefined } + : {}), + timezone: new Date().getTimezoneOffset().toString(), + include_current_utc_day: entity === "user" && req.includeCurrentUtcDay ? "true" : undefined, + ...extra, + }; }; -const fetchDailyActivity = async ({ - accessToken, - endpoint, - startTime, - endTime, - page = 1, - extraQueryParams, -}: DailyActivityCallOptions) => { - try { - const url = buildDailyActivityUrl(endpoint, startTime, endTime, page, extraQueryParams); - - const response = await fetch(url, { - method: "GET", - headers: { - [globalLitellmHeaderName]: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - }); - - if (!response.ok) { - const errorData = await response.json(); - const errorMessage = deriveErrorMessage(errorData); - handleError(errorMessage); - throw new Error(errorMessage); - } - - const data = await response.json(); - return data; - } catch (error) { - console.error(`Failed to fetch daily activity (${endpoint}):`, error); - throw error; - } -}; - -export const userDailyActivityCall = async ( - accessToken: string, - startTime: Date, - endTime: Date, - page: number = 1, - userId: string | null = null, - includeCurrentUtcDay: boolean = false, - apiKey: string | null = null, -) => { - /** - * Get daily user activity on proxy - */ - return fetchDailyActivity({ - accessToken, - endpoint: "/user/daily/activity", - startTime, - endTime, - page, - extraQueryParams: { - user_id: userId, - include_current_utc_day: includeCurrentUtcDay ? "true" : undefined, - api_key: apiKey, - }, +export const dailyActivityAggregatedCall = ( + entity: DailyActivityEntity, + req: DailyActivityRequest, +): Promise => + apiClient.get(`/${entity}/daily/activity/aggregated`, { + accessToken: req.accessToken, + query: dailyActivityQuery(entity, req, req.apiKeyLimit === undefined ? {} : { api_key_limit: req.apiKeyLimit }), }); -}; -export const tagDailyActivityCall = async ( - accessToken: string, - startTime: Date, - endTime: Date, - page: number = 1, - tags: string[] | null = null, -) => { - /** - * Get daily user activity on proxy - */ - return fetchDailyActivity({ - accessToken, - endpoint: "/tag/daily/activity", - startTime, - endTime, - page, - extraQueryParams: { - tags, - }, +export const dailyActivityKeyPageCall = ( + entity: DailyActivityEntity, + req: DailyActivityRequest, + offset: number, + limit: number, +): Promise => + apiClient.get(`/${entity}/daily/activity/aggregated/keys`, { + accessToken: req.accessToken, + query: dailyActivityQuery(entity, req, { offset, limit }), }); -}; -export const teamDailyActivityCall = async ( - accessToken: string, - startTime: Date, - endTime: Date, - page: number = 1, - teamIds: string[] | null = null, -) => { - /** - * Get daily user activity on proxy - */ - return fetchDailyActivity({ - accessToken, - endpoint: "/team/daily/activity", - startTime, - endTime, - page, - extraQueryParams: { - team_ids: teamIds, - exclude_team_ids: "litellm-dashboard", - }, +export const dailyActivityKeySearchCall = ( + entity: DailyActivityEntity, + req: DailyActivityRequest, + search: string, + limit?: number, +): Promise => + apiClient.get(`/${entity}/daily/activity/aggregated/search`, { + accessToken: req.accessToken, + query: dailyActivityQuery(entity, req, { search, ...(limit === undefined ? {} : { limit }) }), }); -}; -export const teamDailyActivityAggregatedCall = async ( - accessToken: string, - startTime: Date, - endTime: Date, - teamIds: string[] | null = null, -) => { - /** - * Get aggregated daily team activity with per-team breakdown (no pagination) - */ - try { - return await apiClient.get(`/team/daily/activity/aggregated`, { - accessToken, - query: { - start_date: formatDate(startTime), - end_date: formatDate(endTime), - timezone: new Date().getTimezoneOffset().toString(), - team_ids: teamIds && teamIds.length > 0 ? teamIds.join(",") : undefined, - exclude_team_ids: "litellm-dashboard", - }, - }); - } catch (error) { - console.error("Failed to fetch aggregated team daily activity:", error); - throw error; - } -}; +export const dailyActivityModelTopKeysCall = ( + entity: DailyActivityEntity, + req: DailyActivityRequest, + model: string, + byModelGroup: boolean, + limit?: number, +): Promise => + apiClient.get(`/${entity}/daily/activity/aggregated/model_top_keys`, { + accessToken: req.accessToken, + query: dailyActivityQuery(entity, req, { + model_group: model, + by_model_group: byModelGroup ? "true" : "false", + ...(limit === undefined ? {} : { limit }), + }), + }); + +export const dailyActivityExportCall = ( + entity: DailyActivityEntity, + req: DailyActivityRequest, + exportType: ExportType, + format: ExportFormat, +): Promise => + apiClient.getBlob(`/${entity}/daily/activity/export`, { + accessToken: req.accessToken, + query: dailyActivityQuery(entity, req, { export_type: exportType, format }), + }); + +export const cacheLeakageKeysCall = (req: DailyActivityRequest, limit?: number): Promise => + apiClient.get(`/user/daily/activity/aggregated/cache_leakage_keys`, { + accessToken: req.accessToken, + query: dailyActivityQuery("user", req, limit === undefined ? {} : { limit }), + }); export type TeamUserSpendResponse = components["schemas"]["TeamUserSpendResponse"]; @@ -1485,63 +1415,6 @@ export const teamSpendByUserCall = async ( }, }); -export const organizationDailyActivityCall = async ( - accessToken: string, - startTime: Date, - endTime: Date, - page: number = 1, - organizationIds: string[] | null = null, -) => { - return fetchDailyActivity({ - accessToken, - endpoint: "/organization/daily/activity", - startTime, - endTime, - page, - extraQueryParams: { - organization_ids: organizationIds, - }, - }); -}; - -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 agentDailyActivityCall = async ( - accessToken: string, - startTime: Date, - endTime: Date, - page: number = 1, - agentIds: string[] | null = null, -) => { - return fetchDailyActivity({ - accessToken, - endpoint: "/agent/daily/activity", - startTime, - endTime, - page, - extraQueryParams: { - agent_ids: agentIds, - }, - }); -}; - export const getOnboardingCredentials = async (inviteUUID: string) => { /** * Get all models on proxy @@ -2571,43 +2444,6 @@ export const keyAliasesCall = async ( } }; -export const userDailyActivityAggregatedCall = async ( - accessToken: string, - startTime: Date, - endTime: Date, - ...options: [userId?: string | null, includeCurrentUtcDay?: boolean, apiKey?: string | null] -) => { - /** - * Get aggregated daily user activity (no pagination) - */ - const [userId = null, includeCurrentUtcDay = false, apiKey = null] = options; - try { - const formatDate = (date: Date) => { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - return `${year}-${month}-${day}`; - }; - return await apiClient.get(`/user/daily/activity/aggregated`, { - accessToken, - query: { - start_date: formatDate(startTime), - end_date: formatDate(endTime), - timezone: new Date().getTimezoneOffset().toString(), - // Passed raw, matching the paginated caller: both serializers drop null and undefined, - // and both keep "". An empty filter must not vanish, or a request scoped to one user or - // key would silently widen into an unscoped, proxy-wide read. - user_id: userId, - include_current_utc_day: includeCurrentUtcDay ? "true" : undefined, - api_key: apiKey, - }, - }); - } catch (error) { - console.error("Failed to fetch aggregated user daily activity:", error); - throw error; - } -}; - export const gatewayDailyActivityCall = async (accessToken: string, startTime: Date, endTime: Date) => { /** * Get gateway request counts (SGR) recorded by the proxy middleware. diff --git a/ui/litellm-dashboard/src/components/shared/PaginationStatusAlerts.test.tsx b/ui/litellm-dashboard/src/components/shared/PaginationStatusAlerts.test.tsx deleted file mode 100644 index 93d464b6615..00000000000 --- a/ui/litellm-dashboard/src/components/shared/PaginationStatusAlerts.test.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; - -import PaginationStatusAlerts from "./PaginationStatusAlerts"; - -describe("PaginationStatusAlerts", () => { - it("shows page progress and wires the Stop button while fetching", () => { - const cancel = vi.fn(); - render( - , - ); - - expect(screen.getByText(/Currently fetching spend data: fetched 7 \/ 42 pages/)).toBeInTheDocument(); - fireEvent.click(screen.getByRole("button", { name: "Stop" })); - expect(cancel).toHaveBeenCalledTimes(1); - }); - - it("shows the partial-data notice after a cancel, frozen at the last fetched page", () => { - render( - , - ); - - expect(screen.getByText("Showing partial spend data (7/42 pages loaded)")).toBeInTheDocument(); - }); - - it("calls out a failed page as an error so partial totals do not read as final", () => { - render( - , - ); - - expect( - screen.getByText(/Fetching spend data failed, so the totals below cover only 7 of 42 pages of the range/), - ).toBeInTheDocument(); - }); - - it("does not claim a page loaded when the very first request is what failed", () => { - render( - , - ); - - expect(screen.getByText(/failed before any of it arrived/)).toBeInTheDocument(); - expect(screen.queryByText(/pages of the range/)).not.toBeInTheDocument(); - }); - - it("shows only the failure when a stopped fetch also failed", () => { - render( - , - ); - - expect(screen.getByText(/Fetching spend data failed/)).toBeInTheDocument(); - expect(screen.queryByText(/Showing partial spend data/)).not.toBeInTheDocument(); - }); - - it("names the subject it is fetching", () => { - render( - , - ); - - expect(screen.getByText(/Currently fetching agent data: fetched 1 \/ 3 pages/)).toBeInTheDocument(); - }); - - it("renders nothing when idle", () => { - const { container } = render( - , - ); - - expect(container).toBeEmptyDOMElement(); - }); -}); diff --git a/ui/litellm-dashboard/src/components/shared/PaginationStatusAlerts.tsx b/ui/litellm-dashboard/src/components/shared/PaginationStatusAlerts.tsx deleted file mode 100644 index e9357993dd0..00000000000 --- a/ui/litellm-dashboard/src/components/shared/PaginationStatusAlerts.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { ExternalLink, Loader2 } from "lucide-react"; - -import { Alert, AlertDescription } from "@/components/shared/Alert"; -import { Button } from "@/components/ui/button"; - -interface PaginationStatusAlertsProps { - isFetchingMore: boolean; - cancelled: boolean; - progress: { currentPage: number; totalPages: number }; - cancel: () => void; - subject?: string; - failed?: boolean; -} - -const failureMessage = (subject: string, progress: { currentPage: number; totalPages: number }) => - progress.currentPage === 0 - ? `Fetching ${subject} failed before any of it arrived, so the totals below are empty rather than final. Reload the page to try again.` - : `Fetching ${subject} failed, so the totals below cover only ${progress.currentPage} of ${progress.totalPages} pages of the range. Reload the page to try again.`; - -const PaginationStatusAlerts = ({ - isFetchingMore, - cancelled, - progress, - cancel, - subject = "spend data", - failed = false, -}: PaginationStatusAlertsProps) => ( - <> - {isFetchingMore && ( - - - - - Currently fetching {subject}: fetched {progress.currentPage} / {progress.totalPages} pages. Charts will - update periodically as data loads. Moving off of this page will stop and reset this. To continue using the - UI in the meantime,{" "} - - open a new tab - - . - - - - - )} - {failed && ( - - {failureMessage(subject, progress)} - - )} - {cancelled && !failed && ( - - - Showing partial {subject} ({progress.currentPage}/{progress.totalPages} pages loaded) - - - )} - -); - -export default PaginationStatusAlerts; diff --git a/ui/litellm-dashboard/src/components/shared/ScopedSavingsTab.tsx b/ui/litellm-dashboard/src/components/shared/ScopedSavingsTab.tsx index 7a0455f9d7e..514ecc40004 100644 --- a/ui/litellm-dashboard/src/components/shared/ScopedSavingsTab.tsx +++ b/ui/litellm-dashboard/src/components/shared/ScopedSavingsTab.tsx @@ -24,19 +24,19 @@ import { import { useScopedDailyActivityRange, type ActivityDateRange, - type DailyActivityScope, + type ScopedActivityInput, } from "@/app/(dashboard)/cost-optimization/_components/useDailyActivityRange"; interface ScopedSavingsTabProps { accessToken: string | null; - scope: DailyActivityScope; + scope: ScopedActivityInput; activity: ActivityDateRange; entityType: "key" | "user"; scopeNote?: string; } const ScopedSavingsTab = ({ accessToken, scope, activity, entityType, scopeNote }: ScopedSavingsTabProps) => { - const { dateValue, onDateChange, results, loading, isFetchingMore, failed, cancelled } = useScopedDailyActivityRange( + const { dateValue, onDateChange, results, loading, failed } = useScopedDailyActivityRange( accessToken, scope, activity, @@ -63,8 +63,8 @@ const ScopedSavingsTab = ({ accessToken, scope, activity, entityType, scopeNote .filter(Boolean) .join(" · "); - const isLoading = loading || isFetchingMore; - const unavailable = failed || cancelled; + const isLoading = loading; + const unavailable = failed; const showResults = !isLoading && !unavailable; const hasRows = results.length > 0; const showEmpty = !unavailable && (isLoading || !hasRows); diff --git a/ui/litellm-dashboard/src/components/templates/KeySavingsTab.integration.test.tsx b/ui/litellm-dashboard/src/components/templates/KeySavingsTab.integration.test.tsx index f6fea8ebe91..3567388de05 100644 --- a/ui/litellm-dashboard/src/components/templates/KeySavingsTab.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/KeySavingsTab.integration.test.tsx @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen } from "@testing-library/react"; import KeySavingsTab from "./KeySavingsTab"; import { DailyData, SpendMetrics } from "@/components/UsagePage/types"; +import { EMPTY_DAILY_ACTIVITY_METADATA } from "@/components/UsagePage/dailyActivityApi"; import * as useScopedDailyActivityRangeModule from "@/app/(dashboard)/cost-optimization/_components/useDailyActivityRange"; const metrics = (overrides: Partial): SpendMetrics => ({ @@ -36,12 +37,16 @@ const mockActivity = ( dateValue: { from: new Date("2025-01-01"), to: new Date("2025-01-31") }, onDateChange: vi.fn(), results: [] as DailyData[], + metadata: EMPTY_DAILY_ACTIVITY_METADATA, loading: false, - isFetchingMore: false, - progress: { currentPage: 1, totalPages: 1 }, - cancelled: false, failed: false, - cancel: vi.fn(), + scope: { + accessToken: "test-token", + startTime: new Date("2025-01-01"), + endTime: new Date("2025-01-31"), + userId: null, + apiKey: null, + }, ...overrides, }); diff --git a/ui/litellm-dashboard/src/lib/http/client.test.ts b/ui/litellm-dashboard/src/lib/http/client.test.ts index 0d765371e0c..3d28c154990 100644 --- a/ui/litellm-dashboard/src/lib/http/client.test.ts +++ b/ui/litellm-dashboard/src/lib/http/client.test.ts @@ -113,6 +113,25 @@ describe("createApiClient", () => { expect(init.headers).toEqual({ "Content-Type": "application/json" }); }); + it("getBlob returns the response body as a Blob on success", async () => { + const blob = new Blob(["csv,data"], { type: "text/csv" }); + const fetchImpl = vi.fn(async () => ({ ok: true, status: 200, blob: async () => blob }) as unknown as Response); + const client = createApiClient({ getBaseUrl: () => "https://proxy.example", fetchImpl }); + + const result = await client.getBlob("/user/daily/activity/export", { accessToken: "sk" }); + + expect(result).toBe(blob); + const [, blobInit] = fetchImpl.mock.calls[0] as unknown as [unknown, RequestInit]; + expect(blobInit.method).toBe("GET"); + }); + + it("getBlob throws ApiError on a non-2xx response", async () => { + const fetchImpl = vi.fn(async () => errorResponse(500, { error: "export failed" })); + const client = createApiClient({ getBaseUrl: () => "", fetchImpl }); + + await expect(client.getBlob("/user/daily/activity/export", { accessToken: "sk" })).rejects.toBeInstanceOf(ApiError); + }); + it("resolves the global fetch per call, so a swap after construction takes effect", async () => { const client = createApiClient({ getBaseUrl: () => "" }); diff --git a/ui/litellm-dashboard/src/lib/http/client.ts b/ui/litellm-dashboard/src/lib/http/client.ts index c78b2b10011..33d54c0fa28 100644 --- a/ui/litellm-dashboard/src/lib/http/client.ts +++ b/ui/litellm-dashboard/src/lib/http/client.ts @@ -111,6 +111,7 @@ export interface ApiClientConfig { export interface ApiClient { request(method: HttpMethod, path: string, options?: RequestOptions): Promise; get(path: string, options?: RequestOptions): Promise; + getBlob(path: string, options?: RequestOptions): Promise; post(path: string, options?: RequestOptions): Promise; put(path: string, options?: RequestOptions): Promise; delete(path: string, options?: RequestOptions): Promise; @@ -137,7 +138,7 @@ export function createApiClient(config: ApiClientConfig): ApiClient { const { getBaseUrl, getAuthHeaderName, onError, fetchImpl } = config; const doFetch: typeof fetch = (input, init) => (fetchImpl ?? fetch)(input, init); - async function request(method: HttpMethod, path: string, options: RequestOptions = {}): Promise { + async function fetchChecked(method: HttpMethod, path: string, options: RequestOptions = {}): Promise { const { accessToken, body, rawBody, query, headers: extraHeaders, signal, credentials } = options; const url = appendQuery(`${getBaseUrl()}${path}`, query); @@ -177,13 +178,24 @@ export function createApiClient(config: ApiClientConfig): ApiClient { throw new ApiError(message, response.status, errorBody); } + return response; + } + + async function request(method: HttpMethod, path: string, options: RequestOptions = {}): Promise { + const response = await fetchChecked(method, path, options); const text = await response.text(); return (text ? JSON.parse(text) : undefined) as T; } + async function getBlob(path: string, options: RequestOptions = {}): Promise { + const response = await fetchChecked("GET", path, options); + return response.blob(); + } + return { request, get: (path, options) => request("GET", path, options), + getBlob, post: (path, options) => request("POST", path, options), put: (path, options) => request("PUT", path, options), delete: (path, options) => request("DELETE", path, options),