mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-15 23:31:29 +00:00
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:
commit
ba6a0c9fc6
14 changed files with 284 additions and 2 deletions
|
|
@ -3234,6 +3234,17 @@
|
|||
}
|
||||
],
|
||||
"title": "User Email"
|
||||
},
|
||||
"user_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "User Id"
|
||||
}
|
||||
},
|
||||
"title": "KeyMetadata",
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -28374,6 +28374,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