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>
This commit is contained in:
Devin AI 2026-09-17 22:32:46 +00:00 committed by ryan-crabbe-berri
parent e93fe60512
commit 88799f6f80
7 changed files with 200 additions and 89 deletions

View file

@ -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

View file

@ -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<string, KeyMetricWithMetadata>): 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 }[] }) => (
<div>
<span>Top Keys</span>
<span>
{`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("|")}`}
</span>
</div>
),
@ -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(<EntityUsage {...defaultProps} />);

View file

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

View file

@ -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<UsagePageProps> = ({ teams, organizations }) => {
}, [userSpendData.results]);
// Calculate top API keys from the breakdown data
const topKeys = useMemo<TopKeyItem[]>(() => {
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<TopKeyItem[]>(
() => 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()),

View file

@ -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(
<TopKeyView
{...baseProps}
topKeys={[
{
api_key: "key-123",
key_alias: "Key with user ID",
user: "user-123",
spend: 100,
},
]}
/>,
);
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(<TopKeyView {...baseProps} />);
@ -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,
},
]}

View file

@ -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<TopKeyViewProps> = ({ 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() || "-",
},
]

View file

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