diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index cb7a18cd107..f46301fde8d 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -3234,6 +3234,17 @@ } ], "title": "User Email" + }, + "user_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "User Id" } }, "title": "KeyMetadata", diff --git a/litellm/proxy/management_endpoints/common_daily_activity.py b/litellm/proxy/management_endpoints/common_daily_activity.py index a8aef30107c..44ed0017e42 100644 --- a/litellm/proxy/management_endpoints/common_daily_activity.py +++ b/litellm/proxy/management_endpoints/common_daily_activity.py @@ -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"), ) diff --git a/litellm/types/proxy/management_endpoints/common_daily_activity.py b/litellm/types/proxy/management_endpoints/common_daily_activity.py index 2b39c5dbb9b..090e5c42376 100644 --- a/litellm/types/proxy/management_endpoints/common_daily_activity.py +++ b/litellm/types/proxy/management_endpoints/common_daily_activity.py @@ -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 diff --git a/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py b/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py index 6cd900cb041..71896a18f48 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py +++ b/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py @@ -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 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx index 273e478528e..6c15b3c418d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx @@ -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 = ({ { key: "keys", label: "Key Activity", - content: , + content: , }, { key: "endpoints", label: "Endpoint Activity", content: }, ]; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx index a92d1209567..de353948db9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx @@ -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 = ({ teams, organizations }) => { - + diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.test.tsx new file mode 100644 index 00000000000..693ac20a360 --- /dev/null +++ b/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.test.tsx @@ -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 }) => ( +
    + {Object.keys(modelMetrics).map((hash) => ( +
  • {hash}
  • + ))} +
+ ), +})); + +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 = { + "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(); + 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(); + 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(); + 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(); + 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"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.tsx new file mode 100644 index 00000000000..8287a04d0c7 --- /dev/null +++ b/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.tsx @@ -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; + hidePromptCachingMetrics?: boolean; +} + +const KeyActivityPanel: React.FC = ({ 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 ( +
+
+ + + + + setQuery(e.target.value)} + /> + {isFiltering && ( + + setQuery("")}> + + + + )} + + + Showing {shownKeys.toLocaleString()} of {totalKeys.toLocaleString()} keys + +
+ {isFiltering && totalKeys > 0 && shownKeys === 0 ? ( +

+ No keys match "{query.trim()}" in this date range +

+ ) : ( + + )} +
+ ); +}; + +export default KeyActivityPanel; diff --git a/ui/litellm-dashboard/src/components/UsagePage/keyActivityFilter.test.ts b/ui/litellm-dashboard/src/components/UsagePage/keyActivityFilter.test.ts new file mode 100644 index 00000000000..ce181f6b0c6 --- /dev/null +++ b/ui/litellm-dashboard/src/components/UsagePage/keyActivityFilter.test.ts @@ -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 = { + "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({}); + }); +}); diff --git a/ui/litellm-dashboard/src/components/UsagePage/keyActivityFilter.ts b/ui/litellm-dashboard/src/components/UsagePage/keyActivityFilter.ts new file mode 100644 index 00000000000..1e9a654fb2e --- /dev/null +++ b/ui/litellm-dashboard/src/components/UsagePage/keyActivityFilter.ts @@ -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, + query: string, +): Record { + if (query.trim() === "") return keyMetrics; + return Object.fromEntries( + Object.entries(keyMetrics).filter(([apiKey, data]) => keyActivityMatches(apiKey, data, query)), + ); +} diff --git a/ui/litellm-dashboard/src/components/UsagePage/types.ts b/ui/litellm-dashboard/src/components/UsagePage/types.ts index a10e9e68c4d..fd4f1350020 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/types.ts +++ b/ui/litellm-dashboard/src/components/UsagePage/types.ts @@ -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; diff --git a/ui/litellm-dashboard/src/components/activity_metrics.test.tsx b/ui/litellm-dashboard/src/components/activity_metrics.test.tsx index b0fc8dc7866..914fe1872b6 100644 --- a/ui/litellm-dashboard/src/components/activity_metrics.test.tsx +++ b/ui/litellm-dashboard/src/components/activity_metrics.test.tsx @@ -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: [ diff --git a/ui/litellm-dashboard/src/components/activity_metrics.tsx b/ui/litellm-dashboard/src/components/activity_metrics.tsx index f4348fb65ae..7c40a91be29 100644 --- a/ui/litellm-dashboard/src/components/activity_metrics.tsx +++ b/ui/litellm-dashboard/src/components/activity_metrics.tsx @@ -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, diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 9e844b992b2..506e3f65a8a 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -28374,6 +28374,8 @@ export interface components { team_id?: string | null; /** User Email */ user_email?: string | null; + /** User Id */ + user_id?: string | null; }; /** * KeyMetricWithMetadata