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, },