mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
fix(ui): render Responses API request and response in the logs drawer
The Pretty view only parsed the Chat Completions shape (messages / choices[0].message), so any spend log storing the Responses API shape (input / output) rendered an empty Input card and the literal text "No response data available" even though the row held the full request and response. This also hit plain /v1/chat/completions callers, because litellm may route those over the Responses bridge and then store the upstream Responses-shaped body. Parsing now branches on a tagged union covering both shapes, which also replaces the any-typed key sniffing and the role guessing it relied on.
This commit is contained in:
parent
47d2e225b7
commit
9c2c79f976
4 changed files with 299 additions and 78 deletions
|
|
@ -4142,11 +4142,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/view_logs/LogDetailsDrawer/prettyMessagesUtils.ts": {
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/view_logs/LogDetailsDrawer/useKeyboardNavigation.ts": {
|
||||
"react-hooks/immutability": {
|
||||
"count": 2
|
||||
|
|
|
|||
|
|
@ -76,6 +76,126 @@ describe("PrettyMessagesView", () => {
|
|||
expect(modelElements.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("renders a Responses API log, whose body uses input/output instead of messages/choices", () => {
|
||||
const request = {
|
||||
model: "gpt-5.6",
|
||||
input: [{ role: "user", content: "Reply with exactly: hello from responses api" }],
|
||||
};
|
||||
const response = {
|
||||
output: [
|
||||
{
|
||||
id: "msg_070989277645d4ae",
|
||||
role: "assistant",
|
||||
type: "message",
|
||||
status: "completed",
|
||||
content: [{ text: "hello from responses api", type: "output_text", annotations: [] }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
render(<PrettyMessagesView request={request} response={response} />);
|
||||
expect(screen.getByText("Reply with exactly: hello from responses api")).toBeInTheDocument();
|
||||
expect(screen.getByText("hello from responses api")).toBeInTheDocument();
|
||||
expect(screen.queryByText("No response data available")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a Responses API tool call, whose output item is a function_call", () => {
|
||||
const request = {
|
||||
model: "gpt-5.6",
|
||||
input: [{ role: "user", content: "What is the weather in San Francisco? Use the tool." }],
|
||||
};
|
||||
const response = {
|
||||
output: [
|
||||
{
|
||||
id: "fc_08edf6c2312f1485",
|
||||
name: "get_weather",
|
||||
type: "function_call",
|
||||
status: "completed",
|
||||
call_id: "call_AtO0J9eNy5jgECXzBicMJM8W",
|
||||
arguments: '{"city":"San Francisco"}',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
render(<PrettyMessagesView request={request} response={response} />);
|
||||
expect(screen.getByText("What is the weather in San Francisco? Use the tool.")).toBeInTheDocument();
|
||||
expect(screen.getByText("get_weather")).toBeInTheDocument();
|
||||
expect(screen.queryByText("No response data available")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders instructions as the system turn and a bare string input", () => {
|
||||
const request = { model: "gpt-5.6", instructions: "You are terse.", input: "Say A" };
|
||||
const response = {
|
||||
output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: "A" }] }],
|
||||
};
|
||||
|
||||
render(<PrettyMessagesView request={request} response={response} />);
|
||||
expect(screen.getByText("You are terse.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Say A")).toBeInTheDocument();
|
||||
expect(screen.getByText("A")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("skips reasoning output items rather than rendering them as empty turns", () => {
|
||||
const request = { input: [{ role: "user", content: "Think then answer" }] };
|
||||
const response = {
|
||||
output: [
|
||||
{ type: "reasoning", id: "rs_1", summary: [] },
|
||||
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "answered" }] },
|
||||
],
|
||||
};
|
||||
|
||||
render(<PrettyMessagesView request={request} response={response} />);
|
||||
expect(screen.getByText("answered")).toBeInTheDocument();
|
||||
expect(screen.queryByText("No response data available")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a Responses API follow-up turn carrying a prior function_call and its output", () => {
|
||||
const request = {
|
||||
input: [
|
||||
{ role: "user", content: "What is the weather in San Francisco? Use the tool." },
|
||||
{
|
||||
type: "function_call",
|
||||
name: "get_weather",
|
||||
call_id: "call_AtO0J9eNy5jgECXzBicMJM8W",
|
||||
arguments: '{"city":"San Francisco"}',
|
||||
},
|
||||
{ type: "function_call_output", call_id: "call_AtO0J9eNy5jgECXzBicMJM8W", output: '{"temp":18}' },
|
||||
],
|
||||
};
|
||||
const response = {
|
||||
output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: "It is 18 degrees." }] }],
|
||||
};
|
||||
|
||||
render(<PrettyMessagesView request={request} response={response} />);
|
||||
expect(screen.getByText("It is 18 degrees.")).toBeInTheDocument();
|
||||
expect(screen.getByText('{"temp":18}')).toBeInTheDocument();
|
||||
expect(screen.getByText("TOOL")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("maps the developer and legacy function roles onto the roles the drawer renders", () => {
|
||||
const request = {
|
||||
messages: [
|
||||
{ role: "developer", content: "Stay terse." },
|
||||
{ role: "user", content: "Weather?" },
|
||||
{ role: "function", name: "get_weather", content: '{"temp":18}' },
|
||||
],
|
||||
};
|
||||
const response = { choices: [{ message: { role: "assistant", content: "18 degrees." } }] };
|
||||
|
||||
render(<PrettyMessagesView request={request} response={response} />);
|
||||
expect(screen.getByText("Stay terse.")).toBeInTheDocument();
|
||||
expect(screen.getByText("TOOL")).toBeInTheDocument();
|
||||
expect(screen.queryByText("FUNCTION")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("still reports missing output when a Responses API log has an empty output array", () => {
|
||||
const request = { input: [{ role: "user", content: "Hello" }] };
|
||||
|
||||
render(<PrettyMessagesView request={request} response={{ output: [] }} />);
|
||||
expect(screen.getByText("Hello")).toBeInTheDocument();
|
||||
expect(screen.getByText("No response data available")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render standard view when response has results but no realtime events", () => {
|
||||
const request = {
|
||||
messages: [{ role: "user", content: "Test" }],
|
||||
|
|
|
|||
|
|
@ -2,17 +2,29 @@
|
|||
* Type definitions for pretty messages view
|
||||
*/
|
||||
|
||||
export type MessageRole = "system" | "user" | "assistant" | "tool";
|
||||
|
||||
export interface ParsedMessage {
|
||||
role: "system" | "user" | "assistant" | "tool";
|
||||
role: MessageRole;
|
||||
content: string;
|
||||
toolCalls?: ToolCall[];
|
||||
toolCallId?: string;
|
||||
}
|
||||
|
||||
export type RequestPayload =
|
||||
| { kind: "chat"; messages: readonly unknown[] }
|
||||
| { kind: "responses"; instructions: string; input: string | readonly unknown[] }
|
||||
| { kind: "unknown" };
|
||||
|
||||
export type ResponsePayload =
|
||||
| { kind: "chat"; choices: readonly unknown[] }
|
||||
| { kind: "responses"; output: readonly unknown[] }
|
||||
| { kind: "unknown" };
|
||||
|
||||
export interface ToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: Record<string, any>;
|
||||
arguments: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ParsedMessages {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,15 @@
|
|||
* Utility functions for parsing and formatting messages for pretty view
|
||||
*/
|
||||
|
||||
import { ParsedMessage, ParsedMessages, RoleStyle } from "./prettyMessagesTypes";
|
||||
import {
|
||||
MessageRole,
|
||||
ParsedMessage,
|
||||
ParsedMessages,
|
||||
RequestPayload,
|
||||
ResponsePayload,
|
||||
RoleStyle,
|
||||
ToolCall,
|
||||
} from "./prettyMessagesTypes";
|
||||
|
||||
/**
|
||||
* Role color styles for message cards - minimal, professional design
|
||||
|
|
@ -35,102 +43,188 @@ export const ROLE_STYLES: Record<string, RoleStyle> = {
|
|||
},
|
||||
};
|
||||
|
||||
type UnknownRecord = Record<string, unknown>;
|
||||
|
||||
const isRecord = (value: unknown): value is UnknownRecord =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const asString = (value: unknown): string => (typeof value === "string" ? value : "");
|
||||
|
||||
const ROLES: readonly MessageRole[] = ["system", "user", "assistant", "tool"];
|
||||
|
||||
const toRole = (value: unknown, fallback: MessageRole): MessageRole => {
|
||||
if (value === "developer") return "system";
|
||||
if (value === "function") return "tool";
|
||||
return ROLES.includes(value as MessageRole) ? (value as MessageRole) : fallback;
|
||||
};
|
||||
|
||||
const classifyRequest = (request: unknown): RequestPayload => {
|
||||
if (Array.isArray(request)) return { kind: "chat", messages: request };
|
||||
if (!isRecord(request)) return { kind: "unknown" };
|
||||
if (Array.isArray(request.messages)) return { kind: "chat", messages: request.messages };
|
||||
const { input } = request;
|
||||
if (typeof input === "string" || Array.isArray(input)) {
|
||||
return { kind: "responses", instructions: asString(request.instructions), input };
|
||||
}
|
||||
return { kind: "unknown" };
|
||||
};
|
||||
|
||||
const classifyResponse = (response: unknown): ResponsePayload => {
|
||||
if (!isRecord(response)) return { kind: "unknown" };
|
||||
if (Array.isArray(response.choices)) return { kind: "chat", choices: response.choices };
|
||||
if (Array.isArray(response.output)) return { kind: "responses", output: response.output };
|
||||
return { kind: "unknown" };
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse request messages and response message from log data
|
||||
*/
|
||||
export const parseMessages = (request: any, response: any): ParsedMessages => {
|
||||
// Parse request messages. `request` is either the raw request body
|
||||
// ({ messages: [...] }) or, when prompts come from cold storage, the bare
|
||||
// messages array itself.
|
||||
const requestMessages: ParsedMessage[] = [];
|
||||
export const parseMessages = (request: unknown, response: unknown): ParsedMessages => ({
|
||||
requestMessages: parseRequestMessages(classifyRequest(request)),
|
||||
responseMessage: parseResponseMessage(classifyResponse(response)),
|
||||
});
|
||||
|
||||
const requestMessageList = Array.isArray(request)
|
||||
? request
|
||||
: Array.isArray(request?.messages)
|
||||
? request.messages
|
||||
: [];
|
||||
|
||||
requestMessageList.forEach((msg: any) => {
|
||||
requestMessages.push({
|
||||
role: msg.role || "user",
|
||||
content: parseMessageContent(msg.content),
|
||||
toolCallId: msg.tool_call_id,
|
||||
});
|
||||
});
|
||||
|
||||
// Parse response message
|
||||
let responseMessage: ParsedMessage | null = null;
|
||||
const responseMsg = response?.choices?.[0]?.message;
|
||||
|
||||
if (responseMsg) {
|
||||
responseMessage = {
|
||||
role: responseMsg.role || "assistant",
|
||||
content: responseMsg.content || "",
|
||||
toolCalls: parseToolCalls(responseMsg.tool_calls),
|
||||
};
|
||||
const parseRequestMessages = (payload: RequestPayload): ParsedMessage[] => {
|
||||
switch (payload.kind) {
|
||||
case "chat":
|
||||
return payload.messages.map(parseChatMessage);
|
||||
case "responses": {
|
||||
const instructions: ParsedMessage[] = payload.instructions
|
||||
? [{ role: "system", content: payload.instructions }]
|
||||
: [];
|
||||
const input: ParsedMessage[] =
|
||||
typeof payload.input === "string"
|
||||
? [{ role: "user", content: payload.input }]
|
||||
: payload.input.flatMap(parseResponsesInputItem);
|
||||
return [...instructions, ...input];
|
||||
}
|
||||
case "unknown":
|
||||
return [];
|
||||
}
|
||||
|
||||
return { requestMessages, responseMessage };
|
||||
};
|
||||
|
||||
const parseResponseMessage = (payload: ResponsePayload): ParsedMessage | null => {
|
||||
switch (payload.kind) {
|
||||
case "chat": {
|
||||
const choice = payload.choices[0];
|
||||
const message = isRecord(choice) ? choice.message : undefined;
|
||||
if (!isRecord(message)) return null;
|
||||
return {
|
||||
role: toRole(message.role, "assistant"),
|
||||
content: parseMessageContent(message.content),
|
||||
toolCalls: parseChatToolCalls(message.tool_calls),
|
||||
};
|
||||
}
|
||||
case "responses": {
|
||||
const content = payload.output
|
||||
.filter((item): item is UnknownRecord => isRecord(item) && item.type === "message")
|
||||
.map((item) => parseMessageContent(item.content))
|
||||
.filter((text) => text.length > 0)
|
||||
.join("\n");
|
||||
const toolCalls = payload.output.filter(isResponsesFunctionCall).map(parseResponsesFunctionCall);
|
||||
if (content.length === 0 && toolCalls.length === 0) return null;
|
||||
return { role: "assistant", content, toolCalls: toolCalls.length > 0 ? toolCalls : undefined };
|
||||
}
|
||||
case "unknown":
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const parseChatMessage = (message: unknown): ParsedMessage => {
|
||||
if (!isRecord(message)) return { role: "user", content: parseMessageContent(message) };
|
||||
return {
|
||||
role: toRole(message.role, "user"),
|
||||
content: parseMessageContent(message.content),
|
||||
toolCalls: parseChatToolCalls(message.tool_calls),
|
||||
toolCallId: typeof message.tool_call_id === "string" ? message.tool_call_id : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const parseResponsesInputItem = (item: unknown): ParsedMessage[] => {
|
||||
if (typeof item === "string") return [{ role: "user", content: item }];
|
||||
if (!isRecord(item)) return [];
|
||||
if (item.type === "function_call") {
|
||||
return [{ role: "assistant", content: "", toolCalls: [parseResponsesFunctionCall(item)] }];
|
||||
}
|
||||
if (item.type === "function_call_output") {
|
||||
return [{ role: "tool", content: parseMessageContent(item.output), toolCallId: asString(item.call_id) }];
|
||||
}
|
||||
if (item.type === "reasoning") return [];
|
||||
if ("role" in item || "content" in item) {
|
||||
return [{ role: toRole(item.role, "user"), content: parseMessageContent(item.content) }];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const isResponsesFunctionCall = (item: unknown): item is UnknownRecord =>
|
||||
isRecord(item) && item.type === "function_call";
|
||||
|
||||
const parseResponsesFunctionCall = (item: UnknownRecord): ToolCall => ({
|
||||
id: asString(item.call_id) || asString(item.id),
|
||||
name: asString(item.name) || "unknown",
|
||||
arguments: parseToolArguments(item.arguments),
|
||||
});
|
||||
|
||||
/**
|
||||
* Parse message content - handle strings and content arrays (for vision, etc.)
|
||||
*/
|
||||
const parseMessageContent = (content: any): string => {
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
// Handle content arrays (vision API format)
|
||||
return content
|
||||
.map((item) => {
|
||||
if (typeof item === "string") return item;
|
||||
if (item.type === "text") return item.text;
|
||||
if (item.type === "image_url") return "[Image]";
|
||||
return JSON.stringify(item);
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
// Fallback to JSON string for complex content
|
||||
const parseMessageContent = (content: unknown): string => {
|
||||
if (typeof content === "string") return content;
|
||||
if (content === null || content === undefined) return "";
|
||||
if (Array.isArray(content)) return content.map(parseContentPart).join("\n");
|
||||
return JSON.stringify(content);
|
||||
};
|
||||
|
||||
const parseContentPart = (part: unknown): string => {
|
||||
if (typeof part === "string") return part;
|
||||
if (!isRecord(part)) return JSON.stringify(part);
|
||||
switch (part.type) {
|
||||
case "text":
|
||||
case "input_text":
|
||||
case "output_text":
|
||||
return asString(part.text);
|
||||
case "refusal":
|
||||
return asString(part.refusal);
|
||||
case "image_url":
|
||||
case "input_image":
|
||||
return "[Image]";
|
||||
case "input_file":
|
||||
return "[File]";
|
||||
case "input_audio":
|
||||
return "[Audio]";
|
||||
default:
|
||||
return JSON.stringify(part);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse tool calls from response message
|
||||
*/
|
||||
const parseToolCalls = (
|
||||
toolCalls: any[],
|
||||
):
|
||||
| Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: Record<string, any>;
|
||||
}>
|
||||
| undefined => {
|
||||
if (!toolCalls || !Array.isArray(toolCalls)) return undefined;
|
||||
|
||||
return toolCalls.map((tc) => ({
|
||||
id: tc.id || "",
|
||||
name: tc.function?.name || "unknown",
|
||||
arguments: parseToolArguments(tc.function?.arguments),
|
||||
}));
|
||||
const parseChatToolCalls = (toolCalls: unknown): ToolCall[] | undefined => {
|
||||
if (!Array.isArray(toolCalls)) return undefined;
|
||||
return toolCalls.map((toolCall) => {
|
||||
const call = isRecord(toolCall) ? toolCall : {};
|
||||
const fn = isRecord(call.function) ? call.function : {};
|
||||
return {
|
||||
id: asString(call.id),
|
||||
name: asString(fn.name) || "unknown",
|
||||
arguments: parseToolArguments(fn.arguments),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse tool arguments - handle both string and object formats
|
||||
*/
|
||||
const parseToolArguments = (args: any): Record<string, any> => {
|
||||
const parseToolArguments = (args: unknown): Record<string, unknown> => {
|
||||
if (!args) return {};
|
||||
|
||||
if (typeof args === "string") {
|
||||
try {
|
||||
return JSON.parse(args);
|
||||
const parsed: unknown = JSON.parse(args);
|
||||
return isRecord(parsed) ? parsed : { raw: args };
|
||||
} catch {
|
||||
return { raw: args };
|
||||
}
|
||||
}
|
||||
|
||||
return args;
|
||||
return isRecord(args) ? args : {};
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue