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:
Yuneng Jiang 2026-08-03 16:25:35 -07:00
parent 47d2e225b7
commit 9c2c79f976
No known key found for this signature in database
4 changed files with 299 additions and 78 deletions

View file

@ -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

View file

@ -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" }],

View file

@ -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 {

View file

@ -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 : {};
};