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 }) => (
+ onChange(["team-1"])}>Team Multi Select
+ ),
}));
// 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
-
- setIsGlobalExportModalOpen(true)}
- >
-
- Export Data
-
-
+ setIsGlobalExportModalOpen(true)}>
+
+ Export Data
+
{/* 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 ? (
- <>
-
-
- >
- ) : (
- <>
-
- Cancel
-
- handleExport()} disabled={isExporting}>
- {isExporting && }
- {isExporting ? "Exporting..." : `Export ${exportFormat.toUpperCase()}`}
-
- >
- )}
+
+ Cancel
+
+
+ {isExporting && }
+ {isExporting ? "Exporting..." : `Export ${exportFormat.toUpperCase()}`}
+
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 (
Export type
-
onChange(next as ExportScope)} className="gap-2">
+ onChange(next as ExportType)} className="gap-2">
{scopes.map((scope) => (
({
default: ({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) =>
@@ -16,16 +15,7 @@ vi.mock("./EntityUsageExportModal", () => ({
const defaultProps = {
dateValue: { from: new Date("2025-01-01"), to: new Date("2025-01-31") },
entityType: "team" as const,
- spendData: {
- results: [],
- metadata: {
- total_spend: 0,
- total_api_requests: 0,
- total_successful_requests: 0,
- total_failed_requests: 0,
- total_tokens: 0,
- },
- } satisfies EntitySpendData,
+ onExport: vi.fn().mockResolvedValue(new Blob(["data"])),
};
describe("UsageExportHeader", () => {
@@ -41,27 +31,6 @@ describe("UsageExportHeader", () => {
expect(screen.getByTestId("export-modal")).toBeInTheDocument();
});
- it("blocks the export while the data on screen does not cover the range", async () => {
- const user = userEvent.setup();
- renderWithProviders(
- ,
- );
-
- const exportButton = screen.getByRole("button", { name: /export data/i });
- expect(exportButton).toBeDisabled();
- await user.click(exportButton);
- expect(screen.queryByTestId("export-modal")).not.toBeInTheDocument();
- });
-
- it("explains why the export is blocked on hover", () => {
- renderWithProviders( );
-
- expect(screen.getByTitle("Spend data is still loading")).toBeInTheDocument();
- });
-
it("should close the export modal when onClose is called", async () => {
const user = userEvent.setup();
renderWithProviders( );
diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx b/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx
index 388a211d9bb..8f31a1cd642 100644
--- a/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx
+++ b/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx
@@ -16,13 +16,13 @@ import {
useComboboxAnchor,
} from "@/components/ui/combobox";
import EntityUsageExportModal from "./EntityUsageExportModal";
-import type { EntitySpendData, EntityType } from "./types";
+import type { EntityType, ExportFormat, ExportType } from "./types";
import type { Team } from "@/components/key_team_helpers/key_list";
interface UsageExportHeaderProps {
dateValue: DateRangePickerValue;
entityType: EntityType;
- spendData: EntitySpendData;
+ onExport: (exportType: ExportType, format: ExportFormat) => Promise;
// Optional filter props
showFilters?: boolean;
filterLabel?: string;
@@ -34,13 +34,12 @@ interface UsageExportHeaderProps {
customTitle?: string;
compactLayout?: boolean;
teams?: Team[];
- exportBlockedReason?: string;
}
const UsageExportHeader: React.FC = ({
dateValue,
entityType,
- spendData,
+ onExport,
showFilters = false,
filterLabel,
filterPlaceholder,
@@ -51,7 +50,6 @@ const UsageExportHeader: React.FC = ({
customTitle,
compactLayout = false,
teams = [],
- exportBlockedReason,
}) => {
const anchor = useComboboxAnchor();
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
@@ -123,12 +121,10 @@ const UsageExportHeader: React.FC = ({
)}
-
- setIsExportModalOpen(true)}>
-
- Export Data
-
-
+ setIsExportModalOpen(true)}>
+
+ Export Data
+
@@ -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.{" "}
+
+ Retry
+
+
+ );
+ }
+ 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.{" "}
+
+ Retry
+
+
+ );
+ 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 = (
+
+ Retry search
+
+ );
+ 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.{" "}
+
+ Retry
+
+
+ )}
+ {!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.{" "}
+ {
+ setSettled(null);
+ setRetryToken((token) => token + 1);
+ }}
+ >
+ Retry
+
+
+
+
+ );
+ }
+
+ 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
-
- .
-
-
- Stop
-
-
-
- )}
- {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),