mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-20 00:11:50 +00:00
feat(ui): search Key Activity by key alias, key hash, user id, or email
Team Usage and the main Usage page render every key in the selected scope with no way to narrow the list. Add a client-side search box above Key Activity that filters the loaded keys by alias, hash, user id, or user email, and expose user_id on the daily activity key metadata so the id is searchable Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
2f46425732
commit
a0b55fe68d
13 changed files with 273 additions and 2 deletions
|
|
@ -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"),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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} /> },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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 "{query.trim()}" in this date range
|
||||
</p>
|
||||
) : (
|
||||
<ActivityMetrics modelMetrics={filtered} hidePromptCachingMetrics={hidePromptCachingMetrics} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default KeyActivityPanel;
|
||||
|
|
@ -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({});
|
||||
});
|
||||
});
|
||||
|
|
@ -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)),
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
2
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
2
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -28414,6 +28414,8 @@ export interface components {
|
|||
team_id?: string | null;
|
||||
/** User Email */
|
||||
user_email?: string | null;
|
||||
/** User Id */
|
||||
user_id?: string | null;
|
||||
};
|
||||
/**
|
||||
* KeyMetricWithMetadata
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue