diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 4cfb2bf8c38..06e157498aa 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -3247,6 +3247,17 @@ ], "title": "Key Alias" }, + "key_exists": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Key Exists" + }, "team_id": { "anyOf": [ { diff --git a/litellm/proxy/management_endpoints/common_daily_activity.py b/litellm/proxy/management_endpoints/common_daily_activity.py index a1c92d37871..5a19d743105 100644 --- a/litellm/proxy/management_endpoints/common_daily_activity.py +++ b/litellm/proxy/management_endpoints/common_daily_activity.py @@ -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 } diff --git a/litellm/proxy/spend_tracking/key_metadata_recovery.py b/litellm/proxy/spend_tracking/key_metadata_recovery.py index 29688b61b3d..ee2e1cfeaf7 100644 --- a/litellm/proxy/spend_tracking/key_metadata_recovery.py +++ b/litellm/proxy/spend_tracking/key_metadata_recovery.py @@ -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): diff --git a/litellm/types/proxy/management_endpoints/common_daily_activity.py b/litellm/types/proxy/management_endpoints/common_daily_activity.py index 5d42b1230a0..2a4f6b2944a 100644 --- a/litellm/types/proxy/management_endpoints/common_daily_activity.py +++ b/litellm/types/proxy/management_endpoints/common_daily_activity.py @@ -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): 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 fc3ede88aa9..baaf3f4ba2f 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 @@ -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( diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx index 5846a63bc70..6bd16095351 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx @@ -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): 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 }[] }) => (
Top Keys - {`top-keys:${topKeys.map((row) => `${row.api_key}=${row.spend}`).join("|")}`} + {`top-keys:${topKeys.map((row) => `${row.api_key}=${row.spend}=${row.user ?? "-"}`).join("|")}`}
), })); @@ -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(); @@ -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(); 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(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/entityUsageAggregations.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/entityUsageAggregations.ts index d482a5576ae..54569608b0e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/entityUsageAggregations.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/entityUsageAggregations.ts @@ -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) 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 a9ab0f17f40..228d8acf146 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 @@ -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 = ({ 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( + () => 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()), diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.test.tsx index c2837cf412e..e65094c5228 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.test.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.test.tsx @@ -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( + , + ); + + expect(screen.queryByText("User")).not.toBeInTheDocument(); + + rerender( + , + ); + + expect(screen.getByText("User")).toBeInTheDocument(); + expect(screen.getByText("alice@example.com")).toBeInTheDocument(); + }); + + it("should render a user ID in the User column", () => { + render( + , + ); + + 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(); @@ -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( + , + ); + + 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); }); }); diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx index df1a51d8e38..178a5b7ec37 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx @@ -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 = ({ 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 = ({ topKeys, teams, showTags = fals { header: "Key ID", accessorKey: "api_key", - cell: (info: any) => handleKeyClick(info.row.original)} />, + cell: (info: any) => + canOpenKeyInfo(info.row.original) ? ( + handleKeyClick(info.row.original)} /> + ) : ( + + ), }, { 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 = { diff --git a/ui/litellm-dashboard/src/components/UsagePage/types.ts b/ui/litellm-dashboard/src/components/UsagePage/types.ts index e8bd3cb3a87..d53db68bb9f 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/types.ts +++ b/ui/litellm-dashboard/src/components/UsagePage/types.ts @@ -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 }[]; } diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 4fe8bff3da8..7aa34c5752c 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -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 */ diff --git a/ui/litellm-dashboard/tests/top_key_view.test.tsx b/ui/litellm-dashboard/tests/top_key_view.test.tsx index 51662b8f453..6751b639339 100644 --- a/ui/litellm-dashboard/tests/top_key_view.test.tsx +++ b/ui/litellm-dashboard/tests/top_key_view.test.tsx @@ -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(); - 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(); - 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(); - 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, },