Merge pull request #40652 from BerriAI/litellm_key_activity_search

feat(ui): search Key Activity by key alias, key hash, user id, or email
This commit is contained in:
ryan-crabbe-berri 2026-09-12 15:05:53 -07:00 committed by GitHub
commit ba6a0c9fc6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 284 additions and 2 deletions

View file

@ -3234,6 +3234,17 @@
}
],
"title": "User Email"
},
"user_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "User Id"
}
},
"title": "KeyMetadata",

View file

@ -127,6 +127,7 @@ def _key_metadata(api_key_metadata: Mapping[str, _KeyMetadataDict], api_key: str
return KeyMetadata(
key_alias=meta.get("key_alias"),
team_id=meta.get("team_id"),
user_id=meta.get("user_id"),
user_email=meta.get("user_email"),
)

View file

@ -43,6 +43,7 @@ class KeyMetadata(BaseModel):
key_alias: str | None = None
team_id: str | None = None
user_id: str | None = None
user_email: str | None = None

View file

@ -613,6 +613,7 @@ def test_key_metadata_includes_recovered_user_email():
"dirty-key": {
"key_alias": "batch-worker",
"team_id": "team-1",
"user_id": "alice",
"user_email": "alice@example.com",
}
},
@ -620,6 +621,7 @@ def test_key_metadata_includes_recovered_user_email():
)
assert meta.key_alias == "batch-worker"
assert meta.user_id == "alice"
assert meta.user_email == "alice@example.com"
@ -848,9 +850,11 @@ async def test_aggregated_activity_preserves_metadata_for_deleted_keys():
mock_deleted_key.token = "deleted-key-hash"
mock_deleted_key.key_alias = "toto-test-2"
mock_deleted_key.team_id = "69cd4b77-b095-4489-8c46-4f2f31d840a2"
mock_deleted_key.user_id = "deleted-key-owner"
mock_prisma.db.litellm_deletedverificationtoken = MagicMock()
mock_prisma.db.litellm_deletedverificationtoken.find_many = AsyncMock(return_value=[mock_deleted_key])
mock_prisma.db.litellm_usertable.find_many = AsyncMock(return_value=[])
result = await get_daily_activity_aggregated(
prisma_client=mock_prisma,
@ -871,6 +875,7 @@ async def test_aggregated_activity_preserves_metadata_for_deleted_keys():
key_data = chat_endpoint.api_key_breakdown["deleted-key-hash"]
assert key_data.metadata.key_alias == "toto-test-2"
assert key_data.metadata.team_id == "69cd4b77-b095-4489-8c46-4f2f31d840a2"
assert key_data.metadata.user_id == "deleted-key-owner"
assert key_data.metrics.spend == 10.0

View file

@ -42,6 +42,7 @@ import { valueFormatterSpend } from "@/components/UsagePage/utils/value_formatte
import EndpointUsage from "../EndpointUsage/EndpointUsage";
import ModelViewToggle, { ModelViewType } from "../ModelViewToggle";
import TopKeyView from "@/components/UsagePage/components/EntityUsage/TopKeyView";
import KeyActivityPanel from "@/components/UsagePage/components/KeyActivityPanel";
import TopModelView from "./TopModelView";
import TeamUserSpendCard from "./TeamUserSpendCard";
@ -654,7 +655,7 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
{
key: "keys",
label: "Key Activity",
content: <ActivityMetrics modelMetrics={keyMetrics} hidePromptCachingMetrics={entityType === "agent"} />,
content: <KeyActivityPanel keyMetrics={keyMetrics} hidePromptCachingMetrics={entityType === "agent"} />,
},
{ key: "endpoints", label: "Endpoint Activity", content: <EndpointUsage userSpendData={spendData} /> },
];

View file

@ -30,6 +30,7 @@ import { ActivityMetrics, processActivityData } from "@/components/activity_metr
import CloudZeroExportModal from "@/components/cloudzero_export_modal";
import UserDropdown from "@/components/common_components/UserDropdown";
import EntityUsageExportModal from "@/components/EntityUsageExport";
import KeyActivityPanel from "@/components/UsagePage/components/KeyActivityPanel";
import { Team } from "@/components/key_team_helpers/key_list";
import {
gatewayDailyActivityCall,
@ -886,7 +887,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
<ActivityMetrics modelMetrics={modelMetrics} />
</TabsContent>
<TabsContent value="keys" keepMounted>
<ActivityMetrics modelMetrics={keyMetrics} />
<KeyActivityPanel keyMetrics={keyMetrics} />
</TabsContent>
<TabsContent value="mcp" keepMounted>
<ActivityMetrics modelMetrics={mcpServerMetrics} />

View file

@ -0,0 +1,71 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { ModelActivityData } from "../types";
import KeyActivityPanel from "./KeyActivityPanel";
vi.mock("@/components/activity_metrics", () => ({
ActivityMetrics: ({ modelMetrics }: { modelMetrics: Record<string, ModelActivityData> }) => (
<ul data-testid="rendered-keys">
{Object.keys(modelMetrics).map((hash) => (
<li key={hash}>{hash}</li>
))}
</ul>
),
}));
function activity(label: string, user_email: string | null, user_id: string | null): ModelActivityData {
return {
label,
key_metadata: { key_alias: label, team_id: "team-1", user_id, user_email },
total_requests: 1,
total_successful_requests: 1,
total_failed_requests: 0,
total_cache_read_input_tokens: 0,
total_cache_creation_input_tokens: 0,
total_tokens: 10,
prompt_tokens: 5,
completion_tokens: 5,
total_spend: 0.01,
top_api_keys: [],
top_models: [],
daily_data: [],
};
}
const keyMetrics: Record<string, ModelActivityData> = {
"hash-alice": activity("alice-key", "alice@example.com", "user-alice"),
"hash-bob": activity("bob-key", "bob@example.com", "user-bob"),
};
describe("KeyActivityPanel", () => {
it("renders every key and the full count before searching", () => {
render(<KeyActivityPanel keyMetrics={keyMetrics} />);
expect(screen.getByTestId("rendered-keys")).toHaveTextContent("hash-alicehash-bob");
expect(screen.getByText("Showing 2 of 2 keys")).toBeInTheDocument();
});
it("narrows the rendered keys to those matching the user email", () => {
render(<KeyActivityPanel keyMetrics={keyMetrics} />);
fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "bob@example.com" } });
expect(screen.getByTestId("rendered-keys")).toHaveTextContent("hash-bob");
expect(screen.getByTestId("rendered-keys")).not.toHaveTextContent("hash-alice");
expect(screen.getByText("Showing 1 of 2 keys")).toBeInTheDocument();
});
it("shows an empty state instead of zeroed metrics when nothing matches", () => {
render(<KeyActivityPanel keyMetrics={keyMetrics} />);
fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "carol" } });
expect(screen.queryByTestId("rendered-keys")).not.toBeInTheDocument();
expect(screen.getByText('No keys match "carol" in this date range')).toBeInTheDocument();
});
it("clears the search and restores every key", () => {
render(<KeyActivityPanel keyMetrics={keyMetrics} />);
fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "user-alice" } });
expect(screen.getByTestId("rendered-keys")).toHaveTextContent("hash-alice");
fireEvent.click(screen.getByLabelText("Clear key search"));
expect(screen.getByLabelText("Search keys")).toHaveValue("");
expect(screen.getByTestId("rendered-keys")).toHaveTextContent("hash-alicehash-bob");
});
});

View file

@ -0,0 +1,58 @@
import { Search, X } from "lucide-react";
import React, { useMemo, useState } from "react";
import { ActivityMetrics } from "@/components/activity_metrics";
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
import { filterKeyActivity } from "../keyActivityFilter";
import type { ModelActivityData } from "../types";
interface KeyActivityPanelProps {
keyMetrics: Record<string, ModelActivityData>;
hidePromptCachingMetrics?: boolean;
}
const KeyActivityPanel: React.FC<KeyActivityPanelProps> = ({ keyMetrics, hidePromptCachingMetrics = false }) => {
const [query, setQuery] = useState("");
const filtered = useMemo(() => filterKeyActivity(keyMetrics, query), [keyMetrics, query]);
const totalKeys = Object.keys(keyMetrics).length;
const shownKeys = Object.keys(filtered).length;
const isFiltering = query.trim() !== "";
return (
<div className="space-y-4">
<div className="mt-2 flex items-center gap-3">
<InputGroup className="max-w-md">
<InputGroupAddon>
<Search className="size-4 text-muted-foreground" />
</InputGroupAddon>
<InputGroupInput
aria-label="Search keys"
placeholder="Search by key alias, key hash, user ID, or email"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
{isFiltering && (
<InputGroupAddon align="inline-end">
<InputGroupButton size="icon-xs" aria-label="Clear key search" onClick={() => setQuery("")}>
<X />
</InputGroupButton>
</InputGroupAddon>
)}
</InputGroup>
<span className="text-sm text-muted-foreground">
Showing {shownKeys.toLocaleString()} of {totalKeys.toLocaleString()} keys
</span>
</div>
{isFiltering && totalKeys > 0 && shownKeys === 0 ? (
<p className="rounded-lg border p-6 text-center text-sm text-muted-foreground">
No keys match &quot;{query.trim()}&quot; in this date range
</p>
) : (
<ActivityMetrics modelMetrics={filtered} hidePromptCachingMetrics={hidePromptCachingMetrics} />
)}
</div>
);
};
export default KeyActivityPanel;

View file

@ -0,0 +1,91 @@
import { describe, expect, it } from "vitest";
import { filterKeyActivity, keyActivityMatches } from "./keyActivityFilter";
import type { KeyMetadata, ModelActivityData } from "./types";
function activity(label: string, key_metadata?: KeyMetadata): ModelActivityData {
return {
label,
key_metadata,
total_requests: 1,
total_successful_requests: 1,
total_failed_requests: 0,
total_cache_read_input_tokens: 0,
total_cache_creation_input_tokens: 0,
total_tokens: 10,
prompt_tokens: 5,
completion_tokens: 5,
total_spend: 0.01,
top_api_keys: [],
top_models: [],
daily_data: [],
};
}
const aliceMeta: KeyMetadata = {
key_alias: "alice-batch",
team_id: "team-research",
user_id: "user-alice-1234",
user_email: "alice@example.com",
};
const bobMeta: KeyMetadata = {
key_alias: null,
team_id: "team-research",
user_id: "user-bob-5678",
user_email: "bob@example.com",
};
const alice = activity("alice-batch (team: research)", aliceMeta);
const bob = activity("bob@example.com (team: research)", bobMeta);
const orphan = activity("key-hash-deadbeef", { key_alias: null, team_id: null });
const keyMetrics: Record<string, ModelActivityData> = {
"hash-alice": alice,
"hash-bob": bob,
deadbeef: orphan,
};
describe("keyActivityMatches", () => {
it("matches every key on an empty or whitespace query", () => {
expect(keyActivityMatches("deadbeef", orphan, "")).toBe(true);
expect(keyActivityMatches("deadbeef", orphan, " ")).toBe(true);
});
it("matches key alias case-insensitively", () => {
expect(keyActivityMatches("hash-alice", alice, "ALICE-batch")).toBe(true);
expect(keyActivityMatches("hash-bob", bob, "alice-batch")).toBe(false);
});
it("matches user email", () => {
expect(keyActivityMatches("hash-bob", bob, "bob@example")).toBe(true);
expect(keyActivityMatches("hash-alice", alice, "bob@example")).toBe(false);
});
it("matches user id", () => {
expect(keyActivityMatches("hash-alice", alice, "user-alice-1234")).toBe(true);
expect(keyActivityMatches("hash-bob", bob, "user-alice-1234")).toBe(false);
});
it("matches the key hash when the key has no alias or user metadata", () => {
expect(keyActivityMatches("deadbeef", orphan, "dead")).toBe(true);
expect(keyActivityMatches("deadbeef", orphan, "alice")).toBe(false);
});
it("trims surrounding whitespace from the query", () => {
expect(keyActivityMatches("hash-alice", alice, " alice@example.com ")).toBe(true);
});
});
describe("filterKeyActivity", () => {
it("returns the same object when the query is blank", () => {
expect(filterKeyActivity(keyMetrics, "")).toBe(keyMetrics);
});
it("keeps only the keys matching the query, preserving their hashes", () => {
expect(Object.keys(filterKeyActivity(keyMetrics, "example.com"))).toEqual(["hash-alice", "hash-bob"]);
expect(filterKeyActivity(keyMetrics, "user-bob")).toEqual({ "hash-bob": bob });
});
it("returns an empty record when nothing matches", () => {
expect(filterKeyActivity(keyMetrics, "nobody")).toEqual({});
});
});

View file

@ -0,0 +1,20 @@
import type { ModelActivityData } from "./types";
export function keyActivityMatches(apiKey: string, data: ModelActivityData, query: string): boolean {
const needle = query.trim().toLowerCase();
if (needle === "") return true;
const meta = data.key_metadata;
return [apiKey, data.label, meta?.key_alias, meta?.user_id, meta?.user_email].some(
(field) => field?.toLowerCase().includes(needle) ?? false,
);
}
export function filterKeyActivity(
keyMetrics: Record<string, ModelActivityData>,
query: string,
): Record<string, ModelActivityData> {
if (query.trim() === "") return keyMetrics;
return Object.fromEntries(
Object.entries(keyMetrics).filter(([apiKey, data]) => keyActivityMatches(apiKey, data, query)),
);
}

View file

@ -46,6 +46,7 @@ export interface KeyMetricWithMetadata {
export interface KeyMetadata {
key_alias: string | null;
team_id: string | null;
user_id?: string | null;
user_email?: string | null;
tags?: { tag: string; usage: number }[];
}
@ -70,6 +71,7 @@ export interface TopModelData {
export interface ModelActivityData {
label: string;
key_metadata?: KeyMetadata;
total_requests: number;
total_successful_requests: number;
total_failed_requests: number;

View file

@ -655,6 +655,23 @@ describe("processActivityData", () => {
expect(result["key1"].label).toBe("test-key-1 (team_id: team1)");
});
it("retains the api key metadata so key activity can be searched by user", () => {
const metadata = { key_alias: "test-key-1", team_id: "team1", user_id: "user-1", user_email: "user1@example.com" };
const withUser: { results: DailyData[] } = {
results: [
createMockDailyData("2025-01-01", mockDailyActivity.results[0].metrics, {
...EMPTY_BREAKDOWN,
api_keys: { key1: createMockKeyMetricWithMetadata(metadata, mockDailyActivity.results[0].metrics) },
}),
],
};
const result = processActivityData(withUser, "api_keys", MOCK_TEAMS);
expect(result["key1"].key_metadata).toEqual(metadata);
expect(processActivityData(withUser, "models")["key1"]).toBeUndefined();
});
it("should process data for models key with data", () => {
const dailyActivityWithModels: { results: DailyData[] } = {
results: [

View file

@ -461,6 +461,7 @@ export const processActivityData = (
: key === "entities"
? (modelData as any).metadata?.agent_name || (modelData as any).metadata?.team_alias || model
: model,
...(key === "api_keys" ? { key_metadata: (modelData as KeyMetricWithMetadata).metadata } : {}),
total_requests: 0,
total_successful_requests: 0,
total_failed_requests: 0,

View file

@ -28374,6 +28374,8 @@ export interface components {
team_id?: string | null;
/** User Email */
user_email?: string | null;
/** User Id */
user_id?: string | null;
};
/**
* KeyMetricWithMetadata