diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/PrettyMessagesView.integration.test.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/PrettyMessagesView.integration.test.tsx
index efd1a2ca995..7282aa4d666 100644
--- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/PrettyMessagesView.integration.test.tsx
+++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/PrettyMessagesView.integration.test.tsx
@@ -1,9 +1,30 @@
import React from "react";
-import { render, screen } from "@testing-library/react";
+import { fireEvent, render, screen } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { PrettyMessagesView } from "./PrettyMessagesView";
describe("PrettyMessagesView", () => {
+ it("bounds rendered WebSocket turns and lets operators navigate all pages", () => {
+ const response = Array.from({ length: 101 }, (_, index) => ({
+ type: "response.completed",
+ response: { id: `resp_${index}`, output: [] },
+ }));
+ render();
+ expect(screen.getAllByRole("region", { name: /^Turn / })).toHaveLength(50);
+ expect(screen.getByText("Turns 1–50 of 101")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Previous turns" })).toBeDisabled();
+ fireEvent.click(screen.getByRole("button", { name: "Next turns" }));
+ expect(screen.queryByText("Turn 1 · Completed")).not.toBeInTheDocument();
+ expect(screen.getByText("Turn 51 · Completed")).toBeInTheDocument();
+ expect(screen.getAllByRole("region", { name: /^Turn / })).toHaveLength(50);
+ fireEvent.click(screen.getByRole("button", { name: "Next turns" }));
+ expect(screen.getByText("Turns 101–101 of 101")).toBeInTheDocument();
+ expect(screen.getAllByRole("region", { name: /^Turn / })).toHaveLength(1);
+ expect(screen.getByRole("button", { name: "Next turns" })).toBeDisabled();
+ fireEvent.click(screen.getByRole("button", { name: "Previous turns" }));
+ expect(screen.getByText("Turn 51 · Completed")).toBeInTheDocument();
+ });
+
it.each([false, true])("renders separate WebSocket turns (wrapped: %s)", (wrapped) => {
const events = [
{ type: "response.created", response: { id: "resp_1", output: [] } },
diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/ResponsesWebSocketPrettyView.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/ResponsesWebSocketPrettyView.tsx
index 3569697dd4b..4035e6204f6 100644
--- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/ResponsesWebSocketPrettyView.tsx
+++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/ResponsesWebSocketPrettyView.tsx
@@ -3,6 +3,9 @@ import { toast } from "@/lib/toast";
import { ResponsesWebSocketTurn } from "./prettyMessagesTypes";
import { SectionHeader } from "./SectionHeader";
import { SimpleMessageBlock } from "./SimpleMessageBlock";
+import { Button } from "@/components/ui/button";
+
+const TURNS_PER_PAGE = 50;
interface ResponsesWebSocketPrettyViewProps {
turns: readonly ResponsesWebSocketTurn[];
@@ -16,6 +19,11 @@ export function ResponsesWebSocketPrettyView({
outputCost,
}: ResponsesWebSocketPrettyViewProps) {
const [isCollapsed, setIsCollapsed] = useState(false);
+ const [page, setPage] = useState(0);
+ const lastPage = Math.max(0, Math.ceil(turns.length / TURNS_PER_PAGE) - 1);
+ const currentPage = Math.min(page, lastPage);
+ const start = currentPage * TURNS_PER_PAGE;
+ const end = Math.min(start + TURNS_PER_PAGE, turns.length);
const copyOutput = async () => {
await navigator.clipboard.writeText(JSON.stringify(turns, null, 2));
toast.success("Output copied");
@@ -32,14 +40,14 @@ export function ResponsesWebSocketPrettyView({
/>
{!isCollapsed && (
- {turns.map((turn, index) => (
+ {turns.slice(start, end).map((turn, index) => (
- Turn {index + 1} · {turn.status}
+ Turn {start + index + 1} · {turn.status}
{turn.id && {turn.id}
}
{turn.detail && {turn.detail}
}
@@ -54,6 +62,24 @@ export function ResponsesWebSocketPrettyView({
)}
))}
+ {turns.length > TURNS_PER_PAGE && (
+
+ )}
)}
diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/prettyMessagesUtils.test.ts b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/prettyMessagesUtils.test.ts
index 15e44916038..e7881a6f5c8 100644
--- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/prettyMessagesUtils.test.ts
+++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/prettyMessagesUtils.test.ts
@@ -2,6 +2,23 @@ import { describe, expect, it } from "vitest";
import { parseResponsesWebSocketTurns } from "./prettyMessagesUtils";
describe("parseResponsesWebSocketTurns", () => {
+ it("reads event types a bounded number of times for a large session", () => {
+ const reads = { count: 0 };
+ const events = Array.from({ length: 2000 }, (_, index) =>
+ ["response.created", "response.completed", "response.completed"].map((type) => ({
+ get type() {
+ reads.count += 1;
+ return type;
+ },
+ response: { id: `resp_${index}`, output: [] },
+ })),
+ ).flat();
+ const turns = parseResponsesWebSocketTurns(events);
+ expect(turns).toHaveLength(2000);
+ expect(turns?.[1999].id).toBe("resp_1999");
+ expect(reads.count).toBeLessThan(events.length * 20);
+ });
+
it.each([
null,
{},
diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/prettyMessagesUtils.ts b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/prettyMessagesUtils.ts
index 656698a762e..a01382516c2 100644
--- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/prettyMessagesUtils.ts
+++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/prettyMessagesUtils.ts
@@ -71,16 +71,17 @@ export const parseResponsesWebSocketTurns = (response: unknown): ResponsesWebSoc
"response.incomplete": "Incomplete",
"response.failed": "Failed",
};
- return events.flatMap((event, index): ResponsesWebSocketTurn[] => {
+ const terminalIds = new Set(
+ events.flatMap((event) =>
+ terminalTypes.includes(asString(event.type)) && isRecord(event.response) ? [asString(event.response.id)] : [],
+ ),
+ );
+ const seenTerminalIds = new Set();
+ return events.flatMap((event): ResponsesWebSocketTurn[] => {
const body = isRecord(event.response) ? event.response : {};
const id = asString(body.id);
if (event.type === "response.created") {
- const hasTerminal = events.some(
- (other) =>
- terminalTypes.includes(asString(other.type)) &&
- isRecord(other.response) &&
- asString(other.response.id) === id,
- );
+ const hasTerminal = terminalIds.has(id);
return hasTerminal ? [] : [{ id, status: "No terminal event recorded", message: null, detail: "" }];
}
if (event.type === "error") {
@@ -88,16 +89,8 @@ export const parseResponsesWebSocketTurns = (response: unknown): ResponsesWebSoc
return [{ id: "", status: "Error", message: null, detail: asString(error.message) || asString(error.code) }];
}
if (!terminalTypes.includes(asString(event.type))) return [];
- if (
- id &&
- events
- .slice(0, index)
- .some(
- (other) =>
- terminalTypes.includes(asString(other.type)) && isRecord(other.response) && other.response.id === id,
- )
- )
- return [];
+ if (id && seenTerminalIds.has(id)) return [];
+ if (id) seenTerminalIds.add(id);
const error = isRecord(body.error) ? body.error : {};
const incomplete = isRecord(body.incomplete_details) ? body.incomplete_details : {};
return [