diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/PrettyMessagesView.test.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/PrettyMessagesView.integration.test.tsx
similarity index 78%
rename from ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/PrettyMessagesView.test.tsx
rename to ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/PrettyMessagesView.integration.test.tsx
index 807aaeeae00..efd1a2ca995 100644
--- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/PrettyMessagesView.test.tsx
+++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/PrettyMessagesView.integration.test.tsx
@@ -4,6 +4,60 @@ import { describe, it, expect } from "vitest";
import { PrettyMessagesView } from "./PrettyMessagesView";
describe("PrettyMessagesView", () => {
+ 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..3569697dd4b
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/ResponsesWebSocketPrettyView.tsx
@@ -0,0 +1,61 @@
+import { useState } from "react";
+import { toast } from "@/lib/toast";
+import { ResponsesWebSocketTurn } from "./prettyMessagesTypes";
+import { SectionHeader } from "./SectionHeader";
+import { SimpleMessageBlock } from "./SimpleMessageBlock";
+
+interface ResponsesWebSocketPrettyViewProps {
+ turns: readonly ResponsesWebSocketTurn[];
+ completionTokens?: number;
+ outputCost?: number;
+}
+
+export function ResponsesWebSocketPrettyView({
+ turns,
+ completionTokens,
+ outputCost,
+}: ResponsesWebSocketPrettyViewProps) {
+ const [isCollapsed, setIsCollapsed] = useState(false);
+ const copyOutput = async () => {
+ await navigator.clipboard.writeText(JSON.stringify(turns, null, 2));
+ toast.success("Output copied");
+ };
+ return (
+
+
setIsCollapsed(!isCollapsed)}
+ />
+ {!isCollapsed && (
+
+ {turns.map((turn, index) => (
+
+
+ Turn {index + 1} · {turn.status}
+
+ {turn.id && {turn.id}
}
+ {turn.detail && {turn.detail}
}
+ {turn.message ? (
+
+ ) : (
+ No output recorded for this turn
+ )}
+
+ ))}
+
+ )}
+
+ );
+}
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..15e44916038
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/prettyMessagesUtils.test.ts
@@ -0,0 +1,69 @@
+import { describe, expect, it } from "vitest";
+import { parseResponsesWebSocketTurns } from "./prettyMessagesUtils";
+
+describe("parseResponsesWebSocketTurns", () => {
+ 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..656698a762e 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,64 @@ 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",
+ };
+ return events.flatMap((event, index): 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,
+ );
+ 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 &&
+ events
+ .slice(0, index)
+ .some(
+ (other) =>
+ terminalTypes.includes(asString(other.type)) && isRecord(other.response) && other.response.id === id,
+ )
+ )
+ return [];
+ 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":