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":