mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
Merge 840eeca200 into ad8babae33
This commit is contained in:
commit
f0e405e763
8 changed files with 355 additions and 10 deletions
|
|
@ -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> = {}): 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(<LogDetailContent logEntry={log} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Next turns" }));
|
||||
expect(screen.getByText("Turns 51–100 of 101")).toBeInTheDocument();
|
||||
|
||||
rerender(<LogDetailContent logEntry={{ ...log, response: { ...response } }} />);
|
||||
expect(screen.getByText("Turns 51–100 of 101")).toBeInTheDocument();
|
||||
|
||||
rerender(<LogDetailContent logEntry={{ ...log, request_id: "websocket-second" }} />);
|
||||
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(<LogDetailContent logEntry={log} />);
|
||||
expect(screen.getByText("Turns 1–50 of 101")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the component successfully", () => {
|
||||
render(<LogDetailContent logEntry={createLogEntry()} />);
|
||||
|
||||
|
|
|
|||
|
|
@ -664,6 +664,7 @@ function RequestResponseSection({
|
|||
<div>
|
||||
<TabsContent value="pretty">
|
||||
<PrettyMessagesView
|
||||
key={logEntry.request_id}
|
||||
request={getRawRequest()}
|
||||
response={getFormattedResponse()}
|
||||
metrics={{
|
||||
|
|
|
|||
|
|
@ -1,9 +1,84 @@
|
|||
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(<PrettyMessagesView request={{}} response={response} />);
|
||||
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(
|
||||
<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,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 (
|
||||
<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.slice(start, end).map((turn, index) => (
|
||||
<section
|
||||
key={`${turn.id}-${start + index}`}
|
||||
className="px-4 py-3"
|
||||
aria-label={`Turn ${start + index + 1} · ${turn.status}`}
|
||||
>
|
||||
<h4 className="mb-2 text-sm font-medium">
|
||||
Turn {start + 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>
|
||||
))}
|
||||
{turns.length > TURNS_PER_PAGE && (
|
||||
<nav aria-label="Response turns" className="flex items-center justify-between gap-2 px-4 py-3">
|
||||
<Button variant="outline" size="sm" disabled={currentPage === 0} onClick={() => setPage(currentPage - 1)}>
|
||||
Previous turns
|
||||
</Button>
|
||||
<span className="text-sm" aria-live="polite">
|
||||
Turns {start + 1}–{end} of {turns.length}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage === lastPage}
|
||||
onClick={() => setPage(currentPage + 1)}
|
||||
>
|
||||
Next turns
|
||||
</Button>
|
||||
</nav>
|
||||
)}
|
||||
</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,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: "" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<Record<string, ResponsesWebSocketTurn["status"]>> = {
|
||||
"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<string>();
|
||||
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":
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue