From 61b3611b8c6667c8eae5dadc6036a6d95e897d04 Mon Sep 17 00:00:00 2001 From: yassin Date: Wed, 16 Sep 2026 09:22:20 +0000 Subject: [PATCH] fix(ui): block the global usage export when the aggregated key cap is reached Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../components/EntityUsage/EntityUsage.tsx | 2 +- .../_components/components/UsagePageView.tsx | 3 +- .../exportBlockedReason.test.ts | 37 ++++++++++++++++++- .../EntityUsageExport/exportBlockedReason.ts | 18 ++++++++- 4 files changed, 56 insertions(+), 4 deletions(-) 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..27a4163dff7 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 @@ -664,7 +664,7 @@ const EntityUsage: React.FC = ({ { key: "endpoints", label: "Endpoint Activity", content: }, ]; - const spendFetchState = { coversRange, cancelled, failed }; + const spendFetchState = { coversRange, cancelled, failed, apiKeyLimitReached: undefined }; return (
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 691c5dc839a..48322477fb4 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 @@ -30,7 +30,7 @@ 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 { getApiKeyLimitReached, getExportBlockedReason } from "@/components/EntityUsageExport/exportBlockedReason"; import KeyActivityPanel from "@/components/UsagePage/components/KeyActivityPanel"; import { Team } from "@/components/key_team_helpers/key_list"; import { @@ -256,6 +256,7 @@ const UsagePage: React.FC = ({ teams, organizations }) => { coversRange: activeAggregated !== null || paginatedResult.coversRange, cancelled: paginatedResult.cancelled, failed: paginatedResult.failed, + apiKeyLimitReached: getApiKeyLimitReached(userSpendData.results, userSpendData.metadata?.api_key_limit), }; const exportBlockedReason = getExportBlockedReason(spendFetchState); diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.test.ts b/ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.test.ts index e39b01a5dea..86783e2e4a0 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.test.ts +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.test.ts @@ -1,14 +1,24 @@ import { describe, expect, it } from "vitest"; -import { getExportBlockedReason, type UsageFetchState } from "./exportBlockedReason"; +import type { DailyData } from "@/components/UsagePage/types"; + +import { getApiKeyLimitReached, getExportBlockedReason, type UsageFetchState } from "./exportBlockedReason"; const state = (overrides: Partial = {}): UsageFetchState => ({ coversRange: true, cancelled: false, failed: false, + apiKeyLimitReached: undefined, ...overrides, }); +const dayWithKeys = (date: string, ...keys: string[]): DailyData => + ({ + date, + metrics: {}, + breakdown: { api_keys: Object.fromEntries(keys.map((k) => [k, { metrics: {}, metadata: {} }])) }, + }) as unknown as DailyData; + describe("getExportBlockedReason", () => { it("lets the export through once the data on screen covers the range", () => { expect(getExportBlockedReason(state())).toBeUndefined(); @@ -31,4 +41,29 @@ describe("getExportBlockedReason", () => { expect(reason).toMatch(/failed to load/i); expect(reason).not.toMatch(/stopped/i); }); + + it("blocks when the aggregated endpoint hit its key cap, since a per-team CSV would miss keys", () => { + const reason = getExportBlockedReason(state({ apiKeyLimitReached: 100 })); + + expect(reason).toMatch(/100 highest-spend keys/); + expect(reason).toMatch(/USAGE_TOP_API_KEYS_LIMIT/); + }); +}); + +describe("getApiKeyLimitReached", () => { + it("reports the cap once the distinct keys across every day reach it", () => { + const results = [dayWithKeys("2026-06-01", "key-1", "key-2"), dayWithKeys("2026-06-02", "key-2", "key-3")]; + + expect(getApiKeyLimitReached(results, 3)).toBe(3); + }); + + it("stays quiet while fewer keys than the cap came back, which means every key is on screen", () => { + const results = [dayWithKeys("2026-06-01", "key-1", "key-2"), dayWithKeys("2026-06-02", "key-2")]; + + expect(getApiKeyLimitReached(results, 3)).toBeUndefined(); + }); + + it("stays quiet when the response carries no cap, as the paginated fallback does", () => { + expect(getApiKeyLimitReached([dayWithKeys("2026-06-01", "key-1")], undefined)).toBeUndefined(); + }); }); diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.ts b/ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.ts index 71408ba8f3f..ca756090351 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.ts +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.ts @@ -1,13 +1,29 @@ +import type { DailyData } from "@/components/UsagePage/types"; + export interface UsageFetchState { coversRange: boolean; cancelled: boolean; failed: boolean; + apiKeyLimitReached: number | undefined; } -export const getExportBlockedReason = ({ coversRange, cancelled, failed }: UsageFetchState): string | undefined => { +export const getApiKeyLimitReached = (results: DailyData[], apiKeyLimit: unknown): number | undefined => { + if (typeof apiKeyLimit !== "number") return undefined; + const keys = new Set(results.flatMap((day) => Object.keys(day.breakdown.api_keys ?? {}))); + return keys.size >= apiKeyLimit ? apiKeyLimit : undefined; +}; + +export const getExportBlockedReason = ({ + coversRange, + cancelled, + failed, + apiKeyLimitReached, +}: 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."; + if (apiKeyLimitReached !== undefined) + return `Only the ${apiKeyLimitReached} highest-spend keys were loaded, so a per-team export would under-report. Raise USAGE_TOP_API_KEYS_LIMIT on the proxy to load more keys.`; return undefined; };