This commit is contained in:
WinJay 2026-10-21 14:07:26 +00:00 • committed by GitHub
commit b7feeb461c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 443 additions and 75 deletions

View file

@ -54,17 +54,21 @@ USER root
COPY --from=uvbin /uv /usr/local/bin/uv
COPY --from=uvbin /uvx /usr/local/bin/uvx
RUN apk add --no-cache \
bash \
gcc \
python-3.13 \
python-3.13-dev \
rust \
openssl \
openssl-dev \
nodejs \
npm \
libsndfile
RUN for attempt in 1 2 3 4 5; do \
apk add --no-cache \
bash \
gcc \
python-3.13 \
python-3.13-dev \
rust \
openssl \
openssl-dev \
nodejs \
npm \
libsndfile && break; \
[ "$attempt" -eq 5 ] && exit 1; \
sleep 3; \
done
ENV UV_PROJECT_ENVIRONMENT=/app/.venv \
UV_LINK_MODE=copy \
@ -141,7 +145,11 @@ USER root
RUN echo "https://packages.wolfi.dev/os" >> /etc/apk/repositories
# node (without npm) is required by the prisma CLI at runtime
RUN apk add --no-cache bash openssl tzdata nodejs python-3.13 libsndfile libevent
RUN for attempt in 1 2 3 4 5; do \
apk add --no-cache bash openssl tzdata nodejs python-3.13 libsndfile libevent && break; \
[ "$attempt" -eq 5 ] && exit 1; \
sleep 3; \
done
COPY --from=pgbouncer-builder /usr/local/bin/pgbouncer /usr/local/bin/pgbouncer
WORKDIR /app

View file

@ -3914,6 +3914,17 @@
],
"title": "Team Id"
},
"user_alias": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "User Alias"
},
"user_email": {
"anyOf": [
{

View file

@ -168,6 +168,7 @@ class _KeyMetadataDict(TypedDict, total=False):
team_id: ReadOnly[str | None]
user_id: ReadOnly[str | None]
user_email: ReadOnly[str | None]
user_alias: ReadOnly[str | None]
key_exists: ReadOnly[bool]
@ -183,6 +184,7 @@ def _key_metadata(api_key_metadata: Mapping[str, KeyMetadataRow], api_key: str)
team_id=meta.team_id if meta is not None else None,
user_id=meta.user_id if meta is not None else None,
user_email=meta.user_email if meta is not None else None,
user_alias=meta.user_alias if meta is not None else None,
key_exists=meta.key_exists if meta is not None else False,
)
@ -492,6 +494,7 @@ class _ProxyDailyActivityReads:
"team_id": row.team_id,
"user_id": row.user_id,
"user_email": row.user_email,
"user_alias": row.user_alias,
"key_exists": row.key_exists,
}
for key, row in resolved.items()
@ -534,6 +537,7 @@ class _ProxyDailyActivityReads:
team_id=value.get("team_id"),
user_id=value.get("user_id"),
user_email=value.get("user_email"),
user_alias=value.get("user_alias"),
key_exists=value.get("key_exists", False),
)
if key in resolved
@ -545,6 +549,7 @@ class _ProxyDailyActivityReads:
user_email=value.get("user_email"),
key_exists=value.get("key_exists", False),
tags=(),
user_alias=value.get("user_alias"),
)
for key, value in attached.items()
}

View file

@ -137,6 +137,7 @@ def _key_metadata(api_key: str, metadata: Mapping[str, KeyMetadataRow]) -> KeyMe
team_id=row.team_id,
user_id=row.user_id,
user_email=row.user_email,
user_alias=row.user_alias,
key_exists=row.key_exists,
)

View file

@ -116,6 +116,7 @@ class KeyMetadataDict(TypedDict, total=False):
team_id: ReadOnly[str | None]
user_id: ReadOnly[str | None]
user_email: ReadOnly[str | None]
user_alias: ReadOnly[str | None]
key_exists: ReadOnly[bool]
@ -249,6 +250,7 @@ async def recover_key_owner_from_daily_spend(
@dataclass(frozen=True, slots=True)
class _UserDetails:
email: str | None
alias: str | None
only_team: str | None
@ -276,6 +278,7 @@ async def _details_for_user_ids(
{
user.user_id: _UserDetails(
email=getattr(user, "user_email", None) or None,
alias=getattr(user, "user_alias", None) or None,
only_team=_only_team(getattr(user, "teams", None)),
)
for user in users
@ -303,10 +306,12 @@ def _meta_with_user_details(
return meta
user: Final = details[user_id]
email: Final = meta.get("user_email") or user.email
alias: Final = meta.get("user_alias") or user.alias
team_id: Final = meta.get("team_id") or (user.only_team if _is_cli_session_key(api_key) else None)
updated: Final[KeyMetadataDict] = {
**meta,
**({"user_email": email} if email else {}),
**({"user_alias": alias} if alias else {}),
**({"team_id": team_id} if team_id else {}),
}
return updated

View file

@ -314,6 +314,7 @@ class DailyActivityRepository:
user_email=None,
key_exists=key_exists,
tags=tags,
user_alias=None,
)
async def daily_rows(self, scope: DailyActivityScope, *, page: int, page_size: int) -> DailyRowsPage:

View file

@ -1487,6 +1487,10 @@ class LiteLLMCompletionResponsesConfig:
already carries, because Anthropic and Bedrock verify signatures against
the original block order.
A reasoning item replayed *after* the tool calls of its own turn rides
on that assistant message instead of becoming a standalone one, so the
tool results keep following the assistant that requested them.
If the reasoning item is not followed by an assistant message (e.g. a
stateless chain replays ``reasoning`` + ``user``), the standalone
reasoning message is preserved so the reasoning is still passed back.
@ -1573,7 +1577,10 @@ class LiteLLMCompletionResponsesConfig:
pending.append((_reasoning_text(msg), _thinking_blocks(msg)))
continue
if pending and _role(msg) == "assistant":
if pending and merged and _role(merged[-1]) == "assistant" and _tool_calls(merged[-1]):
_apply_pending(merged[-1], pending)
pending = []
elif pending and _role(msg) == "assistant":
_apply_pending(msg, pending)
pending = []
elif pending:

View file

@ -50,6 +50,7 @@ class KeyMetadata(LiteLLMBaseModel):
team_id: str | None = None
user_id: str | None = None
user_email: str | None = None
user_alias: str | None = None
key_exists: bool | None = None

View file

@ -98,6 +98,7 @@ class KeyMetadataRow:
user_email: str | None
key_exists: bool
tags: tuple[str, ...]
user_alias: str | None = None
class ExportType(str, Enum):

View file

@ -569,6 +569,39 @@ async def test_recovered_key_metadata_preserves_resolved_tags_after_user_details
attach_details.assert_awaited_once()
@pytest.mark.asyncio
async def test_recovered_key_metadata_carries_the_user_alias(
monkeypatch: pytest.MonkeyPatch,
) -> None:
resolved: Final = KeyMetadataRow(
api_key="key-hash",
key_alias=None,
team_id=None,
user_id="user-id",
user_email=None,
key_exists=True,
tags=(),
)
attach_details: Final = AsyncMock(
return_value={
"key-hash": {
"key_alias": None,
"team_id": None,
"user_id": "user-id",
"user_email": "user@example.com",
"user_alias": "Alice A",
"key_exists": True,
}
}
)
monkeypatch.setattr(common_daily_activity_module, "attach_user_details", attach_details)
reads: Final = _ProxyDailyActivityReads(MagicMock())
result: Final = await reads.recover_key_metadata({"key-hash": resolved}, frozenset(("key-hash",)), None)
assert result["key-hash"].user_alias == "Alice A"
@pytest.mark.asyncio
async def test_get_api_key_metadata_falls_back_to_deleted_keys():
"""Test that get_api_key_metadata should fall back to deleted keys table for missing keys."""
@ -926,6 +959,28 @@ def test_key_metadata_includes_user_id_without_user_email():
assert meta.user_email is None
def test_key_metadata_includes_recovered_user_alias():
from litellm.proxy.management_endpoints.common_daily_activity import _key_metadata
meta = _key_metadata(
{
"dirty-key": KeyMetadataRow(
api_key="dirty-key",
key_alias=None,
team_id="team-1",
user_id="user-123",
user_email=None,
key_exists=True,
tags=(),
user_alias="Alice A",
)
},
"dirty-key",
)
assert meta.user_alias == "Alice A"
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

View file

@ -756,6 +756,23 @@ async def test_attach_user_details_claims_no_team_for_a_multi_team_user_session_
assert attached["cli-session-bob"]["user_email"] == "bob@example.com"
@pytest.mark.asyncio
async def test_attach_user_details_carries_the_user_alias():
mock_prisma = MagicMock()
mock_prisma.db.litellm_usertable.find_many = AsyncMock(
return_value=[
SimpleNamespace(user_id="alice", user_email="alice@example.com", user_alias="Alice A", teams=[])
]
)
personal_key = hash_token("sk-personal")
attached = await attach_user_details(
mock_prisma, {personal_key: {"key_alias": "personal", "user_id": "alice"}}
)
assert attached[personal_key]["user_alias"] == "Alice A"
def _user_lookup_by_filter() -> AsyncMock:
async def find_many(*, where):
return [

View file

@ -157,3 +157,39 @@ def test_assistant_message_before_function_call_keeps_one_assistant_turn():
assistant_idx = msgs.index(assistant)
assert msgs[assistant_idx + 1].get("role") == "tool"
assert msgs[assistant_idx + 1].get("tool_call_id") == "call_1"
def test_reasoning_replayed_after_the_tool_call_keeps_the_tool_output_adjacent():
"""DeepSeek rejects a tool call whose turn replays its reasoning after the calls."""
msgs = LiteLLMCompletionResponsesConfig._transform_response_input_param_to_chat_completion_message(
input=[
{
"role": "user",
"type": "message",
"content": [{"type": "input_text", "text": "List the files."}],
},
{
"type": "function_call",
"name": "shell_command",
"call_id": "call_42",
"arguments": '{"command": "ls"}',
},
{
"type": "reasoning",
"id": "rs_1",
"summary": [{"type": "summary_text", "text": "The user wants the files listed."}],
},
{
"type": "function_call_output",
"call_id": "call_42",
"output": "file1\nfile2",
},
],
replay_reasoning=True,
)
assert [message["role"] for message in msgs] == ["user", "assistant", "tool"]
assistant = msgs[1]
assert [call["id"] for call in assistant["tool_calls"]] == ["call_42"]
assert assistant["reasoning_content"] == "The user wants the files listed."
assert msgs[2]["tool_call_id"] == "call_42"

View file

@ -540,6 +540,36 @@ describe("EntityUsage", () => {
expect(getTopAPIKeys(results, 5)[0]?.user).toBe("user-123");
});
it("names the owner by alias rather than email for global and entity top keys", () => {
const results = [
createDailyData("2025-01-01", {
"key-123": createKeyMetrics(12.5, {
key_alias: null,
team_id: null,
user_id: "user-123",
user_email: "alice@example.com",
user_alias: "Alice Chen",
}),
}),
];
expect(getGlobalTopKeys(results, 5)[0]?.key_alias).toBe("Alice Chen");
expect(getGlobalTopKeys(results, 5)[0]?.user).toBe("Alice Chen");
expect(getTopAPIKeys(results, 5)[0]?.key_alias).toBe("Alice Chen");
expect(getTopAPIKeys(results, 5)[0]?.user).toBe("Alice Chen");
});
it("labels keys with their owner when no alias or email was recorded", () => {
const results = [
createDailyData("2025-01-01", {
"key-123": createKeyMetrics(12.5, { key_alias: null, team_id: null, user_id: "user-123" }),
}),
];
expect(getGlobalTopKeys(results, 5)[0]?.key_alias).toBe("user-123");
expect(getTopAPIKeys(results, 5)[0]?.key_alias).toBe("user-123");
});
it("carries whether each key still exists for global and entity top keys", () => {
const results = [
createDailyData("2025-01-01", {
@ -1159,6 +1189,37 @@ describe("EntityUsage", () => {
expect(screen.queryByText(userUuid)).not.toBeInTheDocument();
});
it("prefers the user alias when labelling an owner who has both a name and an email", async () => {
const userUuid = "c0e68be8-057e-4e2f-9d3a-000000000001";
const spendDataForUser = {
...mockSpendData,
results: [
{
...mockSpendData.results[0],
breakdown: {
...mockSpendData.results[0].breakdown,
entities: {
[userUuid]: {
...mockSpendData.results[0].breakdown.entities["tag-1"],
metadata: { user_alias: "Chen Yan", user_email: "spender@example.com" },
},
},
},
},
],
};
mockUserDailyActivityCall.mockResolvedValue(spendDataForUser);
// entityList is null to simulate a spender missing from the paginated user list
render(<StatefulEntityUsage {...defaultProps} entityType="user" entityList={null} />);
await waitFor(() => {
expect(screen.getByText("Chen Yan")).toBeInTheDocument();
});
expect(screen.queryByText("spender@example.com")).not.toBeInTheDocument();
});
it("renders the provider spend table logo from the bundled provider map", async () => {
render(<StatefulEntityUsage {...defaultProps} />);

View file

@ -272,14 +272,15 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
if (metadata?.team_alias) {
return metadata.team_alias;
}
// Resolve user_id to email/alias so the Spend Per User chart never shows a raw UUID
// when an email is on file (the entityList is paginated and may miss spenders)
if (metadata?.user_email) {
return metadata.user_email;
}
// Resolve user_id to the owner's name or email so the Spend Per User chart never
// shows a raw UUID when either is on file (the entityList is paginated and may
// miss spenders)
if (metadata?.user_alias) {
return metadata.user_alias;
}
if (metadata?.user_email) {
return metadata.user_email;
}
return entity;
};

View file

@ -108,6 +108,7 @@ export const getGlobalTopKeys = (results: DailyData[], topKeysLimit: number): To
team_id: null,
user_id: metrics.metadata.user_id,
user_email: metrics.metadata.user_email,
user_alias: metrics.metadata.user_alias,
key_exists: metrics.metadata.key_exists,
tags: metrics.metadata.tags || [],
},
@ -128,8 +129,8 @@ export const getGlobalTopKeys = (results: DailyData[], topKeysLimit: number): To
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_alias: keyActivityLabel(metrics.metadata, metrics.metadata.user_id ?? "-"),
user: metrics.metadata.user_alias ?? metrics.metadata.user_email ?? metrics.metadata.user_id ?? null,
key_exists: metrics.metadata.key_exists,
tags: metrics.metadata.tags || [],
spend: metrics.metrics.spend,
@ -174,6 +175,7 @@ export const getTopAPIKeys = (results: ExtendedDailyData[], topKeysLimit: number
team_id: metrics.metadata.team_id || null,
user_id: metrics.metadata.user_id,
user_email: metrics.metadata.user_email,
user_alias: metrics.metadata.user_alias,
key_exists: metrics.metadata.key_exists,
tags: tagDictionary[key] || [],
},
@ -194,8 +196,8 @@ export const getTopAPIKeys = (results: ExtendedDailyData[], topKeysLimit: number
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_alias: keyActivityLabel(metrics.metadata, metrics.metadata.user_id ?? "-"),
user: metrics.metadata.user_alias ?? metrics.metadata.user_email ?? metrics.metadata.user_id ?? null,
key_exists: metrics.metadata.key_exists,
tags: metrics.metadata.tags || [],
spend: metrics.metrics.spend,

View file

@ -54,7 +54,8 @@ describe("teamUserSpend", () => {
expect(ids[0]).not.toBe(ids[1]);
});
it("labels a user by email, then alias, then id, then a placeholder", () => {
it("labels a user by alias, then email, then id, then a placeholder", () => {
expect(userLabel(row({ user_alias: "Alice C" }))).toBe("Alice C");
expect(userLabel(row({}))).toBe("alice@example.com");
expect(userLabel(row({ user_email: null, user_alias: "Bob", user_id: "u1" }))).toBe("Bob");
expect(userLabel(row({ user_email: null, user_alias: null, user_id: "u1" }))).toBe("u1");

View file

@ -7,7 +7,7 @@ export type TeamUserSpendRow = TeamUserSpendResponse["results"][number];
export const NO_USER_LABEL = "(no user)";
export const userLabel = (row: TeamUserSpendRow): string => {
const identity = row.user_email || row.user_alias;
const identity = row.user_alias || row.user_email;
return identity || row.user_id || NO_USER_LABEL;
};

View file

@ -826,6 +826,32 @@ describe("UsagePage", () => {
expect(screen.getAllByText("gpt-4").length).toBeGreaterThan(0);
});
it("should render one daily spend bar when the aggregated response repeats a date", async () => {
const repeatedDay = mockSpendData.results[0];
mockUserDailyActivityAggregatedCall.mockResolvedValue({
...mockSpendData,
results: [repeatedDay, { ...repeatedDay, metrics: { ...repeatedDay.metrics } }],
});
const { container } = renderWithProviders(<UsagePage {...defaultProps} />);
await waitFor(() => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
});
const spendBars = () => {
const gatewayCard = container.querySelector('[data-testid="gateway-requests-by-endpoint"]');
return Array.from(container.querySelectorAll("path.recharts-rectangle")).filter(
(rect) => !gatewayCard?.contains(rect),
);
};
// The repeated date collapses into one day stacked as the top model over the remainder.
await waitFor(() => {
expect(spendBars()).toHaveLength(2);
});
});
it("should switch between usage views correctly", async () => {
renderWithProviders(<UsagePage {...defaultProps} />);

View file

@ -56,6 +56,7 @@ import {
type DailyActivityRequest,
} from "@/components/UsagePage/dailyActivityApi";
import { keyDetailFromResponse, overallUsageMetrics } from "@/components/UsagePage/keyActivityData";
import type { DailyData } from "@/components/UsagePage/types";
import {
fetchedRangeKey,
selectForRange,
@ -331,10 +332,25 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
[userSpendData.results, topKeysLimit],
);
const sortedDailyResults = useMemo(
() => [...userSpendData.results].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()),
[userSpendData.results],
);
const sortedDailyResults = useMemo(() => {
const resultsByDate = new Map<string, DailyData>();
userSpendData.results.forEach((result) => {
const existing = resultsByDate.get(result.date);
if (!existing) {
resultsByDate.set(result.date, { ...result, metrics: { ...result.metrics } });
return;
}
existing.metrics.spend += result.metrics.spend;
existing.metrics.api_requests += result.metrics.api_requests;
existing.metrics.successful_requests += result.metrics.successful_requests;
existing.metrics.failed_requests += result.metrics.failed_requests;
existing.metrics.total_tokens += result.metrics.total_tokens;
});
return [...resultsByDate.values()].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
}, [userSpendData.results]);
const gatewayRequestsByRoute = useMemo(() => topGatewayRoutes(gatewayActivity), [gatewayActivity]);
const modelMetrics = useMemo(
() => processActivityData(userSpendData, modelViewType === "groups" ? "model_groups" : "models", teams),

View file

@ -3,7 +3,6 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { KeyResponse } from "../../../key_team_helpers/key_list";
import * as transformKeyInfo from "../../../key_team_helpers/transform_key_info";
import * as networking from "../../../networking";
import TopKeyView from "./TopKeyView";
@ -13,11 +12,7 @@ vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
}));
vi.mock("../../../networking", () => ({
keyInfoV1Call: vi.fn(),
}));
vi.mock("../../../key_team_helpers/transform_key_info", () => ({
transformKeyInfo: vi.fn(),
keyListCall: vi.fn(),
}));
vi.mock("../../../templates/key_info_view", () => ({
@ -33,8 +28,7 @@ const chartBars = (container: HTMLElement) => Array.from(container.querySelector
describe("TopKeyView", () => {
const mockUseAuthorized = vi.mocked(useAuthorized);
const mockKeyInfoV1Call = vi.mocked(networking.keyInfoV1Call);
const mockTransformKeyInfo = vi.mocked(transformKeyInfo.transformKeyInfo);
const mockKeyListCall = vi.mocked(networking.keyListCall);
const mockAuth = {
token: "mock-token",
@ -60,8 +54,7 @@ describe("TopKeyView", () => {
beforeEach(() => {
mockUseAuthorized.mockReturnValue(mockAuth);
mockSetTopKeysLimit.mockClear();
mockKeyInfoV1Call.mockClear();
mockTransformKeyInfo.mockClear();
mockKeyListCall.mockClear();
});
it("should render", () => {
@ -186,10 +179,8 @@ describe("TopKeyView", () => {
});
it("renders brand-blue bars with truncated aliases in chart view and opens the key info modal on bar click", async () => {
const mockKeyInfo = { key: "info" };
const mockTransformedData = { transformed: "data" } as unknown as KeyResponse;
mockKeyInfoV1Call.mockResolvedValue(mockKeyInfo);
mockTransformKeyInfo.mockReturnValue(mockTransformedData);
mockKeyListCall.mockResolvedValue({ keys: [mockTransformedData] });
const user = userEvent.setup();
const { container } = render(
@ -216,7 +207,19 @@ describe("TopKeyView", () => {
fireEvent.click(bars[0]);
await waitFor(() => {
expect(mockKeyInfoV1Call).toHaveBeenCalledWith("test-token", "key-123");
expect(mockKeyListCall).toHaveBeenCalledWith(
"test-token",
null,
null,
null,
null,
"key-123",
1,
1,
null,
null,
"user",
);
});
await waitFor(() => {
@ -459,10 +462,8 @@ describe("TopKeyView", () => {
});
it("should open modal when key ID is clicked", async () => {
const mockKeyInfo = { key: "info" };
const mockTransformedData = { transformed: "data" } as unknown as KeyResponse;
mockKeyInfoV1Call.mockResolvedValue(mockKeyInfo);
mockTransformKeyInfo.mockReturnValue(mockTransformedData);
mockKeyListCall.mockResolvedValue({ keys: [mockTransformedData] });
const user = userEvent.setup();
render(
@ -485,7 +486,19 @@ describe("TopKeyView", () => {
}
await waitFor(() => {
expect(mockKeyInfoV1Call).toHaveBeenCalledWith("test-token", "key-123");
expect(mockKeyListCall).toHaveBeenCalledWith(
"test-token",
null,
null,
null,
null,
"key-123",
1,
1,
null,
null,
"user",
);
});
await waitFor(() => {
@ -494,8 +507,8 @@ 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 mockTransformedData = { transformed: "data" } as unknown as KeyResponse;
mockKeyListCall.mockResolvedValue({ keys: [mockTransformedData] });
const user = userEvent.setup();
const { container } = render(
@ -518,15 +531,25 @@ describe("TopKeyView", () => {
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");
expect(mockKeyListCall).toHaveBeenCalledTimes(1);
expect(mockKeyListCall).toHaveBeenCalledWith(
"test-token",
null,
null,
null,
null,
"stored-key",
1,
1,
null,
null,
"user",
);
});
it("should close modal when close button is clicked", async () => {
const mockKeyInfo = { key: "info" };
const mockTransformedData = { transformed: "data" } as unknown as KeyResponse;
mockKeyInfoV1Call.mockResolvedValue(mockKeyInfo);
mockTransformKeyInfo.mockReturnValue(mockTransformedData);
mockKeyListCall.mockResolvedValue({ keys: [mockTransformedData] });
const user = userEvent.setup();
render(
@ -561,10 +584,8 @@ describe("TopKeyView", () => {
});
it("should close modal when escape key is pressed", async () => {
const mockKeyInfo = { key: "info" };
const mockTransformedData = { transformed: "data" } as unknown as KeyResponse;
mockKeyInfoV1Call.mockResolvedValue(mockKeyInfo);
mockTransformKeyInfo.mockReturnValue(mockTransformedData);
mockKeyListCall.mockResolvedValue({ keys: [mockTransformedData] });
const user = userEvent.setup();
render(
@ -598,10 +619,8 @@ describe("TopKeyView", () => {
});
it("should close modal when clicking outside modal", async () => {
const mockKeyInfo = { key: "info" };
const mockTransformedData = { transformed: "data" } as unknown as KeyResponse;
mockKeyInfoV1Call.mockResolvedValue(mockKeyInfo);
mockTransformKeyInfo.mockReturnValue(mockTransformedData);
mockKeyListCall.mockResolvedValue({ keys: [mockTransformedData] });
const user = userEvent.setup();
const { container } = render(
@ -664,7 +683,7 @@ describe("TopKeyView", () => {
}
await waitFor(() => {
expect(mockKeyInfoV1Call).not.toHaveBeenCalled();
expect(mockKeyListCall).not.toHaveBeenCalled();
});
expect(screen.queryByTestId("key-info-view")).not.toBeInTheDocument();
@ -672,7 +691,7 @@ describe("TopKeyView", () => {
it("should handle error when fetching key info", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
mockKeyInfoV1Call.mockRejectedValue(new Error("Network error"));
mockKeyListCall.mockRejectedValue(new Error("Network error"));
const user = userEvent.setup();
render(
@ -695,7 +714,7 @@ describe("TopKeyView", () => {
}
await waitFor(() => {
expect(mockKeyInfoV1Call).toHaveBeenCalled();
expect(mockKeyListCall).toHaveBeenCalled();
});
await waitFor(() => {

View file

@ -7,8 +7,8 @@ import { Segmented } from "@/app/(dashboard)/usage/_components/components/overvi
import { SimpleTooltip } from "@/components/ui/tooltip";
import React, { useState } from "react";
import { formatNumberWithCommas } from "../../../../utils/dataUtils";
import { transformKeyInfo } from "../../../key_team_helpers/transform_key_info";
import { keyInfoV1Call } from "../../../networking";
import type { KeyResponse } from "../../../key_team_helpers/key_list";
import { keyListCall } from "../../../networking";
import KeyInfoView from "../../../templates/key_info_view";
import { TagUsage } from "../../types";
@ -70,10 +70,14 @@ const TopKeyView: React.FC<TopKeyViewProps> = ({ topKeys, teams, showTags = fals
if (!accessToken || !canOpenKeyInfo(item)) return;
try {
const keyInfo = await keyInfoV1Call(accessToken, item.api_key);
const transformedKeyData = transformKeyInfo(keyInfo);
const keyList = await keyListCall(accessToken, null, null, null, null, item.api_key, 1, 1, null, null, "user");
const expandedKey = keyList.keys[0] as KeyResponse | undefined;
if (!expandedKey) return;
setKeyData(transformedKeyData);
setKeyData({
...expandedKey,
user_email: expandedKey.user?.user_email ?? expandedKey.user_email,
});
setSelectedKey(item.api_key);
setIsModalOpen(true); // Open modal when key is clicked
} catch (error) {

View file

@ -70,6 +70,7 @@ export const toKeyMetadata = (metadata: components["schemas"]["KeyMetadata"] | u
team_id: metadata?.team_id ?? null,
user_id: metadata?.user_id,
user_email: metadata?.user_email,
user_alias: metadata?.user_alias,
key_exists: metadata?.key_exists,
});

View file

@ -9,6 +9,12 @@ describe("keyActivityLabel", () => {
expect(keyActivityLabel({ key_alias: null, user_email: "alice@example.com" })).toBe("alice@example.com");
});
it("prefers the user alias over the user email", () => {
expect(keyActivityLabel({ key_alias: null, user_alias: "Alice A", user_email: "alice@example.com" })).toBe(
"Alice A",
);
});
it("uses the fallback when both alias and email are missing", () => {
expect(keyActivityLabel({ key_alias: null, user_email: null }, "key-hash-abc")).toBe("key-hash-abc");
});

View file

@ -1,8 +1,9 @@
import type { KeyMetadata } from "./types";
export function keyActivityLabel(
metadata: Pick<KeyMetadata, "key_alias" | "user_email"> | null | undefined,
metadata: Pick<KeyMetadata, "key_alias" | "user_alias" | "user_email"> | null | undefined,
fallback = "-",
): string {
return metadata?.key_alias || metadata?.user_email || fallback;
const byAlias = metadata?.key_alias || metadata?.user_alias;
return byAlias || metadata?.user_email || fallback;
}

View file

@ -51,6 +51,7 @@ export interface KeyMetadata {
team_id: string | null;
user_id?: string | null;
user_email?: string | null;
user_alias?: string | null;
key_exists?: boolean | null;
tags?: { tag: string; usage: number }[];
}

View file

@ -259,6 +259,35 @@ describe("KeyInfoView", () => {
expect(screen.queryByRole("tab", { name: "Auto-router usage" })).not.toBeInTheDocument();
});
describe("key alias row", () => {
const renderKeyAliasRow = async (keyData: Partial<KeyResponse>) => {
vi.mocked(useAuthorized).mockReturnValue({ ...baseUseAuthorizedMock, userRole: "Admin" });
renderWithProviders(
<KeyInfoView keyData={{ ...MOCK_KEY_DATA, ...keyData }} onClose={() => {}} keyId="test-key-id" teams={[]} />,
);
await userEvent.click(screen.getByRole("tab", { name: "Settings" }));
return screen.getByText("Key Alias").parentElement;
};
it("shows the key owner when the key has no alias", async () => {
const row = await renderKeyAliasRow({
key_alias: null,
user: { user_id: "790", user_email: "chenyan@example.com", user_alias: "陈岩" },
});
expect(row).toHaveTextContent("陈岩");
});
it("keeps a configured alias ahead of the key owner", async () => {
const row = await renderKeyAliasRow({
key_alias: "batch-worker",
user: { user_id: "790", user_email: "chenyan@example.com", user_alias: "陈岩" },
});
expect(row).toHaveTextContent("batch-worker");
});
});
describe("last updated", () => {
const renderWithTimestamps = (overrides: Partial<KeyResponse>) => {
vi.mocked(useAuthorized).mockReturnValue(baseUseAuthorizedMock);

View file

@ -563,6 +563,8 @@ export default function KeyInfoView({
return "Unlimited";
})();
const inheritedGates = hasOwnBudget ? [] : inheritedBudgetGates(parentTeam, parentOrg, ownerUser, memberGate);
const keyOwnerLabel = currentKeyData.user?.user_alias || currentKeyData.user_email || currentKeyData.user_id;
const keyAliasLabel = currentKeyData.key_alias || keyOwnerLabel || "Not Set";
return (
<div className="w-full h-full overflow-y-auto p-4">
@ -931,7 +933,7 @@ export default function KeyInfoView({
<div>
<p className="text-sm font-medium">Key Alias</p>
<p className="text-sm">{currentKeyData.key_alias || "Not Set"}</p>
<p className="text-sm">{keyAliasLabel}</p>
</div>
<div>

View file

@ -33781,6 +33781,8 @@ export interface components {
key_exists?: boolean | null;
/** Team Id */
team_id?: string | null;
/** User Alias */
user_alias?: string | null;
/** User Email */
user_email?: string | null;
/** User Id */

View file

@ -1,17 +1,19 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderWithProviders, screen, fireEvent } from "./test-utils";
import { renderWithProviders, screen, fireEvent, waitFor } from "./test-utils";
import TopKeyView from "../src/components/UsagePage/components/EntityUsage/TopKeyView";
import { TagUsage } from "../src/components/UsagePage/types";
import useAuthorized from "../src/app/(dashboard)/hooks/useAuthorized";
import { keyListCall } from "../src/components/networking";
// Mock the networking module
vi.mock("../src/components/networking", () => ({
keyInfoV1Call: vi.fn(),
keyListCall: vi.fn(),
}));
// Mock the transform function
vi.mock("../src/components/key_team_helpers/transform_key_info", () => ({
transformKeyInfo: vi.fn((data) => data),
vi.mock("../src/components/templates/key_info_view", () => ({
default: ({ keyData }: { keyData: { user_id?: string; user_email?: string; user?: { user_alias?: string } } }) => (
<div>{`Owner: ${keyData.user?.user_alias}|${keyData.user_email}|${keyData.user_id}`}</div>
),
}));
vi.mock("../src/app/(dashboard)/hooks/useAuthorized", () => ({
@ -20,6 +22,7 @@ vi.mock("../src/app/(dashboard)/hooks/useAuthorized", () => ({
describe("TopKeyView", () => {
const mockUseAuthorized = vi.mocked(useAuthorized);
const mockKeyListCall = vi.mocked(keyListCall);
const mockProps = {
topKeys: [],
accessToken: "test-token",
@ -321,5 +324,50 @@ describe("TopKeyView", () => {
// Check that spend is formatted correctly
expect(screen.getByText("$25.50")).toBeInTheDocument();
});
it("should expand the owner when opening key details", async () => {
mockKeyListCall.mockResolvedValue({
keys: [
{
token: "key-1",
user_id: "user-1",
user_email: null,
user: {
user_id: "user-1",
user_alias: "Alice Example",
user_email: "alice@example.com",
},
},
],
} as Awaited<ReturnType<typeof keyListCall>>);
renderWithProviders(
<TopKeyView
{...mockProps}
topKeys={[{ api_key: "key-1", key_alias: "Test Key", spend: 25.5 }]}
topKeysLimit={5}
setTopKeysLimit={vi.fn()}
/>,
);
fireEvent.click(screen.getByText("key-1"));
await waitFor(() =>
expect(mockKeyListCall).toHaveBeenCalledWith(
"test-token",
null,
null,
null,
null,
"key-1",
1,
1,
null,
null,
"user",
),
);
expect(await screen.findByText("Owner: Alice Example|alice@example.com|user-1")).toBeInTheDocument();
});
});
});