fix(ui): surface x-litellm-call-id in Logs search, table and drawer (#42436)

* fix(ui): surface x-litellm-call-id in Logs search, table and drawer

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* chore(ui): regenerate api types for spend logs search description

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(e2e): drop redundant comments from the call id logs helpers

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* style(e2e): format logs call id helper and spec

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(ui): keep one id per Logs row, move x-litellm-call-id to hover and drawer

The Request ID cell shows only request_id again. When the row's litellm_call_id
differs, the cell tooltip lists it as x-litellm-call-id with its own copy button,
and the drawer header labels the second line x-litellm-call-id: instead of the
call id caption. Stacking two ids in every row made the column noisy for the
common case where the viewer only needs the row they searched for.

* test(e2e): cover the Request ID tooltip hover and copy path

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test: poll the clipboard after the tooltip copy and drop a jsdom aside

The e2e read navigator.clipboard right after the click, so a slow async write
could fail the check even though copy works. The unit test's fireEvent choice
(jsdom has no layout, so a real pointer move off the trigger closes the tooltip
before the click lands) is documented here instead of inline.

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: ryan-crabbe-berri <ryan@berri.ai>
This commit is contained in:
devin-ai-integration[bot] 2026-09-30 02:16:56 +00:00 • committed by GitHub
parent 82eb7405f5
commit 8afabe81f1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 238 additions and 49 deletions

View file

@ -2444,7 +2444,7 @@ def _build_spend_log_search_condition(
f"(request_id = {raw} OR ("
f"\"startTime\" >= ({window_start}::timestamptz AT TIME ZONE 'UTC') "
f"AND \"startTime\" <= ({window_end}::timestamptz AT TIME ZONE 'UTC') "
f'AND (api_key = {raw} OR team_id = {raw} OR "user" = {raw} OR end_user = {raw} '
f'AND (litellm_call_id = {raw} OR api_key = {raw} OR team_id = {raw} OR "user" = {raw} OR end_user = {raw} '
f"OR session_id = {raw} OR model_id = {raw})))"
)
return _SpendLogSearchCondition(sql=sql, params=(search, start_date, end_date))
@ -2557,7 +2557,7 @@ async def ui_view_spend_logs(
search: str | None = fastapi.Query(
default=None,
description=(
"Match a log whose request_id, api_key (hash), team_id, user, end_user, "
"Match a log whose request_id, litellm_call_id, api_key (hash), team_id, user, end_user, "
"session_id, or model_id equals this value. request_id matches across all time; the other columns "
"match inside start_date/end_date, which stay required"
),

View file

@ -51,6 +51,21 @@ export async function sendChatCompletion(request: APIRequestContext, opts: ChatO
return body.id as string;
}
export interface ServedChat {
requestId: string;
callId: string;
}
export async function sendChatCompletionWithCallId(request: APIRequestContext, opts: ChatOptions): Promise<ServedChat> {
const res = await postChatCompletion(request, opts);
expect(res.ok(), `chat completion for ${opts.model} failed (${res.status()}): ${await res.text()}`).toBe(true);
const callId = res.headers()["x-litellm-call-id"];
expect(callId, "proxy did not return an x-litellm-call-id header").toBeTruthy();
const body = await res.json();
expect(body.choices?.[0]?.message?.content).toContain(MOCK_RESPONSE_TEXT);
return { requestId: body.id as string, callId };
}
export interface ChatAttempt {
status: number;
body: string;
@ -124,7 +139,7 @@ export async function waitForSpendLog(
lastStatus = res.status();
if (res.ok()) {
const body = await res.json();
const rows = Array.isArray(body) ? body : (body?.data ?? []);
const rows = Array.isArray(body) ? body : body?.data ?? [];
if (rows.length > 0) {
return;
}

View file

@ -6,6 +6,7 @@ import {
CHAT_MODEL_A,
MOCK_RESPONSE_TEXT,
sendChatCompletion,
sendChatCompletionWithCallId,
waitForSpendLog,
waitForSpendLogByPrompt,
} from "../../helpers/traffic";
@ -95,6 +96,50 @@ test.describe("Logs page", () => {
await expect(drawer.getByText(MOCK_RESPONSE_TEXT, { exact: false }).first()).toBeVisible({ timeout: 20_000 });
});
test("a served request's Logs row and drawer show its x-litellm-call-id", async ({ page, request }) => {
const prompt = `logs-call-id-prompt-${uniqueSuffix()}`;
const { requestId, callId } = await sendChatCompletionWithCallId(request, {
model: CHAT_MODEL_A,
prompt,
});
expect(callId, "call id must differ from the provider response id for this check to mean anything").not.toBe(
requestId,
);
await waitForSpendLog(request, requestId);
await navigateToPage(page, Page.Logs);
await dismissFeedbackPopup(page);
const search = visibleTestId(page, "datatable-search");
await expect(search).toBeVisible({ timeout: 20_000 });
await search.fill(callId);
const row = requestLogsRows(page).filter({ hasText: requestId });
await expect(row, `no logs row for call id ${callId}`).toHaveCount(1, { timeout: 30_000 });
await expect(row, "the row itself shows only the request id").not.toContainText(callId);
await row.getByText(requestId).hover();
const tooltip = page.locator("[data-slot='tooltip-content']");
await expect(tooltip, "hovering the Request ID cell does not list the x-litellm-call-id").toContainText(
`x-litellm-call-id: ${callId}`,
{ timeout: 10_000 },
);
await tooltip.getByRole("button", { name: "Copy x-litellm-call-id" }).click();
if (await page.evaluate(() => window.isSecureContext)) {
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(callId);
}
await row.click();
const drawer = page.getByRole("dialog").first();
await expect(drawer.getByText("Request & Response")).toBeVisible({ timeout: 20_000 });
await expect(drawer.getByText("x-litellm-call-id:"), "drawer header lacks the x-litellm-call-id line").toBeVisible({
timeout: 10_000,
});
await expect(
drawer.getByText(callId, { exact: false }).first(),
`drawer does not show x-litellm-call-id ${callId}`,
).toBeVisible({ timeout: 10_000 });
});
// Split out because only the copy path needs a secure context; folding it in would
// take the drawer-rendering coverage down with it.
test("the drawer copies the request and the response to the clipboard", async ({ page, request }) => {

View file

@ -57,7 +57,7 @@ def _filter_logs_by_date_range(logs, where):
_SEARCH_CLAUSE_RE = re.compile(
r'\(request_id = \$(\d+) OR \("startTime" >= \(\$(\d+)::timestamptz AT TIME ZONE \'UTC\'\) '
r'AND "startTime" <= \(\$(\d+)::timestamptz AT TIME ZONE \'UTC\'\) '
r'AND \(api_key = \$\1 OR team_id = \$\1 OR "user" = \$\1 OR end_user = \$\1 '
r'AND \(litellm_call_id = \$\1 OR api_key = \$\1 OR team_id = \$\1 OR "user" = \$\1 OR end_user = \$\1 '
r"OR session_id = \$\1 OR model_id = \$\1\)\)\)"
)
@ -68,7 +68,7 @@ def _matches_spend_log_search(log, search):
return True
if not _filter_logs_by_date_range([log], {"startTime": {"gte": search["gte"], "lte": search["lte"]}}):
return False
columns = ("api_key", "team_id", "user", "end_user", "session_id", "model_id")
columns = ("litellm_call_id", "api_key", "team_id", "user", "end_user", "session_id", "model_id")
return any(log.get(col) == search["value"] for col in columns)
@ -2986,7 +2986,7 @@ def test_build_spend_log_search_condition_windows_every_branch_except_request_id
assert condition.sql == (
"(request_id = $3 OR (\"startTime\" >= ($4::timestamptz AT TIME ZONE 'UTC') "
"AND \"startTime\" <= ($5::timestamptz AT TIME ZONE 'UTC') "
'AND (api_key = $3 OR team_id = $3 OR "user" = $3 OR end_user = $3 OR session_id = $3 OR model_id = $3)))'
'AND (litellm_call_id = $3 OR api_key = $3 OR team_id = $3 OR "user" = $3 OR end_user = $3 OR session_id = $3 OR model_id = $3)))'
)
assert condition.params == ("key-hash-7", start, end)
@ -3012,6 +3012,8 @@ def _search_fixture_logs(today):
{**base, "request_id": "req-user", "user": "user-7", "startTime": recent},
{**base, "request_id": "req-end-user", "end_user": "cust-7", "startTime": recent},
{**base, "request_id": "req-model", "model_id": "mdl-7", "startTime": recent},
{**base, "request_id": "chatcmpl-x", "litellm_call_id": "call-recent", "startTime": recent},
{**base, "request_id": "chatcmpl-old", "litellm_call_id": "call-old", "startTime": old},
]
@ -3046,6 +3048,8 @@ def _five_day_window(today):
("user-7", {"req-user"}),
("cust-7", {"req-end-user"}),
("mdl-7", {"req-model"}),
("call-recent", {"chatcmpl-x"}),
("call-old", set()),
("no-such-id", set()),
],
)

View file

@ -58,6 +58,23 @@ describe("DrawerHeader sidebar toggle", () => {
expect(within(row).getByText("gpt-4o")).toBeInTheDocument();
});
it("shows the x-litellm-call-id with its own copy button when it differs from the request id", () => {
renderHeader(logEntry({ request_id: "chatcmpl-h", litellm_call_id: "call-h" }), false);
expect(screen.getByText("chatcmpl-h")).toBeInTheDocument();
expect(screen.getByText("call-h")).toBeInTheDocument();
expect(screen.getByText("x-litellm-call-id:")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Copy Request ID" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Copy x-litellm-call-id" })).toBeInTheDocument();
});
it("omits the x-litellm-call-id line and button when the ids match", () => {
renderHeader(logEntry({ request_id: "same-h", litellm_call_id: "same-h" }), false);
expect(screen.queryByText("x-litellm-call-id:")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Copy x-litellm-call-id" })).not.toBeInTheDocument();
});
it("falls back to the request id row when the log names no model", () => {
renderHeader(logEntry({ model: "", custom_llm_provider: "" }), true);

View file

@ -50,6 +50,8 @@ export function DrawerHeader({
const providerInfo = provider ? getProviderLogoAndName(provider) : null;
const showToggleWithProvider = isSidebarCollapsed && Boolean(providerInfo || log.model);
const showToggleWithRequestId = isSidebarCollapsed && !showToggleWithProvider;
const callId: string | null =
log.litellm_call_id && log.litellm_call_id !== log.request_id ? log.litellm_call_id : null;
return (
<div
@ -85,7 +87,20 @@ export function DrawerHeader({
}}
>
{showToggleWithRequestId && <SidebarToggle isCollapsed onToggle={onToggleSidebar} />}
<RequestIdSection requestId={log.request_id} />
<div style={{ flex: 1, minWidth: 0 }}>
<CopyableId value={log.request_id} label="Request ID" fontSize={FONT_SIZE_HEADER} />
{callId && (
<div className="flex items-center gap-1">
<span
className="text-muted-foreground"
style={{ fontSize: FONT_SIZE_MEDIUM, fontFamily: FONT_FAMILY_MONO, whiteSpace: "nowrap" }}
>
x-litellm-call-id:
</span>
<CopyableId value={callId} label="x-litellm-call-id" fontSize={FONT_SIZE_MEDIUM} muted />
</div>
)}
</div>
<NavigationSection onPrevious={onPrevious} onNext={onNext} onClose={onClose} />
</div>
@ -140,15 +155,22 @@ function ModelProviderSection({
);
}
/**
* Request ID display with copy functionality
*/
function RequestIdSection({ requestId }: { requestId: string }) {
function CopyableId({
value,
label,
fontSize,
muted,
}: {
value: string;
label: string;
fontSize: number;
muted?: boolean;
}) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(requestId);
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 1200);
} catch {
@ -157,38 +179,36 @@ function RequestIdSection({ requestId }: { requestId: string }) {
};
return (
<div style={{ flex: 1, minWidth: 0 }}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<span
className="font-semibold"
style={{
fontSize: FONT_SIZE_HEADER,
fontFamily: FONT_FAMILY_MONO,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
display: "block",
}}
/>
}
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<span
className={muted ? "text-muted-foreground" : "font-semibold"}
style={{
fontSize,
fontFamily: FONT_FAMILY_MONO,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
display: "block",
}}
/>
}
>
{value}
<button
type="button"
aria-label={copied ? "Copied!" : `Copy ${label}`}
onClick={handleCopy}
className="ml-1 align-middle text-muted-foreground hover:text-foreground"
>
{requestId}
<button
type="button"
aria-label={copied ? "Copied!" : "Copy Request ID"}
onClick={handleCopy}
className="ml-1 align-middle text-muted-foreground hover:text-foreground"
>
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
</button>
</TooltipTrigger>
<TooltipContent>{requestId}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
</button>
</TooltipTrigger>
<TooltipContent>{value}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}

View file

@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
@ -7,6 +7,13 @@ import { DataTable } from "@/components/shared/DataTable";
import type { LogEntry } from "./columns";
import { getRequestLogsTableColumns } from "./RequestLogsTableColumns";
const { copyToClipboardMock } = vi.hoisted(() => ({ copyToClipboardMock: vi.fn() }));
vi.mock("@/utils/dataUtils", async (importOriginal) => ({
...(await importOriginal<typeof import("@/utils/dataUtils")>()),
copyToClipboard: copyToClipboardMock,
}));
const logEntry = (overrides: Partial<LogEntry>): LogEntry => ({
request_id: "req-1",
api_key: "key-1",
@ -267,13 +274,64 @@ describe("batch rows", () => {
});
it("leaves ordinary request ids untouched", () => {
renderRows([logEntry({ request_id: "chatcmpl-42" })]);
renderRows([logEntry({ request_id: "chatcmpl-42", litellm_call_id: "chatcmpl-42" })]);
expect(screen.getByText("chatcmpl-42")).toBeInTheDocument();
expect(screen.queryByText("batch cost")).not.toBeInTheDocument();
});
});
describe("Request ID column", () => {
it("shows only the request id in the cell and the x-litellm-call-id in its tooltip when they differ", async () => {
const user = userEvent.setup();
renderRows([logEntry({ request_id: "chatcmpl-9", litellm_call_id: "call-uuid-9" })]);
expect(screen.getByText("chatcmpl-9")).toBeInTheDocument();
expect(screen.queryByText("call-uuid-9")).not.toBeInTheDocument();
await user.hover(screen.getByText("chatcmpl-9"));
expect(await screen.findByText("x-litellm-call-id: call-uuid-9")).toBeInTheDocument();
});
it("copies the x-litellm-call-id from the tooltip without opening the row", async () => {
const user = userEvent.setup();
const onRowClick = vi.fn();
render(
<DataTable
data={[logEntry({ request_id: "chatcmpl-9", litellm_call_id: "call-uuid-9" })]}
columns={getRequestLogsTableColumns(noopDeps)}
getRowId={(row) => row.request_id}
size="compact"
onRowClick={onRowClick}
/>,
);
await user.hover(screen.getByText("chatcmpl-9"));
fireEvent.click(await screen.findByRole("button", { name: "Copy x-litellm-call-id" }));
expect(copyToClipboardMock).toHaveBeenCalledWith("call-uuid-9");
expect(onRowClick).not.toHaveBeenCalled();
});
it("keeps the plain id tooltip when request id and call id are the same", async () => {
const user = userEvent.setup();
renderRows([logEntry({ request_id: "same-id-7", litellm_call_id: "same-id-7" })]);
await user.hover(screen.getByText("same-id-7"));
await waitFor(() => expect(screen.getAllByText("same-id-7")).toHaveLength(2));
expect(screen.queryByText(/x-litellm-call-id/)).not.toBeInTheDocument();
});
it("keeps the plain id tooltip when the row carries no call id", async () => {
const user = userEvent.setup();
renderRows([logEntry({ request_id: "chatcmpl-no-call", litellm_call_id: null })]);
await user.hover(screen.getByText("chatcmpl-no-call"));
await waitFor(() => expect(screen.getAllByText("chatcmpl-no-call")).toHaveLength(2));
expect(screen.queryByText(/x-litellm-call-id/)).not.toBeInTheDocument();
});
});
describe("Model column", () => {
it("lists every model used across a conversation, not only the representative call's model", () => {
const conversationCall: Partial<LogEntry> = {

View file

@ -1,10 +1,11 @@
"use client";
import type { ColumnDef } from "@tanstack/react-table";
import { Copy } from "lucide-react";
import { DataTableSortHeader } from "@/components/shared/DataTable";
import { CellTooltip, DateCell, IdCell, MoneyCell, StatusBadge } from "@/components/shared/table_cells";
import { getSpendString } from "@/utils/dataUtils";
import { copyToClipboard, getSpendString } from "@/utils/dataUtils";
import { getProviderLogoAndName } from "../provider_info_helpers";
import { getBatchIdFromRequestId, getBatchRequestCounts, isBatchCallType } from "./batchLogUtils";
@ -30,6 +31,28 @@ const readMcpLogoUrl = (metadata: Record<string, unknown> | undefined): string |
return typeof url === "string" && url !== "" ? url : undefined;
};
function RequestIdWithCallIdTooltip({ requestId, callId }: { requestId: string; callId: string }) {
return (
<span className="flex flex-col gap-0.5">
<span>{requestId}</span>
<span className="inline-flex items-center gap-1">
<span>x-litellm-call-id: {callId}</span>
<button
type="button"
aria-label="Copy x-litellm-call-id"
className="shrink-0 cursor-pointer opacity-70 hover:opacity-100"
onClick={(event) => {
event.stopPropagation();
void copyToClipboard(callId);
}}
>
<Copy className="size-3" />
</button>
</span>
</span>
);
}
const getLogoUrl = (row: LogEntry, provider: string): string =>
readMcpLogoUrl(row.metadata) ?? (provider ? getProviderLogoAndName(provider).logo : "");
@ -160,7 +183,14 @@ export const getRequestLogsTableColumns = ({
</div>
);
}
return <IdCell value={log.request_id} variant="plain" />;
const callId = log.litellm_call_id && log.litellm_call_id !== log.request_id ? log.litellm_call_id : null;
return (
<IdCell
value={log.request_id}
variant="plain"
tooltip={callId ? <RequestIdWithCallIdTooltip requestId={log.request_id} callId={callId} /> : undefined}
/>
);
},
},
{

View file

@ -66115,7 +66115,7 @@ export interface operations {
group_by_session?: boolean;
/** @description Keyset cursor '<last_activity>|<api_key>|<session_key>' from a previous group_by_session page. UI route only, honored when sorting by startTime */
session_cursor?: string | null;
/** @description Match a log whose request_id, api_key (hash), team_id, user, end_user, session_id, or model_id equals this value. request_id matches across all time; the other columns match inside start_date/end_date, which stay required */
/** @description Match a log whose request_id, litellm_call_id, api_key (hash), team_id, user, end_user, session_id, or model_id equals this value. request_id matches across all time; the other columns match inside start_date/end_date, which stay required */
search?: string | null;
};
header?: never;
@ -66235,7 +66235,7 @@ export interface operations {
group_by_session?: boolean;
/** @description Keyset cursor '<last_activity>|<api_key>|<session_key>' from a previous group_by_session page. UI route only, honored when sorting by startTime */
session_cursor?: string | null;
/** @description Match a log whose request_id, api_key (hash), team_id, user, end_user, session_id, or model_id equals this value. request_id matches across all time; the other columns match inside start_date/end_date, which stay required */
/** @description Match a log whose request_id, litellm_call_id, api_key (hash), team_id, user, end_user, session_id, or model_id equals this value. request_id matches across all time; the other columns match inside start_date/end_date, which stay required */
search?: string | null;
};
header?: never;