diff --git a/ui/litellm-dashboard/src/app/chat/logs/page.tsx b/ui/litellm-dashboard/src/app/chat/logs/page.tsx
new file mode 100644
index 00000000000..7c6daff1405
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/chat/logs/page.tsx
@@ -0,0 +1,14 @@
+"use client";
+
+import { useChatShell } from "@/contexts/ChatShellContext";
+import LogsPanel from "@/components/chat/LogsPanel";
+
+export default function LogsPage() {
+ const { accessToken, userId } = useChatShell();
+
+ return (
+
+
+
+ );
+}
diff --git a/ui/litellm-dashboard/src/components/chat/ChatShell.serverRootPath.test.ts b/ui/litellm-dashboard/src/components/chat/ChatShell.serverRootPath.test.ts
index 1fa054396ea..de1f9d2aa50 100644
--- a/ui/litellm-dashboard/src/components/chat/ChatShell.serverRootPath.test.ts
+++ b/ui/litellm-dashboard/src/components/chat/ChatShell.serverRootPath.test.ts
@@ -28,6 +28,7 @@ describe("getChatRoutes under server_root_path", () => {
expect(routes.integrations).toBe("/gw/ui/chat/integrations");
expect(routes.credentials).toBe("/gw/ui/chat/credentials");
expect(routes.apiKeys).toBe("/gw/ui/chat/api-keys");
+ expect(routes.logs).toBe("/gw/ui/chat/logs");
expect(routes.usage).toBe("/gw/ui/chat/usage");
});
diff --git a/ui/litellm-dashboard/src/components/chat/ChatShell.test.tsx b/ui/litellm-dashboard/src/components/chat/ChatShell.test.tsx
index 01dca80acd5..e48a83020f0 100644
--- a/ui/litellm-dashboard/src/components/chat/ChatShell.test.tsx
+++ b/ui/litellm-dashboard/src/components/chat/ChatShell.test.tsx
@@ -62,6 +62,20 @@ describe("ChatShell", () => {
fireEvent.click(screen.getByRole("button", { name: "Usage" }));
expect(mockPush).toHaveBeenCalledWith("/ui/chat/usage");
+
+ fireEvent.click(screen.getByRole("button", { name: "Logs" }));
+ expect(mockPush).toHaveBeenCalledWith("/ui/chat/logs");
+ });
+
+ it("marks Logs active on the logs route", () => {
+ mockUsePathname.mockReturnValue("/ui/chat/logs");
+ render(
+
+
+ ,
+ );
+ expect(screen.getByRole("button", { name: "Logs" })).toHaveAttribute("aria-current", "page");
+ expect(screen.getByRole("button", { name: "Usage" })).not.toHaveAttribute("aria-current");
});
it("tolerates a trailing slash on the current pathname when matching the active route", () => {
diff --git a/ui/litellm-dashboard/src/components/chat/ChatShell.tsx b/ui/litellm-dashboard/src/components/chat/ChatShell.tsx
index 102fc627f98..c71bc0723f7 100644
--- a/ui/litellm-dashboard/src/components/chat/ChatShell.tsx
+++ b/ui/litellm-dashboard/src/components/chat/ChatShell.tsx
@@ -2,7 +2,7 @@
import React from "react";
import { usePathname, useRouter } from "next/navigation";
-import { Plus, MessageSquare, LayoutGrid, KeyRound, Lock, BarChart3 } from "lucide-react";
+import { Plus, MessageSquare, LayoutGrid, KeyRound, Lock, BarChart3, ScrollText } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { migratedHref } from "@/utils/migratedPages";
@@ -16,6 +16,7 @@ export function getChatRoutes() {
integrations: `${base}/integrations`,
credentials: `${base}/credentials`,
apiKeys: `${base}/api-keys`,
+ logs: `${base}/logs`,
usage: `${base}/usage`,
};
}
@@ -109,6 +110,12 @@ const ChatShell: React.FC = ({ children }) => {
onClick={() => router.push(routes.apiKeys)}
active={pathname === routes.apiKeys}
/>
+ }
+ label="Logs"
+ onClick={() => router.push(routes.logs)}
+ active={pathname === routes.logs}
+ />
}
label="Usage"
diff --git a/ui/litellm-dashboard/src/components/chat/LogsPanel.test.tsx b/ui/litellm-dashboard/src/components/chat/LogsPanel.test.tsx
new file mode 100644
index 00000000000..75d755a75a4
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/chat/LogsPanel.test.tsx
@@ -0,0 +1,104 @@
+import { fireEvent, screen, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import LogsPanel from "./LogsPanel";
+import { renderWithProviders } from "../../../tests/test-utils";
+import { uiSpendLogDetailsCall, uiSpendLogsCall } from "../networking";
+
+vi.mock("../networking", () => ({
+ uiSpendLogsCall: vi.fn(),
+ uiSpendLogDetailsCall: vi.fn(),
+}));
+
+const mockedLogsCall = vi.mocked(uiSpendLogsCall);
+const mockedDetailsCall = vi.mocked(uiSpendLogDetailsCall);
+
+const sampleRow = {
+ request_id: "req-abc-123",
+ model: "gpt-4o",
+ status: "success",
+ spend: 0.0123,
+ total_tokens: 1500,
+ prompt_tokens: 1000,
+ completion_tokens: 500,
+ startTime: "2026-07-18T10:00:00Z",
+ endTime: "2026-07-18T10:00:02Z",
+ request_duration_ms: 2000,
+};
+
+const paginated = (rows: unknown[]) => ({
+ data: rows,
+ total: rows.length,
+ page: 1,
+ page_size: 50,
+ total_pages: rows.length > 0 ? 1 : 0,
+});
+
+describe("LogsPanel", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockedLogsCall.mockResolvedValue(paginated([sampleRow]));
+ mockedDetailsCall.mockResolvedValue({ messages: [{ role: "user", content: "hi" }], response: { ok: true } });
+ });
+
+ it("scopes the query to the current user so it only shows their own logs", async () => {
+ renderWithProviders();
+
+ await waitFor(() => expect(mockedLogsCall).toHaveBeenCalled());
+ expect(mockedLogsCall).toHaveBeenCalledWith(
+ expect.objectContaining({
+ accessToken: "tok-scope",
+ params: expect.objectContaining({ user_id: "user-42" }),
+ }),
+ );
+ });
+
+ it("renders a row for each returned log", async () => {
+ renderWithProviders();
+
+ expect(await screen.findByText("gpt-4o")).toBeInTheDocument();
+ expect(screen.getByText("1,500")).toBeInTheDocument();
+ expect(screen.getByText("Success")).toBeInTheDocument();
+ });
+
+ it("shows an empty state when there are no logs", async () => {
+ mockedLogsCall.mockResolvedValue(paginated([]));
+ renderWithProviders();
+
+ expect(await screen.findByText("No logs for this period")).toBeInTheDocument();
+ });
+
+ it("opens the detail dialog and lazily loads request/response when a row is clicked", async () => {
+ renderWithProviders();
+
+ const modelCell = await screen.findByText("gpt-4o");
+ expect(mockedDetailsCall).not.toHaveBeenCalled();
+
+ fireEvent.click(modelCell);
+
+ expect(await screen.findByText("Request details")).toBeInTheDocument();
+ await waitFor(() =>
+ expect(mockedDetailsCall).toHaveBeenCalledWith("tok-detail", "req-abc-123", expect.any(String)),
+ );
+ });
+
+ it("shows an error state (not the empty state) when the logs query fails", async () => {
+ mockedLogsCall.mockRejectedValue(new Error("boom"));
+ renderWithProviders();
+
+ expect(await screen.findByText("Failed to load your logs")).toBeInTheDocument();
+ expect(screen.queryByText("No logs for this period")).not.toBeInTheDocument();
+ });
+
+ it("falls back to proxy_server_request when messages is empty for the request payload", async () => {
+ mockedDetailsCall.mockResolvedValue({
+ messages: {},
+ proxy_server_request: { body: { messages: [{ role: "user", content: "hello from proxy" }] } },
+ response: { ok: true },
+ });
+ renderWithProviders();
+
+ fireEvent.click(await screen.findByText("gpt-4o"));
+
+ expect(await screen.findByText(/hello from proxy/)).toBeInTheDocument();
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/chat/LogsPanel.tsx b/ui/litellm-dashboard/src/components/chat/LogsPanel.tsx
new file mode 100644
index 00000000000..d1bddfa423b
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/chat/LogsPanel.tsx
@@ -0,0 +1,348 @@
+"use client";
+
+import React, { useState } from "react";
+import moment from "moment";
+import { AlertCircle, ScrollText } from "lucide-react";
+import { keepPreviousData, useQuery } from "@tanstack/react-query";
+import { uiSpendLogDetailsCall, uiSpendLogsCall } from "../networking";
+import { Button } from "@/components/ui/button";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
+
+const LOGS_QUERY_KEY = "chat-user-logs";
+const PAGE_SIZE = 50;
+
+interface Props {
+ accessToken: string;
+ userId: string;
+}
+
+type TimeRange = "24h" | "7d" | "30d";
+
+const TIME_RANGE_OPTIONS: { value: TimeRange; label: string }[] = [
+ { value: "24h", label: "24h" },
+ { value: "7d", label: "7d" },
+ { value: "30d", label: "30d" },
+];
+
+function getStartMoment(range: TimeRange): moment.Moment {
+ if (range === "24h") return moment().subtract(24, "hours");
+ if (range === "7d") return moment().subtract(7, "days");
+ return moment().subtract(30, "days");
+}
+
+interface LogRow {
+ request_id: string;
+ model: string;
+ custom_llm_provider?: string;
+ status?: string;
+ spend: number;
+ total_tokens: number;
+ prompt_tokens: number;
+ completion_tokens: number;
+ startTime: string;
+ endTime: string;
+ request_duration_ms?: number;
+}
+
+interface PaginatedLogs {
+ data: LogRow[];
+ total: number;
+ page: number;
+ page_size: number;
+ total_pages: number;
+}
+
+interface LogDetails {
+ messages?: unknown;
+ response?: unknown;
+ proxy_server_request?: unknown;
+}
+
+function formatTokens(n: number): string {
+ return (n ?? 0).toLocaleString();
+}
+
+function formatCost(spend: number): string {
+ const value = spend ?? 0;
+ if (value === 0) return "$0";
+ if (value < 0.01) return `$${value.toFixed(6)}`;
+ return `$${value.toFixed(4)}`;
+}
+
+function durationMs(row: LogRow): number | null {
+ if (row.request_duration_ms != null) return row.request_duration_ms;
+ if (row.startTime && row.endTime) return Date.parse(row.endTime) - Date.parse(row.startTime);
+ return null;
+}
+
+function formatDuration(row: LogRow): string {
+ const ms = durationMs(row);
+ if (ms == null || Number.isNaN(ms)) return "-";
+ return `${(ms / 1000).toFixed(2)}s`;
+}
+
+function StatusBadge({ status }: { status?: string }) {
+ const isFailure = status === "failure";
+ return (
+
+
+ {isFailure ? "Failure" : "Success"}
+
+ );
+}
+
+function JsonBlock({ value }: { value: unknown }) {
+ if (value == null || value === "") {
+ return Not available
;
+ }
+ const text = typeof value === "string" ? value : JSON.stringify(value, null, 2);
+ return (
+
+ {text}
+
+ );
+}
+
+function LogsSkeleton() {
+ return (
+
+
+ {[...Array(8)].map((_, i) => (
+
+
+
+
+
+
+ ))}
+
+
+ );
+}
+
+function LogsEmpty() {
+ return (
+
+
+ No logs for this period
+
+ );
+}
+
+function LogsError({ onRetry }: { onRetry: () => void }) {
+ return (
+
+
+ Failed to load your logs
+
+
+ );
+}
+
+function LogsTable({ rows, onRowClick }: { rows: LogRow[]; onRowClick: (row: LogRow) => void }) {
+ return (
+
+
+
+
+ Time
+ Model
+ Status
+ Tokens
+ Duration
+ Cost
+
+
+
+ {rows.map((row) => (
+ onRowClick(row)}>
+
+ {moment(row.startTime).format("MMM D, HH:mm:ss")}
+
+ {row.model || "-"}
+
+
+
+ {formatTokens(row.total_tokens)}
+
+ {formatDuration(row)}
+
+ {formatCost(row.spend)}
+
+ ))}
+
+
+
+ );
+}
+
+function LogDetailDialog({
+ log,
+ details,
+ isLoading,
+ onClose,
+}: {
+ log: LogRow | null;
+ details: LogDetails | undefined;
+ isLoading: boolean;
+ onClose: () => void;
+}) {
+ return (
+
+ );
+}
+
+const LogsPanel: React.FC = ({ accessToken, userId }) => {
+ const [timeRange, setTimeRange] = useState("24h");
+ const [page, setPage] = useState(1);
+ const [selectedLog, setSelectedLog] = useState(null);
+
+ const startDate = getStartMoment(timeRange).utc().format("YYYY-MM-DD HH:mm:ss");
+ const endDate = moment().utc().format("YYYY-MM-DD HH:mm:ss");
+
+ const logsCallOptions = {
+ accessToken,
+ start_date: startDate,
+ end_date: endDate,
+ page,
+ page_size: PAGE_SIZE,
+ params: { user_id: userId, sort_by: "startTime", sort_order: "desc" as const },
+ };
+ const logsQueryOptions = {
+ queryKey: [LOGS_QUERY_KEY, accessToken, userId, timeRange, page],
+ queryFn: () => uiSpendLogsCall(logsCallOptions),
+ enabled: !!accessToken && !!userId,
+ placeholderData: keepPreviousData,
+ };
+ const { data, isLoading, isError, refetch } = useQuery(logsQueryOptions);
+
+ const logs = data as PaginatedLogs | undefined;
+ const rows = logs?.data ?? [];
+ const totalPages = logs?.total_pages ?? 0;
+ const total = logs?.total ?? 0;
+
+ const detailStartDate = selectedLog ? moment(selectedLog.startTime).utc().format("YYYY-MM-DD HH:mm:ss") : "";
+ const { data: detailData, isLoading: isDetailLoading } = useQuery({
+ queryKey: [LOGS_QUERY_KEY, "detail", accessToken, selectedLog?.request_id, selectedLog?.startTime],
+ queryFn: () => uiSpendLogDetailsCall(accessToken, selectedLog!.request_id, detailStartDate),
+ enabled: !!accessToken && !!selectedLog,
+ });
+ const details = detailData as LogDetails | undefined;
+
+ const renderBody = () => {
+ if (isLoading) return ;
+ if (isError) return refetch()} />;
+ if (rows.length === 0) return ;
+ return (
+ <>
+
+
+
+ {total.toLocaleString()} request{total === 1 ? "" : "s"}
+ {totalPages > 1 ? ` ยท Page ${page} of ${totalPages}` : ""}
+
+ {totalPages > 1 && (
+
+
+
+
+ )}
+
+ >
+ );
+ };
+
+ return (
+
+
+
+
Your Logs
+
Request logs for your account only
+
+
+ {TIME_RANGE_OPTIONS.map((opt) => (
+
+ ))}
+
+
+
+ {renderBody()}
+
+
setSelectedLog(null)}
+ />
+
+ );
+};
+
+export default LogsPanel;