Merge pull request #40729 from BerriAI/litellm_lit7572_top_keys_user_column

fix(ui): show user attribution in Top Virtual Keys usage tables
This commit is contained in:
ryan-crabbe-berri 2026-09-19 11:32:35 -07:00 committed by GitHub
commit 659cef0f57
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 415 additions and 67 deletions

View file

@ -3247,6 +3247,17 @@
],
"title": "Key Alias"
},
"key_exists": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "null"
}
],
"title": "Key Exists"
},
"team_id": {
"anyOf": [
{

View file

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

View file

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

View file

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

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
@ -913,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(

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,10 +76,10 @@ 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?: string | null; spend: number }[] }) => (
<div>
<span>Top Keys</span>
<span>{`top-keys:${topKeys.map((row) => `${row.api_key}=${row.spend}`).join("|")}`}</span>
<span>{`top-keys:${topKeys.map((row) => `${row.api_key}=${row.spend}=${row.user ?? "-"}`).join("|")}`}</span>
</div>
),
}));
@ -431,6 +463,55 @@ 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("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 () => {
render(<EntityUsage {...defaultProps} />);
@ -1099,7 +1180,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 +1194,7 @@ describe("EntityUsage", () => {
render(<EntityUsage {...defaultProps} entityType="team" />);
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();

View file

@ -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,59 @@ export const getTopAgents = (results: ExtendedDailyData[], topAgentsLimit: numbe
.slice(0, topAgentsLimit);
};
export const getTopAPIKeys = (results: ExtendedDailyData[], topKeysLimit: number) => {
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,
key_exists: metrics.metadata.key_exists,
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,
key_exists: metrics.metadata.key_exists,
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) => {
const { breakdown } = day;
@ -119,7 +172,9 @@ 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,
key_exists: metrics.metadata.key_exists,
tags: tagDictionary[key] || [],
},
};
@ -140,7 +195,9 @@ export const getTopAPIKeys = (results: ExtendedDailyData[], topKeysLimit: number
.map(([api_key, metrics]) => ({
api_key,
key_alias: keyActivityLabel(metrics.metadata),
tags: metrics.metadata.tags || "-",
user: metrics.metadata.user_email ?? metrics.metadata.user_id ?? null,
key_exists: metrics.metadata.key_exists,
tags: metrics.metadata.tags || [],
spend: metrics.metrics.spend,
}))
.sort((a, b) => b.spend - a.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,
@ -63,7 +62,8 @@ 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 { getGlobalTopKeys } from "./EntityUsage/entityUsageAggregations";
import UsageAIChatPanel from "./UsageAIChatPanel";
import { UsageOption, UsageViewSelect } from "./UsageViewSelect/UsageViewSelect";
@ -422,53 +422,10 @@ const UsagePage: React.FC<UsagePageProps> = ({ 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),
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

@ -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);
@ -102,6 +104,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "Test Key",
user: null,
spend: 100,
tags: [
{ tag: "tag-1", usage: 50 },
@ -118,6 +121,60 @@ describe("TopKeyView", () => {
expect(screen.getByText("$100.00")).toBeInTheDocument();
});
it("should render User column only when a row has user attribution", () => {
const { rerender } = render(
<TopKeyView
{...baseProps}
topKeys={[
{
api_key: "key-123",
key_alias: "Key without user",
user: null,
spend: 100,
},
]}
/>,
);
expect(screen.queryByText("User")).not.toBeInTheDocument();
rerender(
<TopKeyView
{...baseProps}
topKeys={[
{
api_key: "key-123",
key_alias: "",
user: "alice@example.com",
spend: 100,
},
]}
/>,
);
expect(screen.getByText("User")).toBeInTheDocument();
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} />);
@ -142,6 +199,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "A Very Long Key Alias",
user: null,
spend: 100,
},
]}
@ -150,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);
@ -197,6 +255,7 @@ describe("TopKeyView", () => {
{
api_key: "sk-1234567890abcdef",
key_alias: "Test Key",
user: null,
spend: 100,
},
]}
@ -215,12 +274,13 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "",
user: null,
spend: 100,
},
]}
/>,
);
expect(screen.getByText("-")).toBeInTheDocument();
expect(screen.getAllByText("-")).toHaveLength(1);
});
it("should format spend values with two decimal places", () => {
@ -231,6 +291,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "Test Key",
user: null,
spend: 123.456,
},
]}
@ -247,6 +308,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "Test Key",
user: null,
spend: 0.004,
},
]}
@ -263,12 +325,13 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "Test Key",
user: null,
spend: 0,
},
]}
/>,
);
expect(screen.getByText("-")).toBeInTheDocument();
expect(screen.getAllByText("-")).toHaveLength(1);
expect(screen.queryByText("$0.00")).not.toBeInTheDocument();
});
@ -280,6 +343,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "Test Key",
user: null,
spend: 100,
tags: [],
},
@ -298,6 +362,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "Test Key",
user: null,
spend: 100,
},
]}
@ -315,6 +380,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "Test Key",
user: null,
spend: 100,
tags: [
{ tag: "tag-1", usage: 50 },
@ -340,6 +406,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "Test Key",
user: null,
spend: 100,
tags: [
{ tag: "tag-1", usage: 50 },
@ -367,6 +434,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "Test Key",
user: null,
spend: 100,
tags: [
{ tag: "tag-1", usage: 50 },
@ -404,6 +472,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "Test Key",
user: null,
spend: 100,
},
]}
@ -424,6 +493,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(
<TopKeyView
{...baseProps}
topKeys={[
{ api_key: "session-key", key_alias: null, user: "alice@example.com", key_exists: false, spend: 100 },
{ api_key: "stored-key", key_alias: "Stored", user: null, key_exists: true, spend: 50 },
]}
/>,
);
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 = chartBars(container);
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;
@ -438,6 +536,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "Test Key",
user: null,
spend: 100,
},
]}
@ -475,6 +574,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "Test Key",
user: null,
spend: 100,
},
]}
@ -511,6 +611,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "Test Key",
user: null,
spend: 100,
},
]}
@ -550,6 +651,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "Test Key",
user: null,
spend: 100,
},
]}
@ -580,6 +682,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "Test Key",
user: null,
spend: 100,
},
]}
@ -610,6 +713,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "Test Key",
user: null,
spend: 100,
tags: [
{ tag: "tag-low", usage: 10 },
@ -643,6 +747,7 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: "This is a very long key alias",
user: null,
spend: 100,
},
]}
@ -659,11 +764,12 @@ describe("TopKeyView", () => {
{
api_key: "key-123",
key_alias: null,
user: null,
spend: 100,
},
]}
/>,
);
expect(screen.getByText("-")).toBeInTheDocument();
expect(screen.getAllByText("-")).toHaveLength(1);
});
});

View file

@ -15,8 +15,22 @@ import { TagUsage } from "../../types";
const TOP_KEYS_LIMITS = [5, 10, 25, 50] as const;
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: any[];
topKeys: TopKeyItem[];
teams: any[] | null;
showTags?: boolean;
topKeysLimit: number;
@ -43,8 +57,8 @@ const TopKeyView: React.FC<TopKeyViewProps> = ({ topKeys, teams, showTags = fals
});
};
const handleKeyClick = async (item: any) => {
if (!accessToken) return;
const handleKeyClick = async (item: TopKeyItem) => {
if (!accessToken || !canOpenKeyInfo(item)) return;
try {
const keyInfo = await keyInfoV1Call(accessToken, item.api_key);
@ -88,13 +102,27 @@ const TopKeyView: React.FC<TopKeyViewProps> = ({ topKeys, teams, showTags = fals
{
header: "Key ID",
accessorKey: "api_key",
cell: (info: any) => <IdCell value={info.getValue()} onClick={() => handleKeyClick(info.row.original)} />,
cell: (info: any) =>
canOpenKeyInfo(info.row.original) ? (
<IdCell value={info.getValue()} onClick={() => handleKeyClick(info.row.original)} />
) : (
<IdCell value={info.getValue()} variant="plain" tooltip={KEY_NOT_IN_DATABASE_TOOLTIP} />
),
},
{
header: "Key Alias",
accessorKey: "key_alias",
cell: (info: any) => info.getValue() || "-",
},
...(topKeys.some((k) => k.user)
? [
{
header: "User",
accessorKey: "user",
cell: (info: any) => info.getValue() || "-",
},
]
: []),
];
const tagsColumn = {

View file

@ -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 }[];
}

View file

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

View file

@ -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: 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: 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: 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: null,
tags: [],
spend: 10.0,
},
];
renderWithProviders(<TopKeyView {...mockProps} topKeys={keysWithoutTags} showTags={true} />);
expect(screen.getByText("-")).toBeInTheDocument();
expect(screen.getAllByText("-")).toHaveLength(1);
});
it("should handle keys with undefined tags", () => {
@ -195,13 +205,14 @@ describe("TopKeyView", () => {
{
api_key: "key-undefined-tags",
key_alias: "Undefined Tags Key",
user: null,
tags: undefined,
spend: 5.0,
},
];
renderWithProviders(<TopKeyView {...mockProps} topKeys={keysWithUndefinedTags} showTags={true} />);
expect(screen.getByText("-")).toBeInTheDocument();
expect(screen.getAllByText("-")).toHaveLength(1);
});
it("should handle keys with null tags", () => {
@ -209,13 +220,14 @@ describe("TopKeyView", () => {
{
api_key: "key-null-tags",
key_alias: "Null Tags Key",
user: null,
tags: null,
spend: 3.0,
},
];
renderWithProviders(<TopKeyView {...mockProps} topKeys={keysWithNullTags} showTags={true} />);
expect(screen.getByText("-")).toBeInTheDocument();
expect(screen.getAllByText("-")).toHaveLength(1);
});
});
@ -225,6 +237,7 @@ describe("TopKeyView", () => {
{
api_key: "key-long-tags",
key_alias: "Long Tags Key",
user: 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: 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: 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: null,
tags: [],
spend: 25.5,
},