mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
fix(ui): render native Responses WebSocket log output
This commit is contained in:
parent
93e39d5042
commit
74119b7c8f
6 changed files with 268 additions and 8 deletions
|
|
@ -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(
|
||||
<PrettyMessagesView
|
||||
request={{ input: "Check the weather" }}
|
||||
response={wrapped ? { results: events, usage: {} } : events}
|
||||
metrics={{ completion_tokens: 42, output_cost: 0.001 }}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<PrettyMessagesView
|
||||
request={{}}
|
||||
response={{
|
||||
results: [
|
||||
{ type: "response.failed", response: { id: "resp_failed", error: { message: "Provider unavailable" } } },
|
||||
{ type: "response.created", response: { id: "resp_unfinished" } },
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
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" }],
|
||||
|
|
@ -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 (
|
||||
<div>
|
||||
|
|
@ -33,11 +35,19 @@ export function PrettyMessagesView({ request, response, metrics }: PrettyMessage
|
|||
<InputCard messages={requestMessages} promptTokens={metrics?.prompt_tokens} inputCost={metrics?.input_cost} />
|
||||
|
||||
{/* Output Card */}
|
||||
<OutputCard
|
||||
message={responseMessage}
|
||||
completionTokens={metrics?.completion_tokens}
|
||||
outputCost={metrics?.output_cost}
|
||||
/>
|
||||
{turns !== null ? (
|
||||
<ResponsesWebSocketPrettyView
|
||||
turns={turns}
|
||||
completionTokens={metrics?.completion_tokens}
|
||||
outputCost={metrics?.output_cost}
|
||||
/>
|
||||
) : (
|
||||
<OutputCard
|
||||
message={responseMessage}
|
||||
completionTokens={metrics?.completion_tokens}
|
||||
outputCost={metrics?.output_cost}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="overflow-hidden rounded-md border border-border">
|
||||
<SectionHeader
|
||||
type="output"
|
||||
tokens={completionTokens}
|
||||
cost={outputCost}
|
||||
onCopy={copyOutput}
|
||||
isCollapsed={isCollapsed}
|
||||
onToggleCollapse={() => setIsCollapsed(!isCollapsed)}
|
||||
/>
|
||||
{!isCollapsed && (
|
||||
<div className="divide-y divide-border">
|
||||
{turns.map((turn, index) => (
|
||||
<section
|
||||
key={`${turn.id}-${index}`}
|
||||
className="px-4 py-3"
|
||||
aria-label={`Turn ${index + 1} · ${turn.status}`}
|
||||
>
|
||||
<h4 className="mb-2 text-sm font-medium">
|
||||
Turn {index + 1} · {turn.status}
|
||||
</h4>
|
||||
{turn.id && <div className="mb-2 break-all text-xs text-muted-foreground">{turn.id}</div>}
|
||||
{turn.detail && <p className="mb-2 whitespace-pre-wrap text-sm">{turn.detail}</p>}
|
||||
{turn.message ? (
|
||||
<SimpleMessageBlock
|
||||
label="ASSISTANT"
|
||||
content={turn.message.content}
|
||||
toolCalls={turn.message.toolCalls}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-[13px] italic text-muted-foreground">No output recorded for this turn</span>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: "" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<Record<string, ResponsesWebSocketTurn["status"]>> = {
|
||||
"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":
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue