diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.integration.test.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.integration.test.tsx index e0551c61062..913d8a8d03d 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.integration.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, within } from "@testing-library/react"; +import { fireEvent, render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import { GuardrailJumpLink, LogDetailContent } from "./LogDetailContent"; @@ -34,6 +34,33 @@ const createLogEntry = (overrides: Partial = {}): LogEntry => }) as LogEntry; describe("LogDetailContent", () => { + it("resets WebSocket pagination for a different log but preserves it when the same log refreshes", () => { + const response = { + results: Array.from({ length: 101 }, (_, index) => ({ + type: "response.completed", + response: { id: `resp_${index}`, output: [] }, + })), + }; + const log = createLogEntry({ request_id: "websocket-first", response }); + const { rerender } = render(); + fireEvent.click(screen.getByRole("button", { name: "Next turns" })); + expect(screen.getByText("Turns 51–100 of 101")).toBeInTheDocument(); + + rerender(); + expect(screen.getByText("Turns 51–100 of 101")).toBeInTheDocument(); + + rerender(); + expect(screen.getByText("Turns 1–50 of 101")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Previous turns" })).toBeDisabled(); + expect(screen.queryByText("Turn 51 · Completed")).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Next turns" })); + fireEvent.click(screen.getByRole("button", { name: "Next turns" })); + expect(screen.getByText("Turns 101–101 of 101")).toBeInTheDocument(); + + rerender(); + expect(screen.getByText("Turns 1–50 of 101")).toBeInTheDocument(); + }); + it("should render the component successfully", () => { render(); diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.tsx index f3762223e96..fb18114962a 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.tsx @@ -664,6 +664,7 @@ function RequestResponseSection({
{ + 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: [] } }, + { type: "response.output_text.delta", delta: "First answer" }, + { + type: "response.completed", + response: { + id: "resp_1", + output: [{ type: "message", content: [{ type: "output_text", text: "First answer" }] }], + }, + }, + { + type: "response.incomplete", + response: { + id: "resp_2", + incomplete_details: { reason: "max_output_tokens" }, + output: [{ type: "function_call", call_id: "call_1", name: "get_weather", arguments: '{"city":"Hanoi"}' }], + }, + }, + ]; + render( + , + ); + expect(screen.getByText("Check the weather")).toBeInTheDocument(); + expect(screen.getAllByText("First answer")).toHaveLength(1); + expect(screen.getByText("Turn 1 · Completed")).toBeInTheDocument(); + expect(screen.getByText("Turn 2 · Incomplete")).toBeInTheDocument(); + expect(screen.getByText("max_output_tokens")).toBeInTheDocument(); + expect(screen.getByText("get_weather")).toBeInTheDocument(); + expect(screen.getAllByText("Tokens: 42")).toHaveLength(1); + expect(screen.getAllByText("Cost: $0.001000")).toHaveLength(1); + }); + + it("shows failed and unfinished WebSocket turns without inventing output", () => { + render( + , + ); + expect(screen.getByText("Turn 1 · Failed")).toBeInTheDocument(); + expect(screen.getByText("Provider unavailable")).toBeInTheDocument(); + expect(screen.getByText("Turn 2 · No terminal event recorded")).toBeInTheDocument(); + }); + it("should render the component for standard chat completions", () => { const request = { messages: [{ role: "user", content: "Hello" }], diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/PrettyMessagesView.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/PrettyMessagesView.tsx index 7d7e1bca6e8..ea38ea51d38 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/PrettyMessagesView.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/PrettyMessagesView.tsx @@ -4,14 +4,15 @@ * Detects realtime API responses and renders a specialized view. */ -import { parseMessages } from "./prettyMessagesUtils"; +import { parseMessages, parseResponsesWebSocketTurns } from "./prettyMessagesUtils"; +import { ResponsesWebSocketPrettyView } from "./ResponsesWebSocketPrettyView"; import { InputCard } from "./InputCard"; import { OutputCard } from "./OutputCard"; import { isRealtimeResponse, RealtimePrettyView } from "./RealtimePrettyView"; interface PrettyMessagesViewProps { - request: any; - response: any; + request: unknown; + response: unknown; metrics?: { prompt_tokens?: number; completion_tokens?: number; @@ -26,6 +27,7 @@ export function PrettyMessagesView({ request, response, metrics }: PrettyMessage } const { requestMessages, responseMessage } = parseMessages(request, response); + const turns = parseResponsesWebSocketTurns(response); return (
@@ -33,11 +35,19 @@ export function PrettyMessagesView({ request, response, metrics }: PrettyMessage {/* Output Card */} - + {turns !== null ? ( + + ) : ( + + )}
); } diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/ResponsesWebSocketPrettyView.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/ResponsesWebSocketPrettyView.tsx new file mode 100644 index 00000000000..4035e6204f6 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/ResponsesWebSocketPrettyView.tsx @@ -0,0 +1,87 @@ +import { useState } from "react"; +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[]; + completionTokens?: number; + outputCost?: number; +} + +export function ResponsesWebSocketPrettyView({ + turns, + completionTokens, + 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"); + }; + return ( +
+ setIsCollapsed(!isCollapsed)} + /> + {!isCollapsed && ( +
+ {turns.slice(start, end).map((turn, index) => ( +
+

+ Turn {start + index + 1} · {turn.status} +

+ {turn.id &&
{turn.id}
} + {turn.detail &&

{turn.detail}

} + {turn.message ? ( + + ) : ( + No output recorded for this turn + )} +
+ ))} + {turns.length > TURNS_PER_PAGE && ( + + )} +
+ )} +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/prettyMessagesTypes.ts b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/prettyMessagesTypes.ts index 10f6cbe865d..47778e259b5 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/prettyMessagesTypes.ts +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/prettyMessagesTypes.ts @@ -31,3 +31,10 @@ export interface ParsedMessages { requestMessages: ParsedMessage[]; responseMessage: ParsedMessage | null; } + +export interface ResponsesWebSocketTurn { + id: string; + status: "Completed" | "Incomplete" | "Failed" | "Error" | "No terminal event recorded"; + message: ParsedMessage | null; + detail: string; +} 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 new file mode 100644 index 00000000000..e7881a6f5c8 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/prettyMessagesUtils.test.ts @@ -0,0 +1,86 @@ +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, + {}, + [], + { results: null }, + { choices: [] }, + { output: [] }, + { results: [{ type: "response.done" }] }, + { results: [null, "invalid", 42] }, + ])("leaves non-Responses payloads to their existing renderer: %j", (payload) => { + expect(parseResponsesWebSocketTurns(payload)).toBeNull(); + }); + + it("uses terminal output once, skipping deltas, created snapshots, and duplicate terminal IDs", () => { + const response = { + id: "resp_1", + output: [{ type: "message", content: [{ type: "output_text", text: "Final text" }] }], + }; + expect( + parseResponsesWebSocketTurns([ + null, + { type: "response.created", response }, + { type: "response.output_text.delta", delta: "Final text" }, + { type: "response.completed", response }, + { type: "response.completed", response }, + ]), + ).toEqual([ + { + id: "resp_1", + status: "Completed", + detail: "", + message: { role: "assistant", content: "Final text", toolCalls: undefined }, + }, + ]); + }); + + it("keeps failed output and errors inspectable alongside an unfinished turn", () => { + const turns = parseResponsesWebSocketTurns({ + results: [ + { + type: "response.failed", + response: { + id: "failed", + error: { code: "server_error" }, + output: [{ type: "message", content: [{ type: "output_text", text: "Partial answer" }] }], + }, + }, + { type: "response.created", response: { id: "pending" } }, + { type: "error", error: { message: "Connection error" } }, + ], + }); + expect(turns?.map(({ status, detail }) => ({ status, detail }))).toEqual([ + { status: "Failed", detail: "server_error" }, + { status: "No terminal event recorded", detail: "" }, + { status: "Error", detail: "Connection error" }, + ]); + expect(turns?.[0].message?.content).toBe("Partial answer"); + }); + + it("does not invent output for malformed terminal payloads", () => { + expect(parseResponsesWebSocketTurns([{ type: "response.completed", response: null }])).toEqual([ + { id: "", status: "Completed", message: null, detail: "" }, + ]); + }); +}); 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 1f4289e7c2f..a01382516c2 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/prettyMessagesUtils.ts +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/prettyMessagesUtils.ts @@ -8,6 +8,7 @@ import { ParsedMessages, RequestPayload, ResponsePayload, + ResponsesWebSocketTurn, ToolCall, } from "./prettyMessagesTypes"; @@ -52,6 +53,57 @@ export const parseMessages = (request: unknown, response: unknown): ParsedMessag responseMessage: parseResponseMessage(classifyResponse(response)), }); +export const parseResponsesWebSocketTurns = (response: unknown): ResponsesWebSocketTurn[] | null => { + const results: unknown = isRecord(response) ? response.results : response; + if (!Array.isArray(results)) return null; + const events = results.filter(isRecord); + if ( + !events.some((event) => + ["response.created", "response.completed", "response.incomplete", "response.failed"].includes( + asString(event.type), + ), + ) + ) + return null; + const terminalTypes = ["response.completed", "response.incomplete", "response.failed"]; + const terminalStatuses: Readonly> = { + "response.completed": "Completed", + "response.incomplete": "Incomplete", + "response.failed": "Failed", + }; + 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 = terminalIds.has(id); + return hasTerminal ? [] : [{ id, status: "No terminal event recorded", message: null, detail: "" }]; + } + if (event.type === "error") { + const error = isRecord(event.error) ? event.error : event; + return [{ id: "", status: "Error", message: null, detail: asString(error.message) || asString(error.code) }]; + } + if (!terminalTypes.includes(asString(event.type))) 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 [ + { + id, + status: terminalStatuses[asString(event.type)], + message: parseResponseMessage(classifyResponse(body)), + detail: asString(error.message) || asString(error.code) || asString(incomplete.reason), + }, + ]; + }); +}; + const parseRequestMessages = (payload: RequestPayload): ParsedMessage[] => { switch (payload.kind) { case "chat":