From 8afabe81f17a778bbb006413e6039144cb938dbc Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:16:56 +0000 Subject: [PATCH] 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 --- .../spend_management_endpoints.py | 4 +- tests/e2e/ui/helpers/traffic.ts | 17 +++- tests/e2e/ui/tests/logs/logs.spec.ts | 45 +++++++++ .../test_spend_management_endpoints.py | 10 +- .../LogDetailsDrawer/DrawerHeader.test.tsx | 17 ++++ .../LogDetailsDrawer/DrawerHeader.tsx | 94 +++++++++++-------- .../RequestLogsTableColumns.test.tsx | 62 +++++++++++- .../view_logs/RequestLogsTableColumns.tsx | 34 ++++++- ui/litellm-dashboard/src/lib/http/schema.d.ts | 4 +- 9 files changed, 238 insertions(+), 49 deletions(-) diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py index c4ed8713f95..728579db5fc 100644 --- a/litellm/proxy/spend_tracking/spend_management_endpoints.py +++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py @@ -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" ), diff --git a/tests/e2e/ui/helpers/traffic.ts b/tests/e2e/ui/helpers/traffic.ts index cb68747b364..b534c475221 100644 --- a/tests/e2e/ui/helpers/traffic.ts +++ b/tests/e2e/ui/helpers/traffic.ts @@ -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 { + 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; } diff --git a/tests/e2e/ui/tests/logs/logs.spec.ts b/tests/e2e/ui/tests/logs/logs.spec.ts index 2748c91395f..3908d79b29a 100644 --- a/tests/e2e/ui/tests/logs/logs.spec.ts +++ b/tests/e2e/ui/tests/logs/logs.spec.ts @@ -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 }) => { diff --git a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py index 7db5d7ca9a3..3b265653b12 100644 --- a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py +++ b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py @@ -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()), ], ) diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/DrawerHeader.test.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/DrawerHeader.test.tsx index a8e27504019..ef320f04f58 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/DrawerHeader.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/DrawerHeader.test.tsx @@ -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); diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/DrawerHeader.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/DrawerHeader.tsx index 65b5801602c..a4afdb68fae 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/DrawerHeader.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/DrawerHeader.tsx @@ -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 (
{showToggleWithRequestId && } - +
+ + {callId && ( +
+ + x-litellm-call-id: + + +
+ )} +
@@ -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 ( -
- - - - } + + + + } + > + {value} + - - {requestId} - - -
+ {copied ? : } + + + {value} + + ); } diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsTableColumns.test.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsTableColumns.test.tsx index 4d451a9c7b9..68590b6de2d 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsTableColumns.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsTableColumns.test.tsx @@ -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()), + copyToClipboard: copyToClipboardMock, +})); + const logEntry = (overrides: Partial): 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( + 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 = { diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsTableColumns.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsTableColumns.tsx index 5705f41f3de..df7f55d7d76 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsTableColumns.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsTableColumns.tsx @@ -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 | undefined): string | return typeof url === "string" && url !== "" ? url : undefined; }; +function RequestIdWithCallIdTooltip({ requestId, callId }: { requestId: string; callId: string }) { + return ( + + {requestId} + + x-litellm-call-id: {callId} + + + + ); +} + const getLogoUrl = (row: LogEntry, provider: string): string => readMcpLogoUrl(row.metadata) ?? (provider ? getProviderLogoAndName(provider).logo : ""); @@ -160,7 +183,14 @@ export const getRequestLogsTableColumns = ({ ); } - return ; + const callId = log.litellm_call_id && log.litellm_call_id !== log.request_id ? log.litellm_call_id : null; + return ( + : undefined} + /> + ); }, }, { diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 8e11a9234e1..a2b35547a47 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -66115,7 +66115,7 @@ export interface operations { group_by_session?: boolean; /** @description Keyset cursor '||' 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 '||' 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;