mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-13 23:11:40 +00:00
Merge b7596d6fba into 9071ca503e
This commit is contained in:
commit
4c958473f4
10 changed files with 230 additions and 13 deletions
|
|
@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
|||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import React, { ReactNode } from "react";
|
||||
import { useInfiniteUsers, useUserLookup } from "./useUsers";
|
||||
import { useInfiniteUsers, useUserEmailLookup, useUserLookup } from "./useUsers";
|
||||
import { userListCall } from "@/components/networking";
|
||||
import type { UserListResponse } from "@/components/networking";
|
||||
|
||||
|
|
@ -235,7 +235,7 @@ describe("useInfiniteUsers", () => {
|
|||
});
|
||||
|
||||
it("should execute query for each admin role", async () => {
|
||||
const adminRoles = ["Admin", "Admin Viewer", "proxy_admin", "proxy_admin_viewer", "org_admin"];
|
||||
const adminRoles = ["Admin", "Admin Viewer", "proxy_admin", "proxy_admin_viewer", "org_admin", "Org Admin"];
|
||||
|
||||
for (const role of adminRoles) {
|
||||
vi.clearAllMocks();
|
||||
|
|
@ -335,3 +335,72 @@ describe("useUserLookup", () => {
|
|||
expect(userListCall).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useUserEmailLookup", () => {
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
vi.clearAllMocks();
|
||||
mockUseAuthorized.mockReturnValue(DEFAULT_AUTH);
|
||||
});
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
|
||||
it("fetches the distinct ids in one call and maps each id to its email", async () => {
|
||||
const response = buildUserListResponse(1, 1, 2);
|
||||
vi.mocked(userListCall).mockResolvedValue(response);
|
||||
|
||||
const { result } = renderHook(() => useUserEmailLookup(["user-1-1", "user-1-0", "user-1-1", ""]), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(userListCall).toHaveBeenCalledTimes(1);
|
||||
expect(userListCall).toHaveBeenCalledWith("test-access-token", ["user-1-0", "user-1-1"], 1, 2);
|
||||
expect(result.current.data).toEqual({
|
||||
"user-1-0": "user-1-0@example.com",
|
||||
"user-1-1": "user-1-1@example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits users that have no email so callers fall back to the id", async () => {
|
||||
const response = buildUserListResponse(1, 1, 2);
|
||||
vi.mocked(userListCall).mockResolvedValue({
|
||||
...response,
|
||||
users: [{ ...response.users[0], user_email: "" }, response.users[1]],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUserEmailLookup(["user-1-0", "user-1-1"]), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(result.current.data).toEqual({ "user-1-1": "user-1-1@example.com" });
|
||||
});
|
||||
|
||||
it("does not query with no ids", async () => {
|
||||
const { result } = renderHook(() => useUserEmailLookup([]), { wrapper });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(result.current.fetchStatus).toBe("idle");
|
||||
expect(userListCall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("queries for the formatted Org Admin session role", async () => {
|
||||
mockUseAuthorized.mockReturnValue({ ...DEFAULT_AUTH, userRole: "Org Admin" });
|
||||
vi.mocked(userListCall).mockResolvedValue(buildUserListResponse(1, 1, 1));
|
||||
|
||||
const { result } = renderHook(() => useUserEmailLookup(["user-1-0"]), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(result.current.data).toEqual({ "user-1-0": "user-1-0@example.com" });
|
||||
});
|
||||
|
||||
it("does not query for a non-admin role", async () => {
|
||||
mockUseAuthorized.mockReturnValue({ ...DEFAULT_AUTH, userRole: "Internal User" });
|
||||
|
||||
const { result } = renderHook(() => useUserEmailLookup(["user-1-0"]), { wrapper });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(result.current.fetchStatus).toBe("idle");
|
||||
expect(userListCall).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { userListCall, UserInfo, UserListResponse } from "@/components/networking";
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { createQueryKeys } from "../common/queryKeysFactory";
|
||||
import { all_admin_roles } from "@/utils/roles";
|
||||
import { canListUsers } from "@/utils/roles";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
|
||||
const infiniteUsersKeys = createQueryKeys("infiniteUsers");
|
||||
|
|
@ -34,7 +34,25 @@ export const useInfiniteUsers = (pageSize: number = DEFAULT_PAGE_SIZE, searchEma
|
|||
}
|
||||
return undefined;
|
||||
},
|
||||
enabled: Boolean(accessToken) && all_admin_roles.includes(userRole!),
|
||||
enabled: Boolean(accessToken) && canListUsers(userRole),
|
||||
});
|
||||
};
|
||||
|
||||
const USER_LIST_MAX_PAGE_SIZE = 100;
|
||||
|
||||
export const useUserEmailLookup = (userIds: readonly string[]) => {
|
||||
const { accessToken, userRole } = useAuthorized();
|
||||
const distinctIds = Array.from(new Set(userIds.filter((id) => id !== ""))).sort();
|
||||
return useQuery<Record<string, string>>({
|
||||
queryKey: userLookupKeys.list({ filters: { ids: JSON.stringify(distinctIds) } }),
|
||||
queryFn: async () => {
|
||||
const ids = distinctIds.slice(0, USER_LIST_MAX_PAGE_SIZE);
|
||||
const response = await userListCall(accessToken!, ids, 1, ids.length);
|
||||
return Object.fromEntries(
|
||||
response.users.filter((user) => Boolean(user.user_email)).map((user) => [user.user_id, user.user_email]),
|
||||
);
|
||||
},
|
||||
enabled: Boolean(accessToken) && distinctIds.length > 0 && canListUsers(userRole),
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -46,6 +64,6 @@ export const useUserLookup = (userId: string | null) => {
|
|||
const response = await userListCall(accessToken!, [userId!], 1, 1);
|
||||
return response.users.find((user) => user.user_id === userId) ?? null;
|
||||
},
|
||||
enabled: Boolean(accessToken) && Boolean(userId) && all_admin_roles.includes(userRole!),
|
||||
enabled: Boolean(accessToken) && Boolean(userId) && canListUsers(userRole),
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -56,6 +56,29 @@ describe("LogDetailContent", () => {
|
|||
expect(screen.getByText("completion")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the requesting user's email and id in Request Details when the email is resolved", () => {
|
||||
render(
|
||||
<LogDetailContent logEntry={createLogEntry({ user: "106514937785257944828" })} userEmail="alice@example.com" />,
|
||||
);
|
||||
|
||||
expect(screen.getByText("User")).toBeInTheDocument();
|
||||
expect(screen.getByText("alice@example.com")).toBeInTheDocument();
|
||||
expect(screen.getByText("106514937785257944828")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to the user id in Request Details when no email is resolved", () => {
|
||||
render(<LogDetailContent logEntry={createLogEntry({ user: "106514937785257944828" })} />);
|
||||
|
||||
expect(screen.getByText("User")).toBeInTheDocument();
|
||||
expect(screen.getByText("106514937785257944828")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits the User row when the log has no internal user", () => {
|
||||
render(<LogDetailContent logEntry={createLogEntry({ user: undefined })} />);
|
||||
|
||||
expect(screen.queryByText("User")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display error alert when request has failed", () => {
|
||||
render(
|
||||
<LogDetailContent
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ export interface LogDetailContentProps {
|
|||
/** When true, log details (messages/response) are still being lazy-loaded. */
|
||||
isLoadingDetails?: boolean;
|
||||
accessToken?: string | null;
|
||||
userEmail?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -66,7 +67,12 @@ export interface LogDetailContentProps {
|
|||
* Designed to be placed inside LogDetailsDrawer's right panel so it can
|
||||
* be reused for both single-log and session-mode views.
|
||||
*/
|
||||
export function LogDetailContent({ logEntry, isLoadingDetails = false, accessToken }: LogDetailContentProps) {
|
||||
export function LogDetailContent({
|
||||
logEntry,
|
||||
isLoadingDetails = false,
|
||||
accessToken,
|
||||
userEmail,
|
||||
}: LogDetailContentProps) {
|
||||
const metadata = logEntry.metadata || {};
|
||||
const hasError = metadata.status === "failure";
|
||||
const errorInfo = hasError ? metadata.error_information : null;
|
||||
|
|
@ -142,6 +148,11 @@ export function LogDetailContent({ logEntry, isLoadingDetails = false, accessTok
|
|||
<DescriptionItem label="Model">{logEntry.model}</DescriptionItem>
|
||||
<DescriptionItem label="Provider">{logEntry.custom_llm_provider || "-"}</DescriptionItem>
|
||||
<DescriptionItem label="Call Type">{logEntry.call_type}</DescriptionItem>
|
||||
{logEntry.user && (
|
||||
<DescriptionItem label="User">
|
||||
<UserIdentity userId={logEntry.user} email={userEmail} />
|
||||
</DescriptionItem>
|
||||
)}
|
||||
<DescriptionItem label="Model ID">
|
||||
<TruncatedValue value={logEntry.model_id} />
|
||||
</DescriptionItem>
|
||||
|
|
@ -333,6 +344,16 @@ function TagsSection({ tags }: { tags: Record<string, any> }) {
|
|||
);
|
||||
}
|
||||
|
||||
function UserIdentity({ userId, email }: { userId: string; email?: string }) {
|
||||
if (!email || email === userId) return <TruncatedValue value={userId} />;
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span>{email}</span>
|
||||
<TruncatedValue value={userId} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function GuardrailLabel({ label, maskedCount }: { label: string; maskedCount: number }) {
|
||||
const handleClick = () => {
|
||||
const el = document.getElementById("guardrail-section");
|
||||
|
|
|
|||
|
|
@ -14,8 +14,13 @@ vi.mock("@/app/(dashboard)/hooks/logDetails/useLogDetails", () => ({
|
|||
useLogDetails: () => ({ data: null, isLoading: false }),
|
||||
}));
|
||||
|
||||
const mockUseUserLookup = vi.fn(() => ({ data: undefined }));
|
||||
vi.mock("@/app/(dashboard)/hooks/users/useUsers", () => ({
|
||||
useUserLookup: (userId: string | null) => mockUseUserLookup(userId),
|
||||
}));
|
||||
|
||||
vi.mock("./LogDetailContent", () => ({
|
||||
LogDetailContent: () => null,
|
||||
LogDetailContent: ({ userEmail }: { userEmail?: string }) => <span>user-email:{userEmail ?? "none"}</span>,
|
||||
GuardrailJumpLink: () => null,
|
||||
}));
|
||||
|
||||
|
|
@ -124,6 +129,38 @@ describe("LogDetailsDrawer session sidebar sorting", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("LogDetailsDrawer internal user email", () => {
|
||||
const renderSingleLog = (user: string | undefined) => {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<LogDetailsDrawer
|
||||
open
|
||||
onClose={() => {}}
|
||||
logEntry={makeLog({ request_id: "single", user })}
|
||||
accessToken="token"
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
it("looks up the log's internal user and hands the resolved email to the detail content", () => {
|
||||
mockUseUserLookup.mockReturnValue({ data: { user_id: "u-1", user_email: "alice@example.com" } });
|
||||
renderSingleLog("u-1");
|
||||
|
||||
expect(mockUseUserLookup).toHaveBeenCalledWith("u-1");
|
||||
expect(screen.getByText("user-email:alice@example.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("skips the lookup and passes no email when the log has no internal user", () => {
|
||||
mockUseUserLookup.mockReturnValue({ data: undefined });
|
||||
renderSingleLog(undefined);
|
||||
|
||||
expect(mockUseUserLookup).toHaveBeenCalledWith(null);
|
||||
expect(screen.getByText("user-email:none")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("LogDetailsDrawer session sidebar auto-router icon", () => {
|
||||
const routedSessionLogs = [
|
||||
makeLog({ request_id: "routed", model: "claude-opus-4-8", model_group: "smart-router" }),
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { getSpendString } from "@/utils/dataUtils";
|
|||
import { normalizeGuardrailEntries, sortSessionLogs, SessionLogSortMode } from "./utils";
|
||||
import { DRAWER_WIDTH } from "./constants";
|
||||
import { useLogDetails } from "@/app/(dashboard)/hooks/logDetails/useLogDetails";
|
||||
import { useUserLookup } from "@/app/(dashboard)/hooks/users/useUsers";
|
||||
|
||||
export interface LogDetailsDrawerProps {
|
||||
open: boolean;
|
||||
|
|
@ -245,6 +246,7 @@ export function LogDetailsDrawer({
|
|||
const logDetails = useLogDetails(currentLog?.request_id, startTime, open && !!currentLog?.request_id);
|
||||
const detailsData = logDetails.data as any;
|
||||
const isLoadingDetails = logDetails.isLoading;
|
||||
const { data: logUser } = useUserLookup(open && currentLog?.user ? currentLog.user : null);
|
||||
|
||||
// Build an enriched log entry that merges lazy-loaded details.
|
||||
// The list endpoint may already include messages/response when store_prompts_in_spend_logs is enabled,
|
||||
|
|
@ -465,6 +467,7 @@ export function LogDetailsDrawer({
|
|||
logEntry={enrichedLog}
|
||||
isLoadingDetails={isLoadingDetails}
|
||||
accessToken={accessToken ?? null}
|
||||
userEmail={logUser?.user_email || undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type { ColumnFiltersState, OnChangeFn, PaginationState, SortingState } fr
|
|||
import { ScrollText } from "lucide-react";
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
|
||||
import { useUserEmailLookup } from "@/app/(dashboard)/hooks/users/useUsers";
|
||||
import { DataTable, DataTableFilterDrawer, DataTableToolbar } from "@/components/shared/DataTable";
|
||||
|
||||
import type { Team } from "../key_team_helpers/key_list";
|
||||
|
|
@ -73,10 +74,13 @@ export function RequestLogsTable({
|
|||
}: RequestLogsTableProps) {
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
|
||||
const userIds = useMemo(() => data.flatMap((log) => (log.user ? [log.user] : [])), [data]);
|
||||
const { data: emailByUserId } = useUserEmailLookup(userIds);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const deps = { onKeyHashClick, onSessionClick };
|
||||
return getRequestLogsTableColumns(deps);
|
||||
}, [onKeyHashClick, onSessionClick]);
|
||||
const resolveUserEmail = (userId: string) => emailByUserId?.[userId];
|
||||
return getRequestLogsTableColumns({ onKeyHashClick, onSessionClick, resolveUserEmail });
|
||||
}, [onKeyHashClick, onSessionClick, emailByUserId]);
|
||||
|
||||
const isFiltered = columnFilters.length > 0 || searchValue !== "";
|
||||
|
||||
|
|
|
|||
|
|
@ -75,6 +75,27 @@ describe("Cost column", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("Internal User column", () => {
|
||||
const emailById: Record<string, string> = { "106514937785257944828": "alice@example.com" };
|
||||
const deps = { ...noopDeps, resolveUserEmail: (userId: string) => emailById[userId] };
|
||||
|
||||
it("shows the user's email instead of the raw id, with both in the tooltip", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderRows([logEntry({ request_id: "req-known-user", user: "106514937785257944828" })], deps);
|
||||
|
||||
const emailCell = screen.getByText("alice@example.com");
|
||||
expect(screen.queryByText("106514937785257944828")).not.toBeInTheDocument();
|
||||
await user.hover(emailCell);
|
||||
expect(await screen.findByText("alice@example.com (106514937785257944828)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to the raw id when no email is known for the user", () => {
|
||||
renderRows([logEntry({ request_id: "req-unknown-user", user: "unknown-user-id" })], deps);
|
||||
|
||||
expect(screen.getByText("unknown-user-id")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tokens column", () => {
|
||||
const sessionRow: Partial<LogEntry> = {
|
||||
request_id: "req-session-tokens",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { AgentBadge, AgentIcon, BatchBadge, LlmBadge, McpBadge, SparkleIcon, Wre
|
|||
export interface RequestLogsTableColumnsDeps {
|
||||
onKeyHashClick: (keyHash: string) => void;
|
||||
onSessionClick: (log: LogEntry) => void;
|
||||
resolveUserEmail?: (userId: string) => string | undefined;
|
||||
}
|
||||
|
||||
const readMetaString = (metadata: Record<string, unknown> | undefined, key: string): string | undefined => {
|
||||
|
|
@ -32,14 +33,25 @@ const readMcpLogoUrl = (metadata: Record<string, unknown> | undefined): string |
|
|||
const getLogoUrl = (row: LogEntry, provider: string): string =>
|
||||
readMcpLogoUrl(row.metadata) ?? (provider ? getProviderLogoAndName(provider).logo : "");
|
||||
|
||||
function TruncatedText({ value }: { value: string | undefined }) {
|
||||
function TruncatedText({ value, tooltip }: { value: string | undefined; tooltip?: string }) {
|
||||
const display = value ?? "-";
|
||||
return <CellTooltip content={display} trigger={<span className="max-w-[15ch] truncate block">{display}</span>} />;
|
||||
return (
|
||||
<CellTooltip
|
||||
content={tooltip ?? display}
|
||||
trigger={<span className="max-w-[15ch] truncate block">{display}</span>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function UserCell({ userId, email }: { userId: string | undefined; email: string | undefined }) {
|
||||
if (!userId || !email || email === userId) return <TruncatedText value={userId} />;
|
||||
return <TruncatedText value={email} tooltip={`${email} (${userId})`} />;
|
||||
}
|
||||
|
||||
export const getRequestLogsTableColumns = ({
|
||||
onKeyHashClick,
|
||||
onSessionClick,
|
||||
resolveUserEmail = () => undefined,
|
||||
}: RequestLogsTableColumnsDeps): ColumnDef<LogEntry>[] => [
|
||||
{
|
||||
id: "startTime",
|
||||
|
|
@ -313,7 +325,12 @@ export const getRequestLogsTableColumns = ({
|
|||
header: "Internal User",
|
||||
size: 150,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <TruncatedText value={row.original.user} />,
|
||||
cell: ({ row }) => (
|
||||
<UserCell
|
||||
userId={row.original.user}
|
||||
email={row.original.user ? resolveUserEmail(row.original.user) : undefined}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "end_user",
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@ export const isAdminRole = (role: string): boolean => {
|
|||
return all_admin_roles.includes(role);
|
||||
};
|
||||
|
||||
const rolesAllowedToListUsers: string[] = [...all_admin_roles, "Org Admin"];
|
||||
|
||||
export const canListUsers = (role: string | null): boolean => rolesAllowedToListUsers.includes(role ?? "");
|
||||
|
||||
export const isProxyAdminRole = (role: string): boolean => {
|
||||
return role === "proxy_admin" || role === "Admin";
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue