diff --git a/Dockerfile b/Dockerfile index 890c90fe449..b6acfb346c4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 4572e59944b..861ebec292d 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -3914,6 +3914,17 @@ ], "title": "Team Id" }, + "user_alias": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "User Alias" + }, "user_email": { "anyOf": [ { diff --git a/litellm/proxy/management_endpoints/common_daily_activity.py b/litellm/proxy/management_endpoints/common_daily_activity.py index f948a49441a..72cdb7770e5 100644 --- a/litellm/proxy/management_endpoints/common_daily_activity.py +++ b/litellm/proxy/management_endpoints/common_daily_activity.py @@ -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() } diff --git a/litellm/proxy/management_endpoints/daily_activity_routes.py b/litellm/proxy/management_endpoints/daily_activity_routes.py index 842873129e4..6967eada047 100644 --- a/litellm/proxy/management_endpoints/daily_activity_routes.py +++ b/litellm/proxy/management_endpoints/daily_activity_routes.py @@ -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, ) diff --git a/litellm/proxy/spend_tracking/key_metadata_recovery.py b/litellm/proxy/spend_tracking/key_metadata_recovery.py index 5442c0f3f81..814399b7b07 100644 --- a/litellm/proxy/spend_tracking/key_metadata_recovery.py +++ b/litellm/proxy/spend_tracking/key_metadata_recovery.py @@ -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 diff --git a/litellm/repositories/daily_activity_repository.py b/litellm/repositories/daily_activity_repository.py index 10c603fe56f..a7db443bd22 100644 --- a/litellm/repositories/daily_activity_repository.py +++ b/litellm/repositories/daily_activity_repository.py @@ -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: diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index 02d9566d396..b2348d6f902 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -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: diff --git a/litellm/types/proxy/management_endpoints/common_daily_activity.py b/litellm/types/proxy/management_endpoints/common_daily_activity.py index 213c911cfdc..7b9a26275c7 100644 --- a/litellm/types/proxy/management_endpoints/common_daily_activity.py +++ b/litellm/types/proxy/management_endpoints/common_daily_activity.py @@ -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 diff --git a/litellm/types/repositories/daily_activity.py b/litellm/types/repositories/daily_activity.py index 4111b83a891..554085bbe34 100644 --- a/litellm/types/repositories/daily_activity.py +++ b/litellm/types/repositories/daily_activity.py @@ -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): diff --git a/tests/unit/proxy/management_endpoints/test_common_daily_activity.py b/tests/unit/proxy/management_endpoints/test_common_daily_activity.py index 01d2f7007a1..a2435d2d4a5 100644 --- a/tests/unit/proxy/management_endpoints/test_common_daily_activity.py +++ b/tests/unit/proxy/management_endpoints/test_common_daily_activity.py @@ -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 diff --git a/tests/unit/proxy/spend_tracking/test_key_metadata_recovery.py b/tests/unit/proxy/spend_tracking/test_key_metadata_recovery.py index 7a0767257dc..6030cc9c373 100644 --- a/tests/unit/proxy/spend_tracking/test_key_metadata_recovery.py +++ b/tests/unit/proxy/spend_tracking/test_key_metadata_recovery.py @@ -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 [ diff --git a/tests/unit/responses/litellm_completion_transformation/test_tool_output_order_preserved_for_gemini.py b/tests/unit/responses/litellm_completion_transformation/test_tool_output_order_preserved_for_gemini.py index 3dec1d571c4..331b5ca78ff 100644 --- a/tests/unit/responses/litellm_completion_transformation/test_tool_output_order_preserved_for_gemini.py +++ b/tests/unit/responses/litellm_completion_transformation/test_tool_output_order_preserved_for_gemini.py @@ -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" 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 04b4dcf9be1..d6a170d8a9e 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 @@ -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(); + + 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(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx index a75afd63633..346e0be77e4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx @@ -272,14 +272,15 @@ const EntityUsage: React.FC = ({ 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; }; 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 54569608b0e..5b4e405b402 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 @@ -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, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/teamUserSpend.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/teamUserSpend.test.ts index 36d442c617f..27d2576a028 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/teamUserSpend.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/teamUserSpend.test.ts @@ -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"); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/teamUserSpend.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/teamUserSpend.ts index d0b47a4e5c0..780a480566b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/teamUserSpend.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/teamUserSpend.ts @@ -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; }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx index b94e2b11b0a..dfd9abc2981 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx @@ -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(); + + 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(); 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 feb1adb0f1d..50499fc05c4 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 @@ -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 = ({ 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(); + + 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), 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 a9c87471511..31396115af1 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 @@ -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(() => { 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 90a26919e01..aaac6402dbe 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx @@ -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 = ({ 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) { diff --git a/ui/litellm-dashboard/src/components/UsagePage/dailyActivityApi.ts b/ui/litellm-dashboard/src/components/UsagePage/dailyActivityApi.ts index c3201b563b1..26ead54dd70 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/dailyActivityApi.ts +++ b/ui/litellm-dashboard/src/components/UsagePage/dailyActivityApi.ts @@ -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, }); diff --git a/ui/litellm-dashboard/src/components/UsagePage/keyActivityLabel.test.ts b/ui/litellm-dashboard/src/components/UsagePage/keyActivityLabel.test.ts index eaf1985c5fa..2b715bb23b8 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/keyActivityLabel.test.ts +++ b/ui/litellm-dashboard/src/components/UsagePage/keyActivityLabel.test.ts @@ -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"); }); diff --git a/ui/litellm-dashboard/src/components/UsagePage/keyActivityLabel.ts b/ui/litellm-dashboard/src/components/UsagePage/keyActivityLabel.ts index 8b3a7eec916..867be288522 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/keyActivityLabel.ts +++ b/ui/litellm-dashboard/src/components/UsagePage/keyActivityLabel.ts @@ -1,8 +1,9 @@ import type { KeyMetadata } from "./types"; export function keyActivityLabel( - metadata: Pick | null | undefined, + metadata: Pick | 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; } diff --git a/ui/litellm-dashboard/src/components/UsagePage/types.ts b/ui/litellm-dashboard/src/components/UsagePage/types.ts index 198c9315046..ffa9b8586ab 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/types.ts +++ b/ui/litellm-dashboard/src/components/UsagePage/types.ts @@ -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 }[]; } diff --git a/ui/litellm-dashboard/src/components/templates/key_info_view.test.tsx b/ui/litellm-dashboard/src/components/templates/key_info_view.test.tsx index a1d3e498dd4..3c806eeedcc 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.test.tsx @@ -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) => { + vi.mocked(useAuthorized).mockReturnValue({ ...baseUseAuthorizedMock, userRole: "Admin" }); + renderWithProviders( + {}} 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) => { vi.mocked(useAuthorized).mockReturnValue(baseUseAuthorizedMock); diff --git a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx index 7b924243fb5..2afd9d4a9a9 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx @@ -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 (
@@ -931,7 +933,7 @@ export default function KeyInfoView({

Key Alias

-

{currentKeyData.key_alias || "Not Set"}

+

{keyAliasLabel}

diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index bd08b743c7d..5c8b6394acd 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -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 */ diff --git a/ui/litellm-dashboard/tests/top_key_view.test.tsx b/ui/litellm-dashboard/tests/top_key_view.test.tsx index 6751b639339..2b851da6e0d 100644 --- a/ui/litellm-dashboard/tests/top_key_view.test.tsx +++ b/ui/litellm-dashboard/tests/top_key_view.test.tsx @@ -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 } } }) => ( +
{`Owner: ${keyData.user?.user_alias}|${keyData.user_email}|${keyData.user_id}`}
+ ), })); 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>); + + renderWithProviders( + , + ); + + 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(); + }); }); });