${formatNumberWithCommas(currentKeyData.spend, 4)}
-
of {budgetDisplay}
+
+ of {budgetDisplay}
+
+
{currentKeyData.budget_reset_at && (
Resets {formatTimestamp(currentKeyData.budget_reset_at)}
)}
diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx
index 82b179b2654..1c94e6418a0 100644
--- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx
+++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx
@@ -14,11 +14,16 @@ vi.mock("@/app/(dashboard)/hooks/models/useModels", () => ({
useInfiniteModelInfo: vi.fn(),
}));
+vi.mock("@/app/(dashboard)/hooks/spendLogs/useSpendLogUsers", () => ({
+ useInfiniteSpendLogUsers: vi.fn(),
+}));
+
vi.mock("@/app/(dashboard)/hooks/spendLogs/useSpendLogEndUsers", () => ({
useInfiniteSpendLogEndUsers: vi.fn(),
}));
import { useInfiniteSpendLogEndUsers } from "@/app/(dashboard)/hooks/spendLogs/useSpendLogEndUsers";
+import { useInfiniteSpendLogUsers } from "@/app/(dashboard)/hooks/spendLogs/useSpendLogUsers";
import { useInfiniteKeyAliases } from "@/app/(dashboard)/hooks/keys/useKeyAliases";
import { useInfiniteModelInfo } from "@/app/(dashboard)/hooks/models/useModels";
@@ -50,6 +55,9 @@ describe("RequestLogsFilters", () => {
vi.mocked(useInfiniteModelInfo).mockReturnValue(
emptyInfiniteQuery as unknown as ReturnType
,
);
+ vi.mocked(useInfiniteSpendLogUsers).mockReturnValue(
+ emptyInfiniteQuery as unknown as ReturnType,
+ );
vi.mocked(useInfiniteSpendLogEndUsers).mockReturnValue(
emptyInfiniteQuery as unknown as ReturnType,
);
@@ -62,6 +70,7 @@ describe("RequestLogsFilters", () => {
"Team ID",
"Status",
"Key Alias",
+ "User ID",
"End User",
"Error Code",
"Error Message",
@@ -74,6 +83,78 @@ describe("RequestLogsFilters", () => {
}
});
+ it("places User ID between Key Alias and End User", async () => {
+ renderFilters();
+
+ const labels = ["Key Alias", "User ID", "End User"].map((label) => screen.getByText(label));
+ expect(labels[0].compareDocumentPosition(labels[1]) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ expect(labels[1].compareDocumentPosition(labels[2]) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ });
+
+ it("selects an internal user value from the caller's visible spend logs", async () => {
+ vi.mocked(useInfiniteSpendLogUsers).mockReturnValue({
+ ...emptyInfiniteQuery,
+ data: {
+ pages: [
+ {
+ data: ["alice@example.com"],
+ meta: { page: 1, page_size: 50, has_more: false },
+ links: { self: "", next: null },
+ },
+ ],
+ pageParams: [1],
+ },
+ } as unknown as ReturnType);
+ const user = userEvent.setup();
+ const { set } = renderFilters();
+
+ await user.click(await screen.findByPlaceholderText("Search an internal user"));
+ await user.click(await screen.findByText("alice@example.com"));
+
+ expect(set).toHaveBeenCalledWith(LOG_FILTER_IDS.USER_ID, "alice@example.com");
+ });
+
+ it("pushes the User ID picker query to the paginated user lookup", async () => {
+ const user = userEvent.setup();
+ renderFilters();
+
+ const input = await screen.findByPlaceholderText("Search an internal user");
+ await user.click(input);
+ await user.type(input, "alice@example.com");
+
+ await waitFor(() => expect(useInfiniteSpendLogUsers).toHaveBeenCalledWith(LOGS_WINDOW, 50, "alice@example.com"));
+ });
+
+ it("loads the next page when the User ID list is scrolled near the end", async () => {
+ const fetchNextPage = vi.fn();
+ vi.mocked(useInfiniteSpendLogUsers).mockReturnValue({
+ ...emptyInfiniteQuery,
+ fetchNextPage,
+ hasNextPage: true,
+ data: {
+ pages: [
+ {
+ data: ["alice@example.com"],
+ meta: { page: 1, page_size: 50, has_more: true },
+ links: { self: "", next: "?page=2" },
+ },
+ ],
+ pageParams: [1],
+ },
+ } as unknown as ReturnType);
+ const user = userEvent.setup();
+ renderFilters();
+
+ await user.click(await screen.findByPlaceholderText("Search an internal user"));
+ const list = await screen.findByTestId("paginated-search-select-list");
+ Object.defineProperty(list, "scrollTop", { value: 90, configurable: true });
+ Object.defineProperty(list, "clientHeight", { value: 10, configurable: true });
+ Object.defineProperty(list, "scrollHeight", { value: 100, configurable: true });
+ list.dispatchEvent(new Event("scroll", { bubbles: true }));
+
+ await waitFor(() => expect(fetchNextPage).toHaveBeenCalled());
+ });
+
it("scopes the Key Alias lookup to the selected team", async () => {
renderFilters({ [LOG_FILTER_IDS.TEAM_ID]: "team-42" });
diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx
index 2005b868cd6..017260230dd 100644
--- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx
+++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx
@@ -3,6 +3,7 @@
import { useMemo, useState } from "react";
import { useInfiniteSpendLogEndUsers } from "@/app/(dashboard)/hooks/spendLogs/useSpendLogEndUsers";
+import { useInfiniteSpendLogUsers } from "@/app/(dashboard)/hooks/spendLogs/useSpendLogUsers";
import { useInfiniteKeyAliases } from "@/app/(dashboard)/hooks/keys/useKeyAliases";
import { useInfiniteModelInfo } from "@/app/(dashboard)/hooks/models/useModels";
import { DataTableFilterField } from "@/components/shared/DataTable";
@@ -144,6 +145,51 @@ function ModelFilterField({ value, onChange }: { value: string; onChange: (value
);
}
+function UserIdFilterField({
+ value,
+ onChange,
+ logsWindow,
+}: {
+ value: string;
+ onChange: (value: string | undefined) => void;
+ logsWindow: LogsWindow;
+}) {
+ const [search, setSearch] = useState("");
+ const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteSpendLogUsers(
+ logsWindow,
+ PAGE_SIZE,
+ emptyToUndefined(search),
+ );
+
+ const options = useMemo(() => {
+ const seen = new Set();
+ return (data?.pages ?? []).flatMap((page) =>
+ page.data.flatMap((userId) => {
+ if (!userId || seen.has(userId)) return [];
+ seen.add(userId);
+ return [{ label: userId, value: userId }];
+ }),
+ );
+ }, [data]);
+
+ return (
+
+ onChange(emptyToUndefined(next))}
+ onSearchChange={setSearch}
+ onLoadMore={() => void fetchNextPage()}
+ hasNextPage={hasNextPage}
+ isLoading={isLoading}
+ isFetchingNextPage={isFetchingNextPage}
+ placeholder="Search an internal user"
+ emptyText="No users found"
+ />
+
+ );
+}
+
function EndUserFilterField({
value,
onChange,
@@ -279,6 +325,12 @@ export function RequestLogsFilters({ get, set, teams, logsWindow }: RequestLogsF
teamId={valueOf(LOG_FILTER_IDS.TEAM_ID)}
/>
+
+
{
});
});
- describe("filterByCurrentUser", () => {
- it("scopes to the current user when no explicit user filter is set", async () => {
- renderFilterHook({ filterByCurrentUser: true });
+ describe("user scope", () => {
+ it("leaves an empty user filter for the backend to authorize", async () => {
+ renderFilterHook();
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled());
- expect(lastCallParams()?.params).toMatchObject({ user_id: "user-1" });
+ expect(lastCallParams()?.params?.user_id).toBeUndefined();
});
- it("lets an explicit user filter win over the current-user scope", async () => {
+ it("sends an explicit user filter for the backend to intersect with authorization", async () => {
renderFilterHook({
- filterByCurrentUser: true,
columnFilters: [{ id: LOG_FILTER_IDS.USER_ID, value: "someone-else" }],
});
diff --git a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx
index e1089c6a16c..474f51e93b3 100644
--- a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx
+++ b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx
@@ -36,6 +36,7 @@ export const LOG_FILTER_LABELS: Record = {
[LOG_FILTER_IDS.TEAM_ID]: "Team ID",
[LOG_FILTER_IDS.STATUS]: "Status",
[LOG_FILTER_IDS.KEY_ALIAS]: "Key Alias",
+ [LOG_FILTER_IDS.USER_ID]: "User ID",
[LOG_FILTER_IDS.END_USER]: "End User",
[LOG_FILTER_IDS.ERROR_CODE]: "Error Code",
[LOG_FILTER_IDS.ERROR_MESSAGE]: "Error Message",
@@ -98,7 +99,6 @@ export function useLogFilterLogic({
userRole,
userID,
columnFilters,
- filterByCurrentUser,
activeTab,
isLiveTail,
startTime,
@@ -112,7 +112,6 @@ export function useLogFilterLogic({
userRole: string | null;
userID: string | null;
columnFilters: ColumnFiltersState;
- filterByCurrentUser: boolean | null;
activeTab: string;
isLiveTail: boolean;
startTime: string;
@@ -136,7 +135,6 @@ export function useLogFilterLogic({
endTime,
isCustomDate,
columnFilters,
- filterByCurrentUser ? userID : null,
sortBy,
sortOrder,
],
@@ -166,7 +164,7 @@ export function useLogFilterLogic({
team_id: getFilterValue(columnFilters, LOG_FILTER_IDS.TEAM_ID),
request_id: getFilterValue(columnFilters, LOG_FILTER_IDS.REQUEST_ID),
session_id: getFilterValue(columnFilters, LOG_FILTER_IDS.SESSION_ID),
- user_id: userIdFilter ?? (filterByCurrentUser ? userID ?? undefined : undefined),
+ user_id: userIdFilter,
end_user: getFilterValue(columnFilters, LOG_FILTER_IDS.END_USER),
status_filter: getFilterValue(columnFilters, LOG_FILTER_IDS.STATUS),
model_id: getFilterValue(columnFilters, LOG_FILTER_IDS.MODEL_ID),
diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts
index 8fca7eaad4d..0629554eecf 100644
--- a/ui/litellm-dashboard/src/lib/http/schema.d.ts
+++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts
@@ -7655,6 +7655,26 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/management/v1/spend_logs/users": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * List Spend Log Users
+ * @description The distinct internal users appearing in spend logs the caller can read.
+ */
+ get: operations["list_spend_log_users_management_v1_spend_logs_users_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/mcp": {
parameters: {
query?: never;
@@ -46163,6 +46183,46 @@ export interface operations {
};
};
};
+ list_spend_log_users_management_v1_spend_logs_users_get: {
+ parameters: {
+ query: {
+ /** @description Window start (UTC when no offset is given) */
+ "filter[startTime][gte]": string;
+ /** @description Window end (UTC when no offset is given) */
+ "filter[startTime][lte]": string;
+ /** @description Case-insensitive partial match on the internal user id */
+ q?: string | null;
+ /** @description Page number */
+ page?: number;
+ /** @description Page size */
+ page_size?: number;
+ };
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["FacetListResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
aggregate_mcp_route_mcp_get: {
parameters: {
query?: never;
diff --git a/uv.lock b/uv.lock
index 1cc924e50fe..63e8e31223c 100644
--- a/uv.lock
+++ b/uv.lock
@@ -10,7 +10,7 @@ resolution-markers = [
]
[options]
-exclude-newer = "2026-08-12T18:09:57.187615Z"
+exclude-newer = "2026-08-14T18:59:56.524034Z"
exclude-newer-span = "P3D"
[manifest]
@@ -9075,11 +9075,11 @@ wheels = [
[[package]]
name = "sqlparse"
-version = "0.5.5"
+version = "0.6.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/5f/d3/3f06a1006f2261d1342aefb3c71eed02f5d4ca5bdbecd86ebc12ad38306e/sqlparse-0.6.0.tar.gz", hash = "sha256:113c35c75365ab9cc9c7231d68c6428fb11c085fc8e9eb1ad659b7ddbf6cd2b9", size = 178477, upload-time = "2026-08-13T19:16:06.396Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/50/f00935da0ec7cbf325f8dc4f772ae46fbc7b672dd62876e73f0a94adda57/sqlparse-0.6.0-py3-none-any.whl", hash = "sha256:b861c0288ce2fa56209a9a6412d2e066ac664b3873b89c26c9d8415e8e32996f", size = 50070, upload-time = "2026-08-13T19:16:04.062Z" },
]
[[package]]