From 19aa5aaa42ac43ebbd89a7d5e0b0787ea5903956 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] 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 2a6c2ede478..54e3c55e7a5 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("|")}`} +
), })); @@ -1082,7 +1084,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" }, + }, + }, }, }, ], @@ -1091,7 +1098,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 a92d1209567..4f28e1e8129 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 @@ -61,7 +61,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"; @@ -407,7 +407,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]) => { @@ -448,6 +448,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, },