mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-21 00:21:49 +00:00
Merge pull request #40729 from BerriAI/litellm_lit7572_top_keys_user_column
fix(ui): show user attribution in Top Virtual Keys usage tables
This commit is contained in:
commit
659cef0f57
13 changed files with 415 additions and 67 deletions
|
|
@ -3247,6 +3247,17 @@
|
|||
],
|
||||
"title": "Key Alias"
|
||||
},
|
||||
"key_exists": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Key Exists"
|
||||
},
|
||||
"team_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<string, KeyMetricWithMetadata>): 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 }[] }) => (
|
||||
<div>
|
||||
<span>Top Keys</span>
|
||||
<span>{`top-keys:${topKeys.map((row) => `${row.api_key}=${row.spend}`).join("|")}`}</span>
|
||||
<span>{`top-keys:${topKeys.map((row) => `${row.api_key}=${row.spend}=${row.user ?? "-"}`).join("|")}`}</span>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
|
@ -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(<EntityUsage {...defaultProps} />);
|
||||
|
||||
|
|
@ -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(<EntityUsage {...defaultProps} entityType="team" />);
|
||||
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<UsagePageProps> = ({ 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<TopKeyItem[]>(
|
||||
() => 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()),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<TopKeyView
|
||||
{...baseProps}
|
||||
topKeys={[
|
||||
{
|
||||
api_key: "key-123",
|
||||
key_alias: "Key without user",
|
||||
user: null,
|
||||
spend: 100,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText("User")).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<TopKeyView
|
||||
{...baseProps}
|
||||
topKeys={[
|
||||
{
|
||||
api_key: "key-123",
|
||||
key_alias: "",
|
||||
user: "alice@example.com",
|
||||
spend: 100,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("User")).toBeInTheDocument();
|
||||
expect(screen.getByText("alice@example.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render a user ID in the User column", () => {
|
||||
render(
|
||||
<TopKeyView
|
||||
{...baseProps}
|
||||
topKeys={[
|
||||
{
|
||||
api_key: "key-123",
|
||||
key_alias: "Key with user ID",
|
||||
user: "user-123",
|
||||
spend: 100,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(<TopKeyView {...baseProps} />);
|
||||
|
|
@ -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(
|
||||
<TopKeyView
|
||||
{...baseProps}
|
||||
topKeys={[
|
||||
{ api_key: "session-key", key_alias: null, user: "alice@example.com", key_exists: false, spend: 100 },
|
||||
{ api_key: "stored-key", key_alias: "Stored", user: null, key_exists: true, spend: 50 },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<TopKeyViewProps> = ({ 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<TopKeyViewProps> = ({ topKeys, teams, showTags = fals
|
|||
{
|
||||
header: "Key ID",
|
||||
accessorKey: "api_key",
|
||||
cell: (info: any) => <IdCell value={info.getValue()} onClick={() => handleKeyClick(info.row.original)} />,
|
||||
cell: (info: any) =>
|
||||
canOpenKeyInfo(info.row.original) ? (
|
||||
<IdCell value={info.getValue()} onClick={() => handleKeyClick(info.row.original)} />
|
||||
) : (
|
||||
<IdCell value={info.getValue()} variant="plain" tooltip={KEY_NOT_IN_DATABASE_TOOLTIP} />
|
||||
),
|
||||
},
|
||||
{
|
||||
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 = {
|
||||
|
|
|
|||
|
|
@ -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 }[];
|
||||
}
|
||||
|
||||
|
|
|
|||
2
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
2
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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(<TopKeyView {...mockProps} topKeys={keysWithoutTags} showTags={true} />);
|
||||
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(<TopKeyView {...mockProps} topKeys={keysWithUndefinedTags} showTags={true} />);
|
||||
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(<TopKeyView {...mockProps} topKeys={keysWithNullTags} showTags={true} />);
|
||||
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,
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue