From 3d805e5166f89653d9e97a793266b45e6386844a Mon Sep 17 00:00:00 2001
From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Fri, 11 Sep 2026 14:14:15 +0000
Subject: [PATCH 1/6] fix(ui): show user attribution in Top Virtual Keys usage
tables
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
.../EntityUsage/EntityUsage.test.tsx | 15 ++++--
.../EntityUsage/entityUsageAggregations.ts | 6 ++-
.../_components/components/UsagePageView.tsx | 5 +-
.../EntityUsage/TopKeyView.test.tsx | 48 +++++++++++++++++--
.../components/EntityUsage/TopKeyView.tsx | 17 ++++++-
.../tests/top_key_view.test.tsx | 22 +++++++--
6 files changed, 96 insertions(+), 17 deletions(-)
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 5846a63bc70..a483db82d3c 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
@@ -44,10 +44,12 @@ vi.mock("../EndpointUsage/EndpointUsage", () => ({
}));
vi.mock("@/components/UsagePage/components/EntityUsage/TopKeyView", () => ({
- default: ({ topKeys }: { topKeys: { api_key: string; spend: number }[] }) => (
+ default: ({ topKeys }: { topKeys: { api_key: string; user_email: string | null; spend: number }[] }) => (
Top Keys
- {`top-keys:${topKeys.map((row) => `${row.api_key}=${row.spend}`).join("|")}`}
+
+ {`top-keys:${topKeys.map((row) => `${row.api_key}=${row.spend}=${row.user_email ?? "-"}`).join("|")}`}
+
),
}));
@@ -1099,7 +1101,12 @@ describe("EntityUsage", () => {
breakdown: {
...mockSpendData.results[0].breakdown,
model_groups: { "gpt-4o": { metrics: { ...usageMetrics, spend: 70.25 }, metadata: {} } },
- api_keys: { "sk-abc": { metrics: usageMetrics, metadata: { key_alias: "prod-key", team_id: null } } },
+ api_keys: {
+ "sk-abc": {
+ metrics: usageMetrics,
+ metadata: { key_alias: "prod-key", team_id: null, user_email: "alice@example.com" },
+ },
+ },
},
},
],
@@ -1108,7 +1115,7 @@ describe("EntityUsage", () => {
render();
await waitFor(() => {
- expect(screen.getByText("top-keys:sk-abc=30.75")).toBeInTheDocument();
+ expect(screen.getByText("top-keys:sk-abc=30.75=alice@example.com")).toBeInTheDocument();
});
expect(screen.getByText("top-models:gpt-4o=70.25")).toBeInTheDocument();
expect(screen.getByText(/^top-models:Code Review Agent=/)).toBeInTheDocument();
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/entityUsageAggregations.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/entityUsageAggregations.ts
index d482a5576ae..60b9c2b8e4d 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/entityUsageAggregations.ts
+++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/entityUsageAggregations.ts
@@ -1,4 +1,5 @@
import { keyActivityLabel } from "@/components/UsagePage/keyActivityLabel";
+import type { TopKeyItem } from "@/components/UsagePage/components/EntityUsage/TopKeyView";
import { BreakdownMetrics, DailyData, KeyMetricWithMetadata, TagUsage } from "@/components/UsagePage/types";
export type ExtendedDailyData = DailyData & {
@@ -85,7 +86,7 @@ export const getTopAgents = (results: ExtendedDailyData[], topAgentsLimit: numbe
.slice(0, topAgentsLimit);
};
-export const getTopAPIKeys = (results: ExtendedDailyData[], topKeysLimit: number) => {
+export const getTopAPIKeys = (results: ExtendedDailyData[], topKeysLimit: number): TopKeyItem[] => {
const keySpend: { [key: string]: KeyMetricWithMetadata } = {};
results.forEach((day) => {
const { breakdown } = day;
@@ -140,7 +141,8 @@ export const getTopAPIKeys = (results: ExtendedDailyData[], topKeysLimit: number
.map(([api_key, metrics]) => ({
api_key,
key_alias: keyActivityLabel(metrics.metadata),
- tags: metrics.metadata.tags || "-",
+ user_email: metrics.metadata.user_email ?? null,
+ tags: metrics.metadata.tags || [],
spend: metrics.metrics.spend,
}))
.sort((a, b) => b.spend - a.spend)
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 a9ab0f17f40..0e32cbed9c5 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
@@ -63,7 +63,7 @@ import EntityUsage, { EntityList } from "./EntityUsage/EntityUsage";
import ModelViewToggle, { ModelViewType } from "./ModelViewToggle";
import SpendByProvider from "./EntityUsage/SpendByProvider";
import { TOP_MODEL_LIMITS } from "./EntityUsage/TopModelView";
-import TopKeyView from "@/components/UsagePage/components/EntityUsage/TopKeyView";
+import TopKeyView, { type TopKeyItem } from "@/components/UsagePage/components/EntityUsage/TopKeyView";
import UsageAIChatPanel from "./UsageAIChatPanel";
import { UsageOption, UsageViewSelect } from "./UsageViewSelect/UsageViewSelect";
@@ -422,7 +422,7 @@ const UsagePage: React.FC = ({ teams, organizations }) => {
}, [userSpendData.results]);
// Calculate top API keys from the breakdown data
- const topKeys = useMemo(() => {
+ const topKeys = useMemo(() => {
const keySpend: { [key: string]: KeyMetricWithMetadata } = {};
userSpendData.results.forEach((day) => {
Object.entries(day.breakdown.api_keys || {}).forEach(([key, metrics]) => {
@@ -463,6 +463,7 @@ const UsagePage: React.FC = ({ teams, organizations }) => {
.map(([api_key, metrics]) => ({
api_key,
key_alias: keyActivityLabel(metrics.metadata),
+ user_email: metrics.metadata.user_email ?? null,
tags: metrics.metadata.tags || [],
spend: metrics.metrics.spend,
}))
diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.test.tsx
index c2837cf412e..88adedf022a 100644
--- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.test.tsx
+++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.test.tsx
@@ -102,6 +102,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "Test Key",
+ user_email: null,
spend: 100,
tags: [
{ tag: "tag-1", usage: 50 },
@@ -118,6 +119,25 @@ describe("TopKeyView", () => {
expect(screen.getByText("$100.00")).toBeInTheDocument();
});
+ it("should display user attribution when the key has no alias", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("User")).toBeInTheDocument();
+ expect(screen.getByText("alice@example.com")).toBeInTheDocument();
+ });
+
it("should switch to chart view when chart view button is clicked", async () => {
const user = userEvent.setup();
render();
@@ -142,6 +162,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "A Very Long Key Alias",
+ user_email: null,
spend: 100,
},
]}
@@ -197,6 +218,7 @@ describe("TopKeyView", () => {
{
api_key: "sk-1234567890abcdef",
key_alias: "Test Key",
+ user_email: null,
spend: 100,
},
]}
@@ -215,12 +237,13 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "",
+ user_email: null,
spend: 100,
},
]}
/>,
);
- expect(screen.getByText("-")).toBeInTheDocument();
+ expect(screen.getAllByText("-")).toHaveLength(2);
});
it("should format spend values with two decimal places", () => {
@@ -231,6 +254,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "Test Key",
+ user_email: null,
spend: 123.456,
},
]}
@@ -247,6 +271,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "Test Key",
+ user_email: null,
spend: 0.004,
},
]}
@@ -263,12 +288,13 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "Test Key",
+ user_email: null,
spend: 0,
},
]}
/>,
);
- expect(screen.getByText("-")).toBeInTheDocument();
+ expect(screen.getAllByText("-")).toHaveLength(2);
expect(screen.queryByText("$0.00")).not.toBeInTheDocument();
});
@@ -280,6 +306,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "Test Key",
+ user_email: null,
spend: 100,
tags: [],
},
@@ -298,6 +325,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "Test Key",
+ user_email: null,
spend: 100,
},
]}
@@ -315,6 +343,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "Test Key",
+ user_email: null,
spend: 100,
tags: [
{ tag: "tag-1", usage: 50 },
@@ -340,6 +369,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "Test Key",
+ user_email: null,
spend: 100,
tags: [
{ tag: "tag-1", usage: 50 },
@@ -367,6 +397,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "Test Key",
+ user_email: null,
spend: 100,
tags: [
{ tag: "tag-1", usage: 50 },
@@ -404,6 +435,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "Test Key",
+ user_email: null,
spend: 100,
},
]}
@@ -438,6 +470,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "Test Key",
+ user_email: null,
spend: 100,
},
]}
@@ -475,6 +508,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "Test Key",
+ user_email: null,
spend: 100,
},
]}
@@ -511,6 +545,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "Test Key",
+ user_email: null,
spend: 100,
},
]}
@@ -550,6 +585,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "Test Key",
+ user_email: null,
spend: 100,
},
]}
@@ -580,6 +616,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "Test Key",
+ user_email: null,
spend: 100,
},
]}
@@ -610,6 +647,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "Test Key",
+ user_email: null,
spend: 100,
tags: [
{ tag: "tag-low", usage: 10 },
@@ -643,6 +681,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "This is a very long key alias",
+ user_email: null,
spend: 100,
},
]}
@@ -658,12 +697,13 @@ describe("TopKeyView", () => {
topKeys={[
{
api_key: "key-123",
- key_alias: null,
+ key_alias: "",
+ user_email: null,
spend: 100,
},
]}
/>,
);
- expect(screen.getByText("-")).toBeInTheDocument();
+ expect(screen.getAllByText("-")).toHaveLength(2);
});
});
diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx
index df1a51d8e38..7701721ac32 100644
--- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx
+++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx
@@ -15,8 +15,16 @@ import { TagUsage } from "../../types";
const TOP_KEYS_LIMITS = [5, 10, 25, 50] as const;
+export interface TopKeyItem {
+ api_key: string;
+ key_alias: string;
+ user_email: string | null;
+ tags?: TagUsage[] | null;
+ spend: number;
+}
+
interface TopKeyViewProps {
- topKeys: any[];
+ topKeys: TopKeyItem[];
teams: any[] | null;
showTags?: boolean;
topKeysLimit: number;
@@ -43,7 +51,7 @@ const TopKeyView: React.FC = ({ topKeys, teams, showTags = fals
});
};
- const handleKeyClick = async (item: any) => {
+ const handleKeyClick = async (item: TopKeyItem) => {
if (!accessToken) return;
try {
@@ -95,6 +103,11 @@ const TopKeyView: React.FC = ({ topKeys, teams, showTags = fals
accessorKey: "key_alias",
cell: (info: any) => info.getValue() || "-",
},
+ {
+ header: "User",
+ accessorKey: "user_email",
+ cell: (info: any) => info.getValue() || "-",
+ },
];
const tagsColumn = {
diff --git a/ui/litellm-dashboard/tests/top_key_view.test.tsx b/ui/litellm-dashboard/tests/top_key_view.test.tsx
index 51662b8f453..a105bec50e2 100644
--- a/ui/litellm-dashboard/tests/top_key_view.test.tsx
+++ b/ui/litellm-dashboard/tests/top_key_view.test.tsx
@@ -28,12 +28,15 @@ describe("TopKeyView", () => {
teams: null,
premiumUser: true,
showTags: false,
+ topKeysLimit: 5,
+ setTopKeysLimit: vi.fn(),
};
const mockKeysWithTags = [
{
api_key: "key-1",
key_alias: "Production Key",
+ user_email: null,
tags: [
{ tag: "production", usage: 0.005 } as TagUsage, // <$0.01
{ tag: "high-volume", usage: 125.5 } as TagUsage, // High spend
@@ -44,6 +47,7 @@ describe("TopKeyView", () => {
{
api_key: "key-2",
key_alias: "Staging Key",
+ user_email: null,
tags: [
{ tag: "staging", usage: 45.75 } as TagUsage, // Medium spend
{ tag: "testing", usage: 0.008 } as TagUsage, // <$0.01
@@ -54,6 +58,7 @@ describe("TopKeyView", () => {
{
api_key: "key-3",
key_alias: "Development Key",
+ user_email: null,
tags: [
{ tag: "dev", usage: 0.002 } as TagUsage, // <$0.01
{ tag: "experimental", usage: 0.001 } as TagUsage, // <$0.01
@@ -65,11 +70,15 @@ describe("TopKeyView", () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseAuthorized.mockReturnValue({
+ isLoading: false,
+ isAuthorized: true,
token: "mock-token",
accessToken: mockProps.accessToken,
userId: mockProps.userID,
userEmail: "test@example.com",
userRole: mockProps.userRole,
+ userRoleLabel: mockProps.userRole,
+ isViewOnly: false,
premiumUser: mockProps.premiumUser,
disabledPersonalKeyCreation: false,
showSSOBanner: false,
@@ -181,13 +190,14 @@ describe("TopKeyView", () => {
{
api_key: "key-no-tags",
key_alias: "No Tags Key",
+ user_email: null,
tags: [],
spend: 10.0,
},
];
renderWithProviders();
- expect(screen.getByText("-")).toBeInTheDocument();
+ expect(screen.getAllByText("-")).toHaveLength(2);
});
it("should handle keys with undefined tags", () => {
@@ -195,13 +205,14 @@ describe("TopKeyView", () => {
{
api_key: "key-undefined-tags",
key_alias: "Undefined Tags Key",
+ user_email: null,
tags: undefined,
spend: 5.0,
},
];
renderWithProviders();
- expect(screen.getByText("-")).toBeInTheDocument();
+ expect(screen.getAllByText("-")).toHaveLength(2);
});
it("should handle keys with null tags", () => {
@@ -209,13 +220,14 @@ describe("TopKeyView", () => {
{
api_key: "key-null-tags",
key_alias: "Null Tags Key",
+ user_email: null,
tags: null,
spend: 3.0,
},
];
renderWithProviders();
- expect(screen.getByText("-")).toBeInTheDocument();
+ expect(screen.getAllByText("-")).toHaveLength(2);
});
});
@@ -225,6 +237,7 @@ describe("TopKeyView", () => {
{
api_key: "key-long-tags",
key_alias: "Long Tags Key",
+ user_email: null,
tags: [{ tag: "very-long-tag-name", usage: 10.0 } as TagUsage, { tag: "short", usage: 5.0 } as TagUsage],
spend: 15.0,
},
@@ -245,12 +258,14 @@ describe("TopKeyView", () => {
{
api_key: "key-mixed-1",
key_alias: "Mixed Key 1",
+ user_email: null,
tags: [{ tag: "expensive", usage: 999.99 } as TagUsage, { tag: "cheap", usage: 0.001 } as TagUsage],
spend: 1000.0,
},
{
api_key: "key-mixed-2",
key_alias: "Mixed Key 2",
+ user_email: null,
tags: [{ tag: "moderate", usage: 50.0 } as TagUsage, { tag: "tiny", usage: 0.005 } as TagUsage],
spend: 50.01,
},
@@ -292,6 +307,7 @@ describe("TopKeyView", () => {
{
api_key: "test-key-123",
key_alias: "Test Key",
+ user_email: null,
tags: [],
spend: 25.5,
},
From e93fe6051211e47ea05af976ab9d62d0dcdc0255 Mon Sep 17 00:00:00 2001
From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Wed, 16 Sep 2026 01:31:38 +0000
Subject: [PATCH 2/6] fix(ui): hide Top Virtual Keys user column when rows
carry no user
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
.../EntityUsage/TopKeyView.test.tsx | 28 +++++++++++++++----
.../components/EntityUsage/TopKeyView.tsx | 18 +++++++-----
.../tests/top_key_view.test.tsx | 6 ++--
3 files changed, 36 insertions(+), 16 deletions(-)
diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.test.tsx
index 88adedf022a..fc8f7a14626 100644
--- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.test.tsx
+++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.test.tsx
@@ -119,8 +119,24 @@ describe("TopKeyView", () => {
expect(screen.getByText("$100.00")).toBeInTheDocument();
});
- it("should display user attribution when the key has no alias", () => {
- render(
+ it("should render User column only when a row has user attribution", () => {
+ const { rerender } = render(
+ ,
+ );
+
+ expect(screen.queryByText("User")).not.toBeInTheDocument();
+
+ rerender(
{
]}
/>,
);
- expect(screen.getAllByText("-")).toHaveLength(2);
+ expect(screen.getAllByText("-")).toHaveLength(1);
});
it("should format spend values with two decimal places", () => {
@@ -294,7 +310,7 @@ describe("TopKeyView", () => {
]}
/>,
);
- expect(screen.getAllByText("-")).toHaveLength(2);
+ expect(screen.getAllByText("-")).toHaveLength(1);
expect(screen.queryByText("$0.00")).not.toBeInTheDocument();
});
@@ -697,13 +713,13 @@ describe("TopKeyView", () => {
topKeys={[
{
api_key: "key-123",
- key_alias: "",
+ key_alias: null,
user_email: null,
spend: 100,
},
]}
/>,
);
- expect(screen.getAllByText("-")).toHaveLength(2);
+ expect(screen.getAllByText("-")).toHaveLength(1);
});
});
diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx
index 7701721ac32..c59d7fe5c8d 100644
--- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx
+++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx
@@ -17,8 +17,8 @@ const TOP_KEYS_LIMITS = [5, 10, 25, 50] as const;
export interface TopKeyItem {
api_key: string;
- key_alias: string;
- user_email: string | null;
+ key_alias: string | null;
+ user_email?: string | null;
tags?: TagUsage[] | null;
spend: number;
}
@@ -103,11 +103,15 @@ const TopKeyView: React.FC = ({ topKeys, teams, showTags = fals
accessorKey: "key_alias",
cell: (info: any) => info.getValue() || "-",
},
- {
- header: "User",
- accessorKey: "user_email",
- cell: (info: any) => info.getValue() || "-",
- },
+ ...(topKeys.some((k) => k.user_email)
+ ? [
+ {
+ header: "User",
+ accessorKey: "user_email",
+ cell: (info: any) => info.getValue() || "-",
+ },
+ ]
+ : []),
];
const tagsColumn = {
diff --git a/ui/litellm-dashboard/tests/top_key_view.test.tsx b/ui/litellm-dashboard/tests/top_key_view.test.tsx
index a105bec50e2..073017d5cd5 100644
--- a/ui/litellm-dashboard/tests/top_key_view.test.tsx
+++ b/ui/litellm-dashboard/tests/top_key_view.test.tsx
@@ -197,7 +197,7 @@ describe("TopKeyView", () => {
];
renderWithProviders();
- expect(screen.getAllByText("-")).toHaveLength(2);
+ expect(screen.getAllByText("-")).toHaveLength(1);
});
it("should handle keys with undefined tags", () => {
@@ -212,7 +212,7 @@ describe("TopKeyView", () => {
];
renderWithProviders();
- expect(screen.getAllByText("-")).toHaveLength(2);
+ expect(screen.getAllByText("-")).toHaveLength(1);
});
it("should handle keys with null tags", () => {
@@ -227,7 +227,7 @@ describe("TopKeyView", () => {
];
renderWithProviders();
- expect(screen.getAllByText("-")).toHaveLength(2);
+ expect(screen.getAllByText("-")).toHaveLength(1);
});
});
From 88799f6f80671ab1bf8d5cc7ffb4c25f306e2018 Mon Sep 17 00:00:00 2001
From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Thu, 17 Sep 2026 22:32:46 +0000
Subject: [PATCH 3/6] fix(ui): fall back to user id in Top Virtual Keys user
column
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
.../test_common_daily_activity.py | 18 +++++
.../EntityUsage/EntityUsage.test.tsx | 71 ++++++++++++++++++-
.../EntityUsage/entityUsageAggregations.ts | 53 +++++++++++++-
.../_components/components/UsagePageView.tsx | 56 ++-------------
.../EntityUsage/TopKeyView.test.tsx | 65 +++++++++++------
.../components/EntityUsage/TopKeyView.tsx | 6 +-
.../tests/top_key_view.test.tsx | 20 +++---
7 files changed, 200 insertions(+), 89 deletions(-)
diff --git a/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py b/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py
index fc3ede88aa9..e11f0c37afd 100644
--- a/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py
+++ b/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py
@@ -643,6 +643,24 @@ def test_key_metadata_includes_recovered_user_email():
assert meta.user_email == "alice@example.com"
+def test_key_metadata_includes_user_id_without_user_email():
+ from litellm.proxy.management_endpoints.common_daily_activity import _key_metadata
+
+ meta = _key_metadata(
+ {
+ "dirty-key": {
+ "key_alias": "batch-worker",
+ "team_id": "team-1",
+ "user_id": "user-123",
+ }
+ },
+ "dirty-key",
+ )
+
+ assert meta.user_id == "user-123"
+ assert meta.user_email is None
+
+
def test_update_breakdown_metrics_includes_user_email():
from litellm.proxy.management_endpoints.common_daily_activity import update_breakdown_metrics
from litellm.types.proxy.management_endpoints.common_daily_activity import BreakdownMetrics
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 a483db82d3c..9807a0056ad 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,39 @@ 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 EntityUsage from "./EntityUsage";
+import { getGlobalTopKeys, getTopAPIKeys } from "./entityUsageAggregations";
+
+const emptySpendMetrics: SpendMetrics = {
+ 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,
+};
+
+const createKeyMetrics = (spend: number, metadata: KeyMetadata): KeyMetricWithMetadata => ({
+ metrics: { ...emptySpendMetrics, spend },
+ metadata,
+});
+
+const createDailyData = (date: string, apiKeys: Record): DailyData => ({
+ date,
+ metrics: { ...emptySpendMetrics },
+ breakdown: {
+ models: {},
+ model_groups: {},
+ mcp_servers: {},
+ providers: {},
+ api_keys: apiKeys,
+ entities: {},
+ },
+});
beforeAll(() => {
if (typeof window !== "undefined" && !window.ResizeObserver) {
@@ -44,11 +76,11 @@ vi.mock("../EndpointUsage/EndpointUsage", () => ({
}));
vi.mock("@/components/UsagePage/components/EntityUsage/TopKeyView", () => ({
- default: ({ topKeys }: { topKeys: { api_key: string; user_email: string | null; spend: number }[] }) => (
+ default: ({ topKeys }: { topKeys: { api_key: string; user?: string | null; spend: number }[] }) => (
Top Keys
- {`top-keys:${topKeys.map((row) => `${row.api_key}=${row.spend}=${row.user_email ?? "-"}`).join("|")}`}
+ {`top-keys:${topKeys.map((row) => `${row.api_key}=${row.spend}=${row.user ?? "-"}`).join("|")}`}
),
@@ -433,6 +465,41 @@ describe("EntityUsage", () => {
);
});
+ describe("top key aggregations", () => {
+ it("sums, sorts, limits, and carries email attribution for global top keys", () => {
+ const results = [
+ createDailyData("2025-01-01", {
+ "key-low": createKeyMetrics(10, { key_alias: "Low", team_id: null, user_email: "low@example.com" }),
+ "key-high": createKeyMetrics(25, { key_alias: "High", team_id: null, user_email: "high@example.com" }),
+ }),
+ createDailyData("2025-01-02", {
+ "key-low": createKeyMetrics(30, { key_alias: "Low", team_id: null, user_email: "low@example.com" }),
+ }),
+ ];
+
+ expect(getGlobalTopKeys(results, 1)).toEqual([
+ {
+ api_key: "key-low",
+ key_alias: "Low",
+ user: "low@example.com",
+ tags: [],
+ spend: 40,
+ },
+ ]);
+ });
+
+ it("falls back to user ID attribution for global and entity top keys", () => {
+ const results = [
+ createDailyData("2025-01-01", {
+ "key-123": createKeyMetrics(12.5, { key_alias: "User ID key", team_id: null, user_id: "user-123" }),
+ }),
+ ];
+
+ expect(getGlobalTopKeys(results, 5)[0]?.user).toBe("user-123");
+ expect(getTopAPIKeys(results, 5)[0]?.user).toBe("user-123");
+ });
+ });
+
it("should render with tag entity type and display spend metrics", async () => {
render();
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/entityUsageAggregations.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/entityUsageAggregations.ts
index 60b9c2b8e4d..eaa462cfb60 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/entityUsageAggregations.ts
+++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/entityUsageAggregations.ts
@@ -86,6 +86,56 @@ export const getTopAgents = (results: ExtendedDailyData[], topAgentsLimit: numbe
.slice(0, topAgentsLimit);
};
+export const getGlobalTopKeys = (results: DailyData[], topKeysLimit: number): TopKeyItem[] => {
+ const keySpend: { [key: string]: KeyMetricWithMetadata } = {};
+ results.forEach((day) => {
+ Object.entries(day.breakdown.api_keys || {}).forEach(([key, metrics]) => {
+ if (!keySpend[key]) {
+ keySpend[key] = {
+ 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: {
+ key_alias: metrics.metadata.key_alias,
+ team_id: null,
+ user_id: metrics.metadata.user_id,
+ user_email: metrics.metadata.user_email,
+ tags: metrics.metadata.tags || [],
+ },
+ };
+ }
+ keySpend[key].metrics.spend += metrics.metrics.spend;
+ keySpend[key].metrics.prompt_tokens += metrics.metrics.prompt_tokens;
+ keySpend[key].metrics.completion_tokens += metrics.metrics.completion_tokens;
+ keySpend[key].metrics.total_tokens += metrics.metrics.total_tokens;
+ keySpend[key].metrics.api_requests += metrics.metrics.api_requests;
+ keySpend[key].metrics.successful_requests += metrics.metrics.successful_requests;
+ keySpend[key].metrics.failed_requests += metrics.metrics.failed_requests;
+ keySpend[key].metrics.cache_read_input_tokens += metrics.metrics.cache_read_input_tokens || 0;
+ keySpend[key].metrics.cache_creation_input_tokens += metrics.metrics.cache_creation_input_tokens || 0;
+ });
+ });
+
+ return Object.entries(keySpend)
+ .map(([api_key, metrics]) => ({
+ api_key,
+ key_alias: keyActivityLabel(metrics.metadata),
+ user: metrics.metadata.user_email ?? metrics.metadata.user_id ?? null,
+ tags: metrics.metadata.tags || [],
+ spend: metrics.metrics.spend,
+ }))
+ .sort((a, b) => b.spend - a.spend)
+ .slice(0, topKeysLimit);
+};
+
export const getTopAPIKeys = (results: ExtendedDailyData[], topKeysLimit: number): TopKeyItem[] => {
const keySpend: { [key: string]: KeyMetricWithMetadata } = {};
results.forEach((day) => {
@@ -120,6 +170,7 @@ export const getTopAPIKeys = (results: ExtendedDailyData[], topKeysLimit: number
metadata: {
key_alias: metrics.metadata.key_alias,
team_id: metrics.metadata.team_id || null,
+ user_id: metrics.metadata.user_id,
user_email: metrics.metadata.user_email,
tags: tagDictionary[key] || [],
},
@@ -141,7 +192,7 @@ export const getTopAPIKeys = (results: ExtendedDailyData[], topKeysLimit: number
.map(([api_key, metrics]) => ({
api_key,
key_alias: keyActivityLabel(metrics.metadata),
- user_email: metrics.metadata.user_email ?? null,
+ user: metrics.metadata.user_email ?? metrics.metadata.user_id ?? null,
tags: metrics.metadata.tags || [],
spend: metrics.metrics.spend,
}))
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 0e32cbed9c5..228d8acf146 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
@@ -46,8 +46,7 @@ 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 { keyActivityLabel } from "@/components/UsagePage/keyActivityLabel";
-import { DailyData, KeyMetricWithMetadata, MetricWithMetadata } from "@/components/UsagePage/types";
+import { DailyData, MetricWithMetadata } from "@/components/UsagePage/types";
import { valueFormatterSpend } from "@/components/UsagePage/utils/value_formatters";
import {
fetchedRangeKey,
@@ -64,6 +63,7 @@ import ModelViewToggle, { ModelViewType } from "./ModelViewToggle";
import SpendByProvider from "./EntityUsage/SpendByProvider";
import { TOP_MODEL_LIMITS } from "./EntityUsage/TopModelView";
import TopKeyView, { type TopKeyItem } from "@/components/UsagePage/components/EntityUsage/TopKeyView";
+import { getGlobalTopKeys } from "./EntityUsage/entityUsageAggregations";
import UsageAIChatPanel from "./UsageAIChatPanel";
import { UsageOption, UsageViewSelect } from "./UsageViewSelect/UsageViewSelect";
@@ -422,54 +422,10 @@ const UsagePage: React.FC = ({ teams, organizations }) => {
}, [userSpendData.results]);
// Calculate top API keys from the breakdown data
- const topKeys = useMemo(() => {
- const keySpend: { [key: string]: KeyMetricWithMetadata } = {};
- userSpendData.results.forEach((day) => {
- Object.entries(day.breakdown.api_keys || {}).forEach(([key, metrics]) => {
- if (!keySpend[key]) {
- keySpend[key] = {
- 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: {
- key_alias: metrics.metadata.key_alias,
- team_id: null,
- user_email: metrics.metadata.user_email,
- tags: metrics.metadata.tags || [],
- },
- };
- }
- keySpend[key].metrics.spend += metrics.metrics.spend;
- keySpend[key].metrics.prompt_tokens += metrics.metrics.prompt_tokens;
- keySpend[key].metrics.completion_tokens += metrics.metrics.completion_tokens;
- keySpend[key].metrics.total_tokens += metrics.metrics.total_tokens;
- keySpend[key].metrics.api_requests += metrics.metrics.api_requests;
- keySpend[key].metrics.successful_requests += metrics.metrics.successful_requests;
- keySpend[key].metrics.failed_requests += metrics.metrics.failed_requests;
- keySpend[key].metrics.cache_read_input_tokens += metrics.metrics.cache_read_input_tokens || 0;
- keySpend[key].metrics.cache_creation_input_tokens += metrics.metrics.cache_creation_input_tokens || 0;
- });
- });
-
- return Object.entries(keySpend)
- .map(([api_key, metrics]) => ({
- api_key,
- key_alias: keyActivityLabel(metrics.metadata),
- user_email: metrics.metadata.user_email ?? null,
- tags: metrics.metadata.tags || [],
- spend: metrics.metrics.spend,
- }))
- .sort((a, b) => b.spend - a.spend)
- .slice(0, topKeysLimit);
- }, [userSpendData.results, topKeysLimit]);
+ const topKeys = useMemo(
+ () => getGlobalTopKeys(userSpendData.results, topKeysLimit),
+ [userSpendData.results, topKeysLimit],
+ );
const sortedDailyResults = useMemo(
() => [...userSpendData.results].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()),
diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.test.tsx
index fc8f7a14626..beb2814f015 100644
--- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.test.tsx
+++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.test.tsx
@@ -102,7 +102,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "Test Key",
- user_email: null,
+ user: null,
spend: 100,
tags: [
{ tag: "tag-1", usage: 50 },
@@ -127,7 +127,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "Key without user",
- user_email: null,
+ user: null,
spend: 100,
},
]}
@@ -143,7 +143,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "",
- user_email: "alice@example.com",
+ user: "alice@example.com",
spend: 100,
},
]}
@@ -154,6 +154,25 @@ describe("TopKeyView", () => {
expect(screen.getByText("alice@example.com")).toBeInTheDocument();
});
+ it("should render a user ID in the User column", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("User")).toBeInTheDocument();
+ expect(screen.getByText("user-123")).toBeInTheDocument();
+ });
+
it("should switch to chart view when chart view button is clicked", async () => {
const user = userEvent.setup();
render();
@@ -178,7 +197,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "A Very Long Key Alias",
- user_email: null,
+ user: null,
spend: 100,
},
]}
@@ -234,7 +253,7 @@ describe("TopKeyView", () => {
{
api_key: "sk-1234567890abcdef",
key_alias: "Test Key",
- user_email: null,
+ user: null,
spend: 100,
},
]}
@@ -253,7 +272,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "",
- user_email: null,
+ user: null,
spend: 100,
},
]}
@@ -270,7 +289,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "Test Key",
- user_email: null,
+ user: null,
spend: 123.456,
},
]}
@@ -287,7 +306,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "Test Key",
- user_email: null,
+ user: null,
spend: 0.004,
},
]}
@@ -304,7 +323,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "Test Key",
- user_email: null,
+ user: null,
spend: 0,
},
]}
@@ -322,7 +341,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "Test Key",
- user_email: null,
+ user: null,
spend: 100,
tags: [],
},
@@ -341,7 +360,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "Test Key",
- user_email: null,
+ user: null,
spend: 100,
},
]}
@@ -359,7 +378,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "Test Key",
- user_email: null,
+ user: null,
spend: 100,
tags: [
{ tag: "tag-1", usage: 50 },
@@ -385,7 +404,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "Test Key",
- user_email: null,
+ user: null,
spend: 100,
tags: [
{ tag: "tag-1", usage: 50 },
@@ -413,7 +432,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "Test Key",
- user_email: null,
+ user: null,
spend: 100,
tags: [
{ tag: "tag-1", usage: 50 },
@@ -451,7 +470,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "Test Key",
- user_email: null,
+ user: null,
spend: 100,
},
]}
@@ -486,7 +505,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "Test Key",
- user_email: null,
+ user: null,
spend: 100,
},
]}
@@ -524,7 +543,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "Test Key",
- user_email: null,
+ user: null,
spend: 100,
},
]}
@@ -561,7 +580,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "Test Key",
- user_email: null,
+ user: null,
spend: 100,
},
]}
@@ -601,7 +620,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "Test Key",
- user_email: null,
+ user: null,
spend: 100,
},
]}
@@ -632,7 +651,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "Test Key",
- user_email: null,
+ user: null,
spend: 100,
},
]}
@@ -663,7 +682,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "Test Key",
- user_email: null,
+ user: null,
spend: 100,
tags: [
{ tag: "tag-low", usage: 10 },
@@ -697,7 +716,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "This is a very long key alias",
- user_email: null,
+ user: null,
spend: 100,
},
]}
@@ -714,7 +733,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: null,
- user_email: null,
+ user: null,
spend: 100,
},
]}
diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx
index c59d7fe5c8d..560633fb1b0 100644
--- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx
+++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx
@@ -18,7 +18,7 @@ const TOP_KEYS_LIMITS = [5, 10, 25, 50] as const;
export interface TopKeyItem {
api_key: string;
key_alias: string | null;
- user_email?: string | null;
+ user?: string | null;
tags?: TagUsage[] | null;
spend: number;
}
@@ -103,11 +103,11 @@ const TopKeyView: React.FC = ({ topKeys, teams, showTags = fals
accessorKey: "key_alias",
cell: (info: any) => info.getValue() || "-",
},
- ...(topKeys.some((k) => k.user_email)
+ ...(topKeys.some((k) => k.user)
? [
{
header: "User",
- accessorKey: "user_email",
+ accessorKey: "user",
cell: (info: any) => info.getValue() || "-",
},
]
diff --git a/ui/litellm-dashboard/tests/top_key_view.test.tsx b/ui/litellm-dashboard/tests/top_key_view.test.tsx
index 073017d5cd5..6751b639339 100644
--- a/ui/litellm-dashboard/tests/top_key_view.test.tsx
+++ b/ui/litellm-dashboard/tests/top_key_view.test.tsx
@@ -36,7 +36,7 @@ describe("TopKeyView", () => {
{
api_key: "key-1",
key_alias: "Production Key",
- user_email: null,
+ user: null,
tags: [
{ tag: "production", usage: 0.005 } as TagUsage, // <$0.01
{ tag: "high-volume", usage: 125.5 } as TagUsage, // High spend
@@ -47,7 +47,7 @@ describe("TopKeyView", () => {
{
api_key: "key-2",
key_alias: "Staging Key",
- user_email: null,
+ user: null,
tags: [
{ tag: "staging", usage: 45.75 } as TagUsage, // Medium spend
{ tag: "testing", usage: 0.008 } as TagUsage, // <$0.01
@@ -58,7 +58,7 @@ describe("TopKeyView", () => {
{
api_key: "key-3",
key_alias: "Development Key",
- user_email: null,
+ user: null,
tags: [
{ tag: "dev", usage: 0.002 } as TagUsage, // <$0.01
{ tag: "experimental", usage: 0.001 } as TagUsage, // <$0.01
@@ -190,7 +190,7 @@ describe("TopKeyView", () => {
{
api_key: "key-no-tags",
key_alias: "No Tags Key",
- user_email: null,
+ user: null,
tags: [],
spend: 10.0,
},
@@ -205,7 +205,7 @@ describe("TopKeyView", () => {
{
api_key: "key-undefined-tags",
key_alias: "Undefined Tags Key",
- user_email: null,
+ user: null,
tags: undefined,
spend: 5.0,
},
@@ -220,7 +220,7 @@ describe("TopKeyView", () => {
{
api_key: "key-null-tags",
key_alias: "Null Tags Key",
- user_email: null,
+ user: null,
tags: null,
spend: 3.0,
},
@@ -237,7 +237,7 @@ describe("TopKeyView", () => {
{
api_key: "key-long-tags",
key_alias: "Long Tags Key",
- user_email: null,
+ user: null,
tags: [{ tag: "very-long-tag-name", usage: 10.0 } as TagUsage, { tag: "short", usage: 5.0 } as TagUsage],
spend: 15.0,
},
@@ -258,14 +258,14 @@ describe("TopKeyView", () => {
{
api_key: "key-mixed-1",
key_alias: "Mixed Key 1",
- user_email: null,
+ user: null,
tags: [{ tag: "expensive", usage: 999.99 } as TagUsage, { tag: "cheap", usage: 0.001 } as TagUsage],
spend: 1000.0,
},
{
api_key: "key-mixed-2",
key_alias: "Mixed Key 2",
- user_email: null,
+ user: null,
tags: [{ tag: "moderate", usage: 50.0 } as TagUsage, { tag: "tiny", usage: 0.005 } as TagUsage],
spend: 50.01,
},
@@ -307,7 +307,7 @@ describe("TopKeyView", () => {
{
api_key: "test-key-123",
key_alias: "Test Key",
- user_email: null,
+ user: null,
tags: [],
spend: 25.5,
},
From 107ec2706bef2993cec998161d9339c36ec39298 Mon Sep 17 00:00:00 2001
From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Thu, 17 Sep 2026 22:46:21 +0000
Subject: [PATCH 4/6] style(ui): format Top Virtual Keys aggregation test
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
.../_components/components/EntityUsage/EntityUsage.test.tsx | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
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 9807a0056ad..ce46f39ab3d 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
@@ -79,9 +79,7 @@ vi.mock("@/components/UsagePage/components/EntityUsage/TopKeyView", () => ({
default: ({ topKeys }: { topKeys: { api_key: string; user?: string | null; spend: number }[] }) => (
Top Keys
-
- {`top-keys:${topKeys.map((row) => `${row.api_key}=${row.spend}=${row.user ?? "-"}`).join("|")}`}
-
+ {`top-keys:${topKeys.map((row) => `${row.api_key}=${row.spend}=${row.user ?? "-"}`).join("|")}`}
),
}));
From 89bf8702253b9e45a82f57332110a4ac0c17b3c9 Mon Sep 17 00:00:00 2001
From: ryan-crabbe-berri
Date: Fri, 18 Sep 2026 18:23:35 -0700
Subject: [PATCH 5/6] fix(ui): stop Top Virtual Keys from opening keys that are
not in the database
/user/daily/activity now reports key_exists on each api key's metadata, true
only when the key is in the active key table that /key/info reads. Top Virtual
Keys renders the Key ID as plain text with an explanatory tooltip and ignores
chart bar clicks when key_exists is false, so deleted keys and CLI/SSO session
keys no longer dead-end on a "Key not found in database" toast
---
litellm/proxy/_lazy_openapi_snapshot.json | 11 ++++
.../common_daily_activity.py | 3 +
.../spend_tracking/key_metadata_recovery.py | 1 +
.../common_daily_activity.py | 1 +
.../test_common_daily_activity.py | 61 +++++++++++++++++++
.../EntityUsage/EntityUsage.test.tsx | 14 +++++
.../EntityUsage/entityUsageAggregations.ts | 4 ++
.../EntityUsage/TopKeyView.test.tsx | 29 +++++++++
.../components/EntityUsage/TopKeyView.tsx | 15 ++++-
.../src/components/UsagePage/types.ts | 1 +
ui/litellm-dashboard/src/lib/http/schema.d.ts | 2 +
11 files changed, 140 insertions(+), 2 deletions(-)
diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json
index 4cfb2bf8c38..06e157498aa 100644
--- a/litellm/proxy/_lazy_openapi_snapshot.json
+++ b/litellm/proxy/_lazy_openapi_snapshot.json
@@ -3247,6 +3247,17 @@
],
"title": "Key Alias"
},
+ "key_exists": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Key Exists"
+ },
"team_id": {
"anyOf": [
{
diff --git a/litellm/proxy/management_endpoints/common_daily_activity.py b/litellm/proxy/management_endpoints/common_daily_activity.py
index a1c92d37871..5a19d743105 100644
--- a/litellm/proxy/management_endpoints/common_daily_activity.py
+++ b/litellm/proxy/management_endpoints/common_daily_activity.py
@@ -127,6 +127,7 @@ class _KeyMetadataDict(TypedDict, total=False):
team_id: ReadOnly[str | None]
user_id: ReadOnly[str | None]
user_email: ReadOnly[str | None]
+ key_exists: ReadOnly[bool]
def _key_metadata(api_key_metadata: Mapping[str, _KeyMetadataDict], api_key: str) -> KeyMetadata:
@@ -136,6 +137,7 @@ def _key_metadata(api_key_metadata: Mapping[str, _KeyMetadataDict], api_key: str
team_id=meta.get("team_id"),
user_id=meta.get("user_id"),
user_email=meta.get("user_email"),
+ key_exists=meta.get("key_exists", False),
)
@@ -512,6 +514,7 @@ async def get_api_key_metadata(
"key_alias": k.key_alias,
"team_id": k.team_id,
"user_id": getattr(k, "user_id", None),
+ "key_exists": True,
}
for k in key_records
}
diff --git a/litellm/proxy/spend_tracking/key_metadata_recovery.py b/litellm/proxy/spend_tracking/key_metadata_recovery.py
index 29688b61b3d..ee2e1cfeaf7 100644
--- a/litellm/proxy/spend_tracking/key_metadata_recovery.py
+++ b/litellm/proxy/spend_tracking/key_metadata_recovery.py
@@ -69,6 +69,7 @@ class KeyMetadataDict(TypedDict, total=False):
team_id: ReadOnly[str | None]
user_id: ReadOnly[str | None]
user_email: ReadOnly[str | None]
+ key_exists: ReadOnly[bool]
class _TokenDigestRow(BaseModel):
diff --git a/litellm/types/proxy/management_endpoints/common_daily_activity.py b/litellm/types/proxy/management_endpoints/common_daily_activity.py
index 5d42b1230a0..2a4f6b2944a 100644
--- a/litellm/types/proxy/management_endpoints/common_daily_activity.py
+++ b/litellm/types/proxy/management_endpoints/common_daily_activity.py
@@ -47,6 +47,7 @@ class KeyMetadata(BaseModel):
team_id: str | None = None
user_id: str | None = None
user_email: str | None = None
+ key_exists: bool | None = None
class KeyMetricWithMetadata(MetricBase):
diff --git a/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py b/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py
index e11f0c37afd..baaf3f4ba2f 100644
--- a/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py
+++ b/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py
@@ -931,6 +931,67 @@ async def test_aggregated_activity_preserves_metadata_for_deleted_keys():
assert key_data.metrics.spend == 10.0
+@pytest.mark.asyncio
+async def test_aggregated_activity_flags_only_keys_that_key_info_can_still_resolve():
+ """/key/info reads the active key table only, so deleted and never-stored (session) keys must not claim to exist."""
+ mock_prisma = MagicMock()
+ base = {
+ "date": "2024-01-01",
+ "endpoint": "/v1/chat/completions",
+ "model": None,
+ "model_group": None,
+ "custom_llm_provider": None,
+ "mcp_namespaced_tool_name": None,
+ "group_level": 30,
+ "distinct_api_keys": 1,
+ "spend": 1.0,
+ "prompt_tokens": 10,
+ "completion_tokens": 5,
+ "cache_read_input_tokens": 0,
+ "cache_creation_input_tokens": 0,
+ "compression_saved_tokens": 0,
+ "compression_savings_spend": 0.0,
+ "prompt_caching_savings_spend": 0.0,
+ "gateway_injected_caching_savings_spend": 0.0,
+ "autorouter_savings_spend": 0.0,
+ "total_response_time_ms": 0,
+ "timed_requests": 0,
+ "api_requests": 1,
+ "successful_requests": 1,
+ "failed_requests": 0,
+ }
+ mock_prisma.db.query_raw = AsyncMock(
+ return_value=[{**base, "api_key": key} for key in ("active-key", "deleted-key", "session-key")]
+ )
+ mock_prisma.db.litellm_verificationtoken.find_many = AsyncMock(
+ return_value=[SimpleNamespace(token="active-key", key_alias="active", team_id=None, user_id="owner")]
+ )
+ mock_prisma.db.litellm_deletedverificationtoken.find_many = AsyncMock(
+ return_value=[SimpleNamespace(token="deleted-key", key_alias="deleted", team_id=None, user_id="owner")]
+ )
+ mock_prisma.db.litellm_usertable.find_many = AsyncMock(return_value=[])
+
+ result = await get_daily_activity_aggregated(
+ prisma_client=mock_prisma,
+ table_name="litellm_dailyuserspend",
+ entity_id_field="user_id",
+ entity_id=None,
+ entity_metadata_field=None,
+ start_date="2024-01-01",
+ end_date="2024-01-01",
+ model=None,
+ api_key=None,
+ )
+
+ key_breakdown = result.results[0].breakdown.endpoints["/v1/chat/completions"].api_key_breakdown
+ assert {key: data.metadata.key_exists for key, data in key_breakdown.items()} == {
+ "active-key": True,
+ "deleted-key": False,
+ "session-key": False,
+ }
+ assert key_breakdown["deleted-key"].metadata.key_alias == "deleted"
+
+
def _daily_user_spend_record(*, user_id, api_key, spend, model="gpt-4", model_group="gpt-4"):
"""A LiteLLM_DailyUserSpend row as the per-user breakdown reads it."""
return SimpleNamespace(
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 ce46f39ab3d..6bd16095351 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
@@ -496,6 +496,20 @@ describe("EntityUsage", () => {
expect(getGlobalTopKeys(results, 5)[0]?.user).toBe("user-123");
expect(getTopAPIKeys(results, 5)[0]?.user).toBe("user-123");
});
+
+ it("carries whether each key still exists for global and entity top keys", () => {
+ const results = [
+ createDailyData("2025-01-01", {
+ "stored-key": createKeyMetrics(20, { key_alias: "Stored", team_id: null, key_exists: true }),
+ "session-key": createKeyMetrics(10, { key_alias: null, team_id: null, key_exists: false }),
+ }),
+ ];
+ const existsByKey = (rows: { api_key: string; key_exists?: boolean | null }[]) =>
+ Object.fromEntries(rows.map((row) => [row.api_key, row.key_exists]));
+
+ expect(existsByKey(getGlobalTopKeys(results, 5))).toEqual({ "stored-key": true, "session-key": false });
+ expect(existsByKey(getTopAPIKeys(results, 5))).toEqual({ "stored-key": true, "session-key": false });
+ });
});
it("should render with tag entity type and display spend metrics", async () => {
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/entityUsageAggregations.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/entityUsageAggregations.ts
index eaa462cfb60..54569608b0e 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/entityUsageAggregations.ts
+++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/entityUsageAggregations.ts
@@ -108,6 +108,7 @@ export const getGlobalTopKeys = (results: DailyData[], topKeysLimit: number): To
team_id: null,
user_id: metrics.metadata.user_id,
user_email: metrics.metadata.user_email,
+ key_exists: metrics.metadata.key_exists,
tags: metrics.metadata.tags || [],
},
};
@@ -129,6 +130,7 @@ export const getGlobalTopKeys = (results: DailyData[], topKeysLimit: number): To
api_key,
key_alias: keyActivityLabel(metrics.metadata),
user: metrics.metadata.user_email ?? metrics.metadata.user_id ?? null,
+ key_exists: metrics.metadata.key_exists,
tags: metrics.metadata.tags || [],
spend: metrics.metrics.spend,
}))
@@ -172,6 +174,7 @@ export const getTopAPIKeys = (results: ExtendedDailyData[], topKeysLimit: number
team_id: metrics.metadata.team_id || null,
user_id: metrics.metadata.user_id,
user_email: metrics.metadata.user_email,
+ key_exists: metrics.metadata.key_exists,
tags: tagDictionary[key] || [],
},
};
@@ -193,6 +196,7 @@ export const getTopAPIKeys = (results: ExtendedDailyData[], topKeysLimit: number
api_key,
key_alias: keyActivityLabel(metrics.metadata),
user: metrics.metadata.user_email ?? metrics.metadata.user_id ?? null,
+ key_exists: metrics.metadata.key_exists,
tags: metrics.metadata.tags || [],
spend: metrics.metrics.spend,
}))
diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.test.tsx
index beb2814f015..e1d64ab03bd 100644
--- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.test.tsx
+++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.test.tsx
@@ -491,6 +491,35 @@ describe("TopKeyView", () => {
});
});
+ it("should only look up keys that still exist in the database, from both the table and the chart", async () => {
+ mockKeyInfoV1Call.mockResolvedValue({ key: "info" });
+ mockTransformKeyInfo.mockReturnValue({ transformed: "data" } as unknown as KeyResponse);
+
+ const user = userEvent.setup();
+ const { container } = render(
+ ,
+ );
+
+ expect(screen.getByRole("button", { name: "stored-key" })).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "session-key" })).not.toBeInTheDocument();
+ await user.click(screen.getByText("session-key"));
+
+ await user.click(screen.getByRole("button", { name: "Chart View" }));
+ const bars = container.querySelectorAll("path.recharts-rectangle");
+ expect(bars).toHaveLength(2);
+ bars.forEach((bar) => fireEvent.click(bar));
+
+ expect(await screen.findByText("Key Info View for stored-key")).toBeInTheDocument();
+ expect(mockKeyInfoV1Call).toHaveBeenCalledTimes(1);
+ expect(mockKeyInfoV1Call).toHaveBeenCalledWith("test-token", "stored-key");
+ });
+
it("should close modal when close button is clicked", async () => {
const mockKeyInfo = { key: "info" };
const mockTransformedData = { transformed: "data" } as unknown as KeyResponse;
diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx
index 560633fb1b0..178a5b7ec37 100644
--- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx
+++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx
@@ -19,10 +19,16 @@ export interface TopKeyItem {
api_key: string;
key_alias: string | null;
user?: string | null;
+ key_exists?: boolean | null;
tags?: TagUsage[] | null;
spend: number;
}
+const KEY_NOT_IN_DATABASE_TOOLTIP =
+ "This key is no longer in the database (deleted, or a CLI/SSO session key), so its details can't be opened";
+
+const canOpenKeyInfo = (item: TopKeyItem) => item.key_exists !== false;
+
interface TopKeyViewProps {
topKeys: TopKeyItem[];
teams: any[] | null;
@@ -52,7 +58,7 @@ const TopKeyView: React.FC = ({ topKeys, teams, showTags = fals
};
const handleKeyClick = async (item: TopKeyItem) => {
- if (!accessToken) return;
+ if (!accessToken || !canOpenKeyInfo(item)) return;
try {
const keyInfo = await keyInfoV1Call(accessToken, item.api_key);
@@ -96,7 +102,12 @@ const TopKeyView: React.FC = ({ topKeys, teams, showTags = fals
{
header: "Key ID",
accessorKey: "api_key",
- cell: (info: any) => handleKeyClick(info.row.original)} />,
+ cell: (info: any) =>
+ canOpenKeyInfo(info.row.original) ? (
+ handleKeyClick(info.row.original)} />
+ ) : (
+
+ ),
},
{
header: "Key Alias",
diff --git a/ui/litellm-dashboard/src/components/UsagePage/types.ts b/ui/litellm-dashboard/src/components/UsagePage/types.ts
index e8bd3cb3a87..d53db68bb9f 100644
--- a/ui/litellm-dashboard/src/components/UsagePage/types.ts
+++ b/ui/litellm-dashboard/src/components/UsagePage/types.ts
@@ -50,6 +50,7 @@ export interface KeyMetadata {
team_id: string | null;
user_id?: string | null;
user_email?: string | null;
+ key_exists?: boolean | null;
tags?: { tag: string; usage: number }[];
}
diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts
index 4fe8bff3da8..7aa34c5752c 100644
--- a/ui/litellm-dashboard/src/lib/http/schema.d.ts
+++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts
@@ -29459,6 +29459,8 @@ export interface components {
KeyMetadata: {
/** Key Alias */
key_alias?: string | null;
+ /** Key Exists */
+ key_exists?: boolean | null;
/** Team Id */
team_id?: string | null;
/** User Email */
From 82fd632153f649fab446fbedc6f1b83b65af21db Mon Sep 17 00:00:00 2001
From: ryan-crabbe-berri
Date: Sat, 19 Sep 2026 11:22:01 -0700
Subject: [PATCH 6/6] test(ui): share one chart bar lookup across Top Virtual
Keys tests
The key_exists chart test added a second direct DOM lookup for the Recharts bars, which exposes no role or label, and pushed testing-library/no-node-access over its budget (709 > 707). Both chart tests now go through one helper
---
.../UsagePage/components/EntityUsage/TopKeyView.test.tsx | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.test.tsx
index e1d64ab03bd..e65094c5228 100644
--- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.test.tsx
+++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.test.tsx
@@ -29,6 +29,8 @@ vi.mock("../../../templates/key_info_view", () => ({
),
}));
+const chartBars = (container: HTMLElement) => Array.from(container.querySelectorAll("path.recharts-rectangle"));
+
describe("TopKeyView", () => {
const mockUseAuthorized = vi.mocked(useAuthorized);
const mockKeyInfoV1Call = vi.mocked(networking.keyInfoV1Call);
@@ -206,7 +208,7 @@ describe("TopKeyView", () => {
await user.click(screen.getByRole("button", { name: "Chart View" }));
- const bars = container.querySelectorAll("path.recharts-rectangle");
+ const bars = chartBars(container);
expect(bars).toHaveLength(1);
expect(bars[0]).toHaveAttribute("fill", "var(--color-cyan-500, #06b6d4)");
expect(screen.getAllByText("A Very Lon...").length).toBeGreaterThan(0);
@@ -511,7 +513,7 @@ describe("TopKeyView", () => {
await user.click(screen.getByText("session-key"));
await user.click(screen.getByRole("button", { name: "Chart View" }));
- const bars = container.querySelectorAll("path.recharts-rectangle");
+ const bars = chartBars(container);
expect(bars).toHaveLength(2);
bars.forEach((bar) => fireEvent.click(bar));