= ({
)}
{apiKeyTruncation !== undefined && (
- Only the {apiKeyTruncation.limit.toLocaleString()} highest-spend keys of{" "}
+ Only the {totalKeys.toLocaleString()} highest-spend keys of{" "}
{apiKeyTruncation.total.toLocaleString()} are loaded
)}
+ {loadMoreKeys !== undefined && (
+
+ )}
+ {loadMore.status === "loading" && (
+
+ Loading more keys...
+
+ )}
+ {loadMore.status === "error" && (
+
+ Loading more keys failed
+
+ )}
{noMatches ? (
diff --git a/ui/litellm-dashboard/src/components/UsagePage/keyPageMerge.test.ts b/ui/litellm-dashboard/src/components/UsagePage/keyPageMerge.test.ts
new file mode 100644
index 00000000000..5702161fad3
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/UsagePage/keyPageMerge.test.ts
@@ -0,0 +1,101 @@
+import { describe, expect, it } from "vitest";
+
+import { mergeKeyPage } from "./keyPageMerge";
+import type { DailyData, KeyMetricWithMetadata, MetricWithMetadata, SpendMetrics } from "./types";
+
+const metrics = (spend: number): SpendMetrics => ({
+ spend,
+ 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,
+});
+
+const keyEntry = (spend: number, alias: string): KeyMetricWithMetadata => ({
+ metrics: metrics(spend),
+ metadata: { key_alias: alias, team_id: null, user_id: null, user_email: null },
+});
+
+const modelEntry = (apiKeys: Record): MetricWithMetadata => ({
+ metrics: metrics(0),
+ metadata: {},
+ api_key_breakdown: apiKeys,
+});
+
+const day = (date: string, apiKeys: Record, model = "gpt-4"): DailyData => ({
+ date,
+ metrics: metrics(1),
+ breakdown: {
+ models: { [model]: modelEntry(apiKeys) },
+ model_groups: { "group-a": modelEntry(apiKeys) },
+ providers: { openai: modelEntry(apiKeys) },
+ mcp_servers: {},
+ api_keys: apiKeys,
+ entities: {},
+ endpoints: { "/v1/chat": modelEntry(apiKeys) },
+ },
+});
+
+const page = (results: DailyData[], next_cursor: string | null) => ({
+ results,
+ metadata: { total_spend: 42, total_api_keys: 3, next_cursor },
+});
+
+describe("mergeKeyPage", () => {
+ it("adds keys from the next page to the same day's breakdown", () => {
+ const prev = page([day("2026-01-01", { "hash-a": keyEntry(1, "a") })], "c1");
+ const next = page([day("2026-01-01", { "hash-b": keyEntry(0.5, "b") })], null);
+
+ const merged = mergeKeyPage(prev, next);
+
+ expect(Object.keys(merged.results[0].breakdown.api_keys)).toEqual(["hash-a", "hash-b"]);
+ expect(merged.results[0].breakdown.models["gpt-4"].api_key_breakdown).toHaveProperty("hash-b");
+ expect(merged.results[0].breakdown.providers.openai.api_key_breakdown).toHaveProperty("hash-b");
+ expect(merged.results[0].breakdown.endpoints?.["/v1/chat"].api_key_breakdown).toHaveProperty("hash-b");
+ });
+
+ it("lets the next page win when a key appears on both pages", () => {
+ const prev = page([day("2026-01-01", { "hash-a": keyEntry(1, "a") })], "c1");
+ const next = page([day("2026-01-01", { "hash-a": keyEntry(9, "a-new") })], null);
+
+ const merged = mergeKeyPage(prev, next);
+
+ expect(merged.results[0].breakdown.api_keys["hash-a"].metadata.key_alias).toBe("a-new");
+ expect(merged.results[0].breakdown.models["gpt-4"].api_key_breakdown["hash-a"].metrics.spend).toBe(9);
+ });
+
+ it("keeps prev metadata totals and takes next_cursor from next", () => {
+ const prev = page([day("2026-01-01", { "hash-a": keyEntry(1, "a") })], "c1");
+ const next = page([day("2026-01-01", { "hash-b": keyEntry(0.5, "b") })], null);
+
+ const merged = mergeKeyPage(prev, next);
+
+ expect(merged.metadata.total_spend).toBe(42);
+ expect(merged.metadata.total_api_keys).toBe(3);
+ expect(merged.metadata.next_cursor).toBeNull();
+ });
+
+ it("appends a day that only exists in the next page", () => {
+ const prev = page([day("2026-01-01", { "hash-a": keyEntry(1, "a") })], "c1");
+ const next = page([day("2026-01-02", { "hash-b": keyEntry(0.5, "b") })], "c2");
+
+ const merged = mergeKeyPage(prev, next);
+
+ expect(merged.results.map((d) => d.date)).toEqual(["2026-01-01", "2026-01-02"]);
+ expect(merged.metadata.next_cursor).toBe("c2");
+ });
+
+ it("does not mutate prev", () => {
+ const prev = page([day("2026-01-01", { "hash-a": keyEntry(1, "a") })], "c1");
+ const next = page([day("2026-01-01", { "hash-b": keyEntry(0.5, "b") })], null);
+
+ mergeKeyPage(prev, next);
+
+ expect(Object.keys(prev.results[0].breakdown.api_keys)).toEqual(["hash-a"]);
+ expect(prev.results[0].breakdown.models["gpt-4"].api_key_breakdown).not.toHaveProperty("hash-b");
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/UsagePage/keyPageMerge.ts b/ui/litellm-dashboard/src/components/UsagePage/keyPageMerge.ts
new file mode 100644
index 00000000000..0b117af31e2
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/UsagePage/keyPageMerge.ts
@@ -0,0 +1,53 @@
+import type { DailyData, MetricWithMetadata } from "./types";
+
+type AggregatedResponse = {
+ results: DailyData[];
+ metadata: M;
+};
+
+const BREAKDOWN_LISTS = ["models", "model_groups", "providers", "mcp_servers"] as const;
+
+function mergeEntryMap(
+ prev: { [key: string]: MetricWithMetadata } | undefined,
+ next: { [key: string]: MetricWithMetadata } | undefined,
+): { [key: string]: MetricWithMetadata } {
+ const merged: { [key: string]: MetricWithMetadata } = { ...prev };
+ for (const [name, nextEntry] of Object.entries(next ?? {})) {
+ const prevEntry = merged[name];
+ merged[name] = prevEntry
+ ? { ...prevEntry, api_key_breakdown: { ...prevEntry.api_key_breakdown, ...nextEntry.api_key_breakdown } }
+ : nextEntry;
+ }
+ return merged;
+}
+
+function mergeDay(prev: DailyData, next: DailyData): DailyData {
+ const breakdown = { ...prev.breakdown };
+ breakdown.api_keys = { ...prev.breakdown.api_keys, ...next.breakdown.api_keys };
+ for (const list of BREAKDOWN_LISTS) {
+ breakdown[list] = mergeEntryMap(prev.breakdown[list], next.breakdown[list]);
+ }
+ if (prev.breakdown.endpoints !== undefined || next.breakdown.endpoints !== undefined) {
+ breakdown.endpoints = mergeEntryMap(prev.breakdown.endpoints, next.breakdown.endpoints);
+ }
+ return { ...prev, breakdown };
+}
+
+export function mergeKeyPage(
+ prev: AggregatedResponse,
+ next: AggregatedResponse,
+): AggregatedResponse {
+ const mergedResults = [...prev.results];
+ for (const nextDay of next.results) {
+ const index = mergedResults.findIndex((day) => day.date === nextDay.date);
+ if (index === -1) {
+ mergedResults.push(nextDay);
+ } else {
+ mergedResults[index] = mergeDay(mergedResults[index], nextDay);
+ }
+ }
+ return {
+ results: mergedResults,
+ metadata: { ...prev.metadata, next_cursor: next.metadata?.next_cursor ?? null },
+ };
+}
diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx
index e6923a26d4c..53abbac20f9 100644
--- a/ui/litellm-dashboard/src/components/networking.tsx
+++ b/ui/litellm-dashboard/src/components/networking.tsx
@@ -1446,6 +1446,7 @@ export const teamDailyActivityAggregatedCall = async (
startTime: Date,
endTime: Date,
teamIds: string[] | null = null,
+ cursor: string | null = null,
) => {
/**
* Get aggregated daily team activity with per-team breakdown (no pagination)
@@ -1459,6 +1460,7 @@ export const teamDailyActivityAggregatedCall = async (
timezone: new Date().getTimezoneOffset().toString(),
team_ids: teamIds && teamIds.length > 0 ? teamIds.join(",") : undefined,
exclude_team_ids: "litellm-dashboard",
+ cursor: cursor ?? undefined,
},
});
} catch (error) {
@@ -2523,12 +2525,17 @@ export const userDailyActivityAggregatedCall = async (
accessToken: string,
startTime: Date,
endTime: Date,
- ...options: [userId?: string | null, includeCurrentUtcDay?: boolean, apiKey?: string | null]
+ ...options: [
+ userId?: string | null,
+ includeCurrentUtcDay?: boolean,
+ apiKey?: string | null,
+ cursor?: string | null,
+ ]
) => {
/**
* Get aggregated daily user activity (no pagination)
*/
- const [userId = null, includeCurrentUtcDay = false, apiKey = null] = options;
+ const [userId = null, includeCurrentUtcDay = false, apiKey = null, cursor = null] = options;
try {
const formatDate = (date: Date) => {
const year = date.getFullYear();
@@ -2548,6 +2555,7 @@ export const userDailyActivityAggregatedCall = async (
user_id: userId,
include_current_utc_day: includeCurrentUtcDay ? "true" : undefined,
api_key: apiKey,
+ cursor: cursor ?? undefined,
},
});
} catch (error) {
diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts
index 61a260be0fd..e960dcf987d 100644
--- a/ui/litellm-dashboard/src/lib/http/schema.d.ts
+++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts
@@ -15633,6 +15633,7 @@ export interface paths {
* api_key (Optional[str]): Filter by API key.
* exclude_team_ids (Optional[str]): Comma-separated list of team IDs to exclude.
* timezone (Optional[int]): Timezone offset in minutes from UTC, matching JavaScript's Date.getTimezoneOffset() convention.
+ * cursor (Optional[str]): Opaque cursor from a previous response's metadata.next_cursor; loads the next page of api_keys.
* Returns:
* SpendAnalyticsPaginatedResponse: Response containing all daily activity data for the range.
*/
@@ -28383,6 +28384,11 @@ export interface components {
* @default false
*/
has_more: boolean;
+ /**
+ * Next Cursor
+ * @description Opaque cursor for the next page of api_keys; None when every key is loaded
+ */
+ next_cursor?: string | null;
/**
* Page
* @default 1
@@ -62309,6 +62315,7 @@ export interface operations {
api_key?: string | null;
exclude_team_ids?: string | null;
timezone?: number | null;
+ cursor?: string | null;
};
header?: never;
path?: never;
@@ -64439,6 +64446,8 @@ export interface operations {
timezone?: number | null;
/** @description When the range ends on the caller's current local day, extend it to today's UTC bucket so spend written after the caller's local midnight (in UTC terms) is included. Requires the timezone parameter. Historical ranges are never extended. */
include_current_utc_day?: boolean;
+ /** @description Opaque cursor from a previous response's metadata.next_cursor; loads the next page of api_keys */
+ cursor?: string | null;
};
header?: never;
path?: never;