From 00cf0e42c985543065b229d841e59c28bf916a6c Mon Sep 17 00:00:00 2001 From: WinJay Date: Thu, 20 Aug 2026 17:14:39 +0800 Subject: [PATCH 1/9] fix(ui): expand user details for top key view --- .../EntityUsage/TopKeyView.test.tsx | 67 ++++++++++--------- .../components/EntityUsage/TopKeyView.tsx | 14 ++-- .../tests/top_key_view.test.tsx | 58 ++++++++++++++-- 3 files changed, 99 insertions(+), 40 deletions(-) diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.test.tsx index c2837cf412e..627a6e35c47 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", () => ({ @@ -31,8 +26,7 @@ vi.mock("../../../templates/key_info_view", () => ({ 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", @@ -58,8 +52,7 @@ describe("TopKeyView", () => { beforeEach(() => { mockUseAuthorized.mockReturnValue(mockAuth); mockSetTopKeysLimit.mockClear(); - mockKeyInfoV1Call.mockClear(); - mockTransformKeyInfo.mockClear(); + mockKeyListCall.mockClear(); }); it("should render", () => { @@ -129,10 +122,8 @@ describe("TopKeyView", () => { }); it("renders cyan 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( @@ -158,7 +149,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(() => { @@ -391,10 +394,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( @@ -416,7 +417,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(() => { @@ -425,10 +438,8 @@ describe("TopKeyView", () => { }); 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( @@ -462,10 +473,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( @@ -498,10 +507,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( @@ -562,7 +569,7 @@ describe("TopKeyView", () => { } await waitFor(() => { - expect(mockKeyInfoV1Call).not.toHaveBeenCalled(); + expect(mockKeyListCall).not.toHaveBeenCalled(); }); expect(screen.queryByTestId("key-info-view")).not.toBeInTheDocument(); @@ -570,7 +577,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( @@ -592,7 +599,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 46990370918..28d3d738dd2 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx @@ -8,8 +8,8 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; 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"; @@ -47,10 +47,14 @@ const TopKeyView: React.FC = ({ topKeys, teams, showTags = fals if (!accessToken) 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/tests/top_key_view.test.tsx b/ui/litellm-dashboard/tests/top_key_view.test.tsx index 51662b8f453..a49736559bd 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", @@ -305,5 +308,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(); + }); }); }); From 5ef56c35093517ec3842523011e7b6574d88edba Mon Sep 17 00:00:00 2001 From: WinJayX Date: Tue, 13 Oct 2026 14:44:13 +0800 Subject: [PATCH 2/9] fix(ui): merge repeated daily dates into one daily spend bar The daily activity response can carry the same date more than once, which made the daily spend chart stack one bar per row and blow up the stacked series lookup on the duplicate date key. sortedDailyResults now sums the per day metrics of rows that share a date before the chart consumes them, with a regression test that repeats a date in the aggregated response and asserts a single stacked day --- .../components/UsagePageView.test.tsx | 26 +++++++++++++++++++ .../_components/components/UsagePageView.tsx | 24 ++++++++++++++--- 2 files changed, 46 insertions(+), 4 deletions(-) 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 bad366ad510..284ab4f8934 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 @@ -652,6 +652,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 e69f6f0afd8..866a1d66fcb 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 @@ -40,6 +40,7 @@ import { EMPTY_DAILY_ACTIVITY_METADATA, toDailyData, type DailyActivityRequest, + type DailyData, } from "@/components/UsagePage/dailyActivityApi"; import { keyDetailFromResponse, overallUsageMetrics } from "@/components/UsagePage/keyActivityData"; import { @@ -266,10 +267,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), From 9a5b5c851c211cf23947cf731a53273a32af31d0 Mon Sep 17 00:00:00 2001 From: WinJayX Date: Tue, 13 Oct 2026 14:44:15 +0800 Subject: [PATCH 3/9] build(docker): retry apk add so the image build survives flaky mirrors apk add intermittently failed while building the custom image, so both Alpine package steps now retry up to five times before giving up --- Dockerfile | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/Dockerfile b/Dockerfile index 7f6775413d1..521090fbf1c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -53,17 +53,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 \ @@ -140,7 +144,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 From d457504ea5ddf8302d02e2800fe46971793aaa78 Mon Sep 17 00:00:00 2001 From: WinJayX Date: Tue, 13 Oct 2026 15:23:24 +0800 Subject: [PATCH 4/9] feat(ui): show the key owner where a virtual key has no alias Most keys in the deployment carry no alias, so the alias label was empty in both places an operator looks at a key. The key detail Settings row now falls back to the owner the header already shows, taken in the same order: user alias, user email, user id. The top key tables on the usage page fall back to the key owner too, extending the existing alias-then-email label with the user id so a key without a stored email still identifies its owner --- .../EntityUsage/EntityUsage.test.tsx | 11 +++++++ .../EntityUsage/entityUsageAggregations.ts | 4 +-- .../templates/key_info_view.test.tsx | 29 +++++++++++++++++++ .../components/templates/key_info_view.tsx | 4 ++- 4 files changed, 45 insertions(+), 3 deletions(-) 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 b546b2c0558..e6f5746d2e1 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 @@ -533,6 +533,17 @@ describe("EntityUsage", () => { expect(getTopAPIKeys(results, 5)[0]?.user).toBe("user-123"); }); + 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", { 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..66a713990f8 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 @@ -128,7 +128,7 @@ export const getGlobalTopKeys = (results: DailyData[], topKeysLimit: number): To return Object.entries(keySpend) .map(([api_key, metrics]) => ({ api_key, - key_alias: keyActivityLabel(metrics.metadata), + key_alias: keyActivityLabel(metrics.metadata, metrics.metadata.user_id ?? "-"), user: metrics.metadata.user_email ?? metrics.metadata.user_id ?? null, key_exists: metrics.metadata.key_exists, tags: metrics.metadata.tags || [], @@ -194,7 +194,7 @@ export const getTopAPIKeys = (results: ExtendedDailyData[], topKeysLimit: number return Object.entries(keySpend) .map(([api_key, metrics]) => ({ api_key, - key_alias: keyActivityLabel(metrics.metadata), + key_alias: keyActivityLabel(metrics.metadata, metrics.metadata.user_id ?? "-"), user: metrics.metadata.user_email ?? metrics.metadata.user_id ?? null, key_exists: metrics.metadata.key_exists, tags: metrics.metadata.tags || [], 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 f4e1fe288ec..75e7abb8a3f 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 @@ -239,6 +239,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 04a792bb5ca..405e3a72d8d 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx @@ -538,6 +538,8 @@ export default function KeyInfoView({ const budgetDisplay = hasOwnBudget ? `$${formatNumberWithCommas(currentKeyData.max_budget, 2)}` : "Unlimited"; const ownerUser = keyOwnerBudgetSource(currentKeyData, applyUserBudgetToTeamKeys); const inheritedGates = hasOwnBudget ? [] : inheritedBudgetGates(parentTeam, parentOrg, ownerUser); + const keyOwnerLabel = currentKeyData.user?.user_alias || currentKeyData.user_email || currentKeyData.user_id; + const keyAliasLabel = currentKeyData.key_alias || keyOwnerLabel || "Not Set"; return (
@@ -904,7 +906,7 @@ export default function KeyInfoView({

Key Alias

-

{currentKeyData.key_alias || "Not Set"}

+

{keyAliasLabel}

From 1025067a9a73083ef148d5f5df1688c00b2d1077 Mon Sep 17 00:00:00 2001 From: WinJayX Date: Tue, 13 Oct 2026 16:24:49 +0800 Subject: [PATCH 5/9] feat(proxy): carry the key owner alias in daily activity key metadata The usage page shows a virtual key's alias, falling back to the owner, but the daily activity routes only ever returned key_alias, user_id and user_email, so the fallback could only show an email address. KeyMetadata, KeyMetadataRow and the recovered key metadata path now carry user_alias too, filled from the user table by the user details recovery that already runs for these rows. The dashboard OpenAPI types are regenerated. --- litellm/proxy/_lazy_openapi_snapshot.json | 11 ++++ .../common_daily_activity.py | 5 ++ .../daily_activity_routes.py | 1 + .../spend_tracking/key_metadata_recovery.py | 5 ++ .../repositories/daily_activity_repository.py | 1 + .../common_daily_activity.py | 1 + litellm/types/repositories/daily_activity.py | 1 + .../test_common_daily_activity.py | 55 +++++++++++++++++++ .../test_key_metadata_recovery.py | 17 ++++++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 2 + 10 files changed, 99 insertions(+) diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 7dcca8f9ec0..dc5806ef6d6 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -3822,6 +3822,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 28dfbb09eab..f82ac6bd321 100644 --- a/litellm/proxy/management_endpoints/common_daily_activity.py +++ b/litellm/proxy/management_endpoints/common_daily_activity.py @@ -166,6 +166,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] @@ -181,6 +182,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, ) @@ -490,6 +492,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() @@ -532,6 +535,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 @@ -543,6 +547,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 fa3c745c88e..ff837787bdb 100644 --- a/litellm/proxy/management_endpoints/daily_activity_routes.py +++ b/litellm/proxy/management_endpoints/daily_activity_routes.py @@ -125,6 +125,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 2230a9e7fa8..84a594fbb72 100644 --- a/litellm/repositories/daily_activity_repository.py +++ b/litellm/repositories/daily_activity_repository.py @@ -277,6 +277,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/types/proxy/management_endpoints/common_daily_activity.py b/litellm/types/proxy/management_endpoints/common_daily_activity.py index 9f7f666f494..97a575cc4e5 100644 --- a/litellm/types/proxy/management_endpoints/common_daily_activity.py +++ b/litellm/types/proxy/management_endpoints/common_daily_activity.py @@ -49,6 +49,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 df302234398..7320b9360a0 100644 --- a/litellm/types/repositories/daily_activity.py +++ b/litellm/types/repositories/daily_activity.py @@ -74,6 +74,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 e6a6680d3e4..5b8508f5498 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 2a5f03bcdf3..aa6c83d920d 100644 --- a/tests/unit/proxy/spend_tracking/test_key_metadata_recovery.py +++ b/tests/unit/proxy/spend_tracking/test_key_metadata_recovery.py @@ -733,6 +733,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/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index fdc63268fe5..deab63f5733 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -34349,6 +34349,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 */ From 1105def5e8e06b762de6da4eb100e169c3a9a714 Mon Sep 17 00:00:00 2001 From: WinJayX Date: Tue, 13 Oct 2026 16:24:52 +0800 Subject: [PATCH 6/9] feat(ui): show the key owner name in the usage key alias column keyActivityLabel resolved a key alias, then the owner's email. It now resolves key_alias, then user_alias, then user_email, so the usage page and the entity usage export name the key owner when the key has no alias of its own. --- .../src/components/UsagePage/dailyActivityApi.ts | 1 + .../src/components/UsagePage/keyActivityLabel.test.ts | 6 ++++++ .../src/components/UsagePage/keyActivityLabel.ts | 5 +++-- ui/litellm-dashboard/src/components/UsagePage/types.ts | 1 + 4 files changed, 11 insertions(+), 2 deletions(-) diff --git a/ui/litellm-dashboard/src/components/UsagePage/dailyActivityApi.ts b/ui/litellm-dashboard/src/components/UsagePage/dailyActivityApi.ts index 36c2837d9ac..fd58cedad45 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/dailyActivityApi.ts +++ b/ui/litellm-dashboard/src/components/UsagePage/dailyActivityApi.ts @@ -67,6 +67,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 06f1856a5a6..b941dcc9154 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/types.ts +++ b/ui/litellm-dashboard/src/components/UsagePage/types.ts @@ -50,6 +50,7 @@ export interface KeyMetadata { team_id: string | null; user_id?: string | null; user_email?: string | null; + user_alias?: string | null; key_exists?: boolean | null; tags?: { tag: string; usage: number }[]; } From 173673fca849987b9bf3182be1b2172b9d591b22 Mon Sep 17 00:00:00 2001 From: WinJayX Date: Tue, 13 Oct 2026 16:24:52 +0800 Subject: [PATCH 7/9] fix(ui): import DailyData from the shared usage types module UsagePageView pulled DailyData out of the daily activity API module, which only imports that type instead of re-exporting it, so tsc --project tsconfig.production.json failed and next build would have failed with it. --- .../(dashboard)/usage/_components/components/UsagePageView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 866a1d66fcb..c4a23d764a0 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 @@ -40,9 +40,9 @@ import { EMPTY_DAILY_ACTIVITY_METADATA, toDailyData, type DailyActivityRequest, - type DailyData, } from "@/components/UsagePage/dailyActivityApi"; import { keyDetailFromResponse, overallUsageMetrics } from "@/components/UsagePage/keyActivityData"; +import type { DailyData } from "@/components/UsagePage/types"; import { fetchedRangeKey, selectForRange, From 1c7b3f1a9a57417062576862a2a628ac8886f3ed Mon Sep 17 00:00:00 2001 From: WinJayX Date: Thu, 15 Oct 2026 14:51:57 +0800 Subject: [PATCH 8/9] fix(ui): name key owners by alias in the usage views The daily activity response carries user_alias, but both top key aggregators copied only user_email into their per key metadata, so the usage overview and the entity pages named owners by email even though Internal Users held an alias for them. Carry user_alias through the aggregations and resolve owner names as alias, then email, then user id, the order the rest of the admin tables use. --- .../EntityUsage/EntityUsage.test.tsx | 50 +++++++++++++++++++ .../components/EntityUsage/EntityUsage.tsx | 11 ++-- .../EntityUsage/entityUsageAggregations.ts | 6 ++- .../EntityUsage/teamUserSpend.test.ts | 3 +- .../components/EntityUsage/teamUserSpend.ts | 2 +- 5 files changed, 63 insertions(+), 9 deletions(-) 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 e6f5746d2e1..7018c1d27fc 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 @@ -533,6 +533,25 @@ 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", { @@ -1163,6 +1182,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 ab1c678869a..19a197517da 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 @@ -258,14 +258,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 66a713990f8..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 || [], }, @@ -129,7 +130,7 @@ export const getGlobalTopKeys = (results: DailyData[], topKeysLimit: number): To .map(([api_key, metrics]) => ({ api_key, key_alias: keyActivityLabel(metrics.metadata, metrics.metadata.user_id ?? "-"), - user: metrics.metadata.user_email ?? metrics.metadata.user_id ?? null, + 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] || [], }, @@ -195,7 +197,7 @@ export const getTopAPIKeys = (results: ExtendedDailyData[], topKeysLimit: number .map(([api_key, metrics]) => ({ api_key, key_alias: keyActivityLabel(metrics.metadata, metrics.metadata.user_id ?? "-"), - user: metrics.metadata.user_email ?? metrics.metadata.user_id ?? null, + 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; }; From 247d8f3724199c5056cb124f6190b11786872a24 Mon Sep 17 00:00:00 2001 From: WinJayX Date: Thu, 15 Oct 2026 15:59:41 +0800 Subject: [PATCH 9/9] fix(responses): merge reasoning replayed after a tool call into that turn A tool-only turn whose reasoning item is replayed after its function_call left a standalone assistant message between the tool_calls assistant message and the tool result, which DeepSeek rejects with "An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'". --- .../transformation.py | 9 ++++- ..._tool_output_order_preserved_for_gemini.py | 36 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index b76dd68018f..d06f978752a 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -825,6 +825,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. @@ -911,7 +915,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/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"