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..c2df8d97bc1 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx @@ -18,9 +18,14 @@ vi.mock("@/app/(dashboard)/hooks/spendLogs/useSpendLogEndUsers", () => ({ useInfiniteSpendLogEndUsers: vi.fn(), })); +vi.mock("@/app/(dashboard)/hooks/users/useUsers", () => ({ + useInfiniteUsers: vi.fn(), +})); + import { useInfiniteSpendLogEndUsers } from "@/app/(dashboard)/hooks/spendLogs/useSpendLogEndUsers"; import { useInfiniteKeyAliases } from "@/app/(dashboard)/hooks/keys/useKeyAliases"; import { useInfiniteModelInfo } from "@/app/(dashboard)/hooks/models/useModels"; +import { useInfiniteUsers } from "@/app/(dashboard)/hooks/users/useUsers"; const emptyInfiniteQuery = { data: { pages: [], pageParams: [] }, @@ -32,10 +37,16 @@ const emptyInfiniteQuery = { const LOGS_WINDOW = { start_date: "2026-07-23 00:00:00", end_date: "2026-07-24 00:00:00" }; -function renderFilters(filters: Record = {}) { +function renderFilters(filters: Record = {}, canFilterByInternalUser = true) { const set = vi.fn(); renderWithProviders( - filters[id]} set={set} teams={[]} logsWindow={LOGS_WINDOW} />, + filters[id]} + set={set} + teams={[]} + logsWindow={LOGS_WINDOW} + canFilterByInternalUser={canFilterByInternalUser} + />, ); return { set }; } @@ -53,6 +64,7 @@ describe("RequestLogsFilters", () => { vi.mocked(useInfiniteSpendLogEndUsers).mockReturnValue( emptyInfiniteQuery as unknown as ReturnType, ); + vi.mocked(useInfiniteUsers).mockReturnValue(emptyInfiniteQuery as unknown as ReturnType); }); it("renders every backend-supported filter field", async () => { @@ -62,6 +74,7 @@ describe("RequestLogsFilters", () => { "Team ID", "Status", "Key Alias", + "Internal User", "End User", "Error Code", "Error Message", @@ -164,8 +177,57 @@ describe("RequestLogsFilters", () => { it("scopes the End User lookup to the window the logs table is showing", async () => { const otherWindow = { start_date: "2026-01-01 00:00:00", end_date: "2026-01-02 00:00:00" }; - renderWithProviders( undefined} set={vi.fn()} teams={[]} logsWindow={otherWindow} />); + renderWithProviders( + undefined} + set={vi.fn()} + teams={[]} + logsWindow={otherWindow} + canFilterByInternalUser + />, + ); await waitFor(() => expect(useInfiniteSpendLogEndUsers).toHaveBeenCalledWith(otherWindow, 50, undefined)); }); + + it("offers internal users by email and filters on their user id", async () => { + vi.mocked(useInfiniteUsers).mockReturnValue({ + ...emptyInfiniteQuery, + data: { + pages: [ + { + users: [{ user_id: "u-1", user_email: "bob@acme.com", user_alias: null }], + page: 1, + page_size: 50, + total: 1, + total_pages: 1, + }, + ], + pageParams: [1], + }, + } as unknown as ReturnType); + const user = userEvent.setup(); + const { set } = renderFilters(); + + await user.click(await screen.findByPlaceholderText("Search a user by email")); + await user.click(await screen.findByText("bob@acme.com")); + + expect(set).toHaveBeenCalledWith(LOG_FILTER_IDS.USER_ID, "u-1"); + }); + + it("pushes the Internal User query to the server rather than filtering a preloaded list", async () => { + const user = userEvent.setup(); + renderFilters(); + + await user.type(await screen.findByPlaceholderText("Search a user by email"), "bob"); + + await waitFor(() => expect(useInfiniteUsers).toHaveBeenCalledWith(50, "bob")); + }); + + it("hides the Internal User filter from callers the proxy scopes to their own logs", async () => { + renderFilters({}, false); + + expect(await screen.findByText("End User")).toBeInTheDocument(); + expect(screen.queryByText("Internal User")).not.toBeInTheDocument(); + }); }); diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx index 2005b868cd6..b5d719a853d 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx @@ -5,6 +5,7 @@ import { useMemo, useState } from "react"; import { useInfiniteSpendLogEndUsers } from "@/app/(dashboard)/hooks/spendLogs/useSpendLogEndUsers"; import { useInfiniteKeyAliases } from "@/app/(dashboard)/hooks/keys/useKeyAliases"; import { useInfiniteModelInfo } from "@/app/(dashboard)/hooks/models/useModels"; +import { useInfiniteUsers } from "@/app/(dashboard)/hooks/users/useUsers"; import { DataTableFilterField } from "@/components/shared/DataTable"; import { PaginatedSearchSelect } from "@/components/shared/PaginatedSearchSelect"; import { SearchSelect, type SearchSelectOption } from "@/components/shared/SearchSelect"; @@ -189,6 +190,49 @@ function EndUserFilterField({ ); } +function InternalUserFilterField({ + value, + onChange, +}: { + value: string; + onChange: (value: string | undefined) => void; +}) { + const [search, setSearch] = useState(""); + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteUsers( + PAGE_SIZE, + emptyToUndefined(search), + ); + + const options = useMemo(() => { + const seen = new Set(); + return (data?.pages ?? []).flatMap((page) => + page.users.flatMap((user) => { + if (!user.user_id || seen.has(user.user_id)) return []; + seen.add(user.user_id); + const name = user.user_email || user.user_alias || ""; + return [{ label: name || user.user_id, value: user.user_id, sublabel: name === "" ? undefined : user.user_id }]; + }), + ); + }, [data]); + + return ( + + onChange(emptyToUndefined(next))} + onSearchChange={setSearch} + onLoadMore={() => void fetchNextPage()} + hasNextPage={hasNextPage} + isLoading={isLoading} + isFetchingNextPage={isFetchingNextPage} + placeholder="Search a user by email" + emptyText="No users found" + /> + + ); +} + function ErrorCodeFilterField({ value, onChange }: { value: string; onChange: (value: string | undefined) => void }) { const [query, setQuery] = useState(""); @@ -243,9 +287,10 @@ interface RequestLogsFiltersProps { set: (columnId: string, value: unknown) => void; teams: Team[]; logsWindow: LogsWindow; + canFilterByInternalUser: boolean; } -export function RequestLogsFilters({ get, set, teams, logsWindow }: RequestLogsFiltersProps) { +export function RequestLogsFilters({ get, set, teams, logsWindow, canFilterByInternalUser }: RequestLogsFiltersProps) { const valueOf = (id: string): string => asString(get(id)); const setter = (id: string) => (next: string | undefined) => set(id, next); @@ -279,6 +324,10 @@ export function RequestLogsFilters({ get, set, teams, logsWindow }: RequestLogsF teamId={valueOf(LOG_FILTER_IDS.TEAM_ID)} /> + {canFilterByInternalUser && ( + + )} + void; teams: Team[]; logsWindow: LogsWindow; + canFilterByInternalUser: boolean; toolbarChildren?: ReactNode; } @@ -69,6 +70,7 @@ export function RequestLogsTable({ onSessionClick, teams, logsWindow, + canFilterByInternalUser, toolbarChildren, }: RequestLogsTableProps) { const [filtersOpen, setFiltersOpen] = useState(false); @@ -122,7 +124,15 @@ export function RequestLogsTable({ title="Filters" description="Narrow down request logs" > - {({ get, set }) => } + {({ get, set }) => ( + + )} )} 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 8244066cadb..83b7b56de6c 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 @@ -42,6 +42,7 @@ export const LOG_FILTER_LABELS: Record = { [LOG_FILTER_IDS.SESSION_ID]: "Session ID", [LOG_FILTER_IDS.MODEL_ID]: "Model", [LOG_FILTER_IDS.PUBLIC_MODEL_OR_SEARCH_TOOL]: "Public model / search tool", + [LOG_FILTER_IDS.USER_ID]: "Internal User", }; export interface LogsWindow {