From cbeef3b98c88b6c09fd2e1b88396f5baf68977fd Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:45:55 -0700 Subject: [PATCH] fix(proxy): bound client x-litellm-call-id, open log deep links by call id, prefer exact request_id rows --- litellm/constants.py | 1 + litellm/proxy/common_request_processing.py | 9 +- .../test_gcs_pub_sub.py | 1 + .../proxy/test_common_request_processing.py | 16 ++- .../GuardrailsMonitor/LogViewer.test.tsx | 97 +++++++++++++++++++ .../GuardrailsMonitor/LogViewer.tsx | 3 +- .../view_logs/RequestLogsPanel.test.tsx | 30 ++++++ .../components/view_logs/RequestLogsPanel.tsx | 9 +- .../src/components/view_logs/columns.tsx | 1 + 9 files changed, 160 insertions(+), 7 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/GuardrailsMonitor/LogViewer.test.tsx diff --git a/litellm/constants.py b/litellm/constants.py index 1bd977dd9a9..8fbe0eeb4f9 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -104,6 +104,7 @@ DEFAULT_MCP_SEMANTIC_FILTER_SIMILARITY_THRESHOLD: Final = float( os.getenv("DEFAULT_MCP_SEMANTIC_FILTER_SIMILARITY_THRESHOLD", 0.3) ) MAX_MCP_SEMANTIC_FILTER_TOOLS_HEADER_LENGTH: Final = int(os.getenv("MAX_MCP_SEMANTIC_FILTER_TOOLS_HEADER_LENGTH", 150)) +MAX_LITELLM_CALL_ID_LENGTH: Final = 256 DEFAULT_AUTO_ROUTER_MAX_INPUT_CHARS: Final = 2000 diff --git a/litellm/proxy/common_request_processing.py b/litellm/proxy/common_request_processing.py index 05ddef822f1..97c6654fa5e 100644 --- a/litellm/proxy/common_request_processing.py +++ b/litellm/proxy/common_request_processing.py @@ -25,6 +25,7 @@ from litellm.constants import ( DEFAULT_MAX_RECURSE_DEPTH, LITELLM_DETAILED_TIMING, LITELLM_HTTP_STATUS_CLIENT_DISCONNECTED, + MAX_LITELLM_CALL_ID_LENGTH, MAX_PAYLOAD_SIZE_FOR_DEBUG_LOG, NON_INFERENCE_CALL_TYPES, RETURN_RAW_MODEL_NAME_METADATA_KEY, @@ -210,6 +211,12 @@ def _withheld_provider_output(response: object) -> bool: return getattr(response, "has_buffered_provider_output", False) is True +def resolve_litellm_call_id(client_call_id: str | None) -> str: + if client_call_id is not None and 0 < len(client_call_id) <= MAX_LITELLM_CALL_ID_LENGTH: + return client_call_id + return str(uuid.uuid4()) + + def _should_return_raw_model_name(request_data: dict[str, object]) -> bool: return any( isinstance(metadata, dict) and metadata.get(RETURN_RAW_MODEL_NAME_METADATA_KEY) is True @@ -1923,7 +1930,7 @@ class ProxyBaseLLMRequestProcessing: if alias_target is not None: self.data["model"] = alias_target - self.data["litellm_call_id"] = request.headers.get("x-litellm-call-id", str(uuid.uuid4())) + self.data["litellm_call_id"] = resolve_litellm_call_id(request.headers.get("x-litellm-call-id")) DDSpanTagger.tag_call_id(self.data.get("litellm_call_id")) DDSpanTagger.tag_request( user_api_key_dict=user_api_key_dict, diff --git a/tests/logging_callback_tests/test_gcs_pub_sub.py b/tests/logging_callback_tests/test_gcs_pub_sub.py index 10957fa2f92..1f1ca8960f6 100644 --- a/tests/logging_callback_tests/test_gcs_pub_sub.py +++ b/tests/logging_callback_tests/test_gcs_pub_sub.py @@ -28,6 +28,7 @@ verbose_logger.setLevel(logging.DEBUG) ignored_keys = [ "request_id", + "litellm_call_id", "metadata.litellm_call_id", "session_id", "startTime", diff --git a/tests/test_litellm/proxy/test_common_request_processing.py b/tests/test_litellm/proxy/test_common_request_processing.py index df14224af5c..83fee4e3f9d 100644 --- a/tests/test_litellm/proxy/test_common_request_processing.py +++ b/tests/test_litellm/proxy/test_common_request_processing.py @@ -13,7 +13,7 @@ from fastapi.responses import JSONResponse, StreamingResponse import litellm from litellm._uuid import uuid -from litellm.constants import RETURN_RAW_MODEL_NAME_METADATA_KEY +from litellm.constants import MAX_LITELLM_CALL_ID_LENGTH, RETURN_RAW_MODEL_NAME_METADATA_KEY from litellm.integrations.custom_logger import CustomLogger from litellm.integrations.opentelemetry import UserAPIKeyAuth from litellm.proxy.common_request_processing import ( @@ -30,6 +30,7 @@ from litellm.proxy.common_request_processing import ( _has_attribute_error_in_chain, _is_azure_model_router_request, open_sse_before_first_byte, + resolve_litellm_call_id, ttft_keepalive_interval, _override_openai_response_model, _parse_event_data_for_error, @@ -7665,3 +7666,16 @@ def test_log_llm_api_exception_traceback_only_for_unexpected_errors(exc, expect_ records = [r for r in caplog.records if "_handle_llm_api_exception(): Exception occured" in r.getMessage()] assert len(records) == 1 assert (records[0].exc_info is not None) is expect_traceback + + +class TestResolveLitellmCallId: + def test_client_call_id_within_the_bound_is_kept(self): + assert resolve_litellm_call_id("req-abc-123") == "req-abc-123" + at_bound: Final = "y" * MAX_LITELLM_CALL_ID_LENGTH + assert resolve_litellm_call_id(at_bound) == at_bound + + @pytest.mark.parametrize("client_call_id", [None, "", "x" * (MAX_LITELLM_CALL_ID_LENGTH + 1), "z" * 3000]) + def test_missing_empty_or_oversized_client_call_id_gets_a_generated_uuid(self, client_call_id): + resolved: Final = resolve_litellm_call_id(client_call_id) + assert resolved != client_call_id + assert uuid.UUID(resolved).version == 4 diff --git a/ui/litellm-dashboard/src/components/GuardrailsMonitor/LogViewer.test.tsx b/ui/litellm-dashboard/src/components/GuardrailsMonitor/LogViewer.test.tsx new file mode 100644 index 00000000000..ab91e10c2fd --- /dev/null +++ b/ui/litellm-dashboard/src/components/GuardrailsMonitor/LogViewer.test.tsx @@ -0,0 +1,97 @@ +import userEvent from "@testing-library/user-event"; +import React from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { renderWithProviders, screen, testQueryClient, waitFor } from "../../../tests/test-utils"; +import type { LogEntry as SpendLogEntry } from "@/components/view_logs/columns"; +import { LogViewer } from "./LogViewer"; + +vi.mock("@/components/networking", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, uiSpendLogsCall: vi.fn() }; +}); + +vi.mock("@/components/view_logs/LogDetailsDrawer", () => ({ + LogDetailsDrawer: function LogDetailsDrawerMock({ + open, + logEntry, + }: { + open: boolean; + logEntry?: { request_id: string } | null; + }) { + return ( +
+ {open ? "open" : "closed"} +
+ ); + }, +})); + +import { uiSpendLogsCall } from "@/components/networking"; + +const spendLog = (overrides: Partial): SpendLogEntry => ({ + request_id: "req-1", + api_key: "key-1", + team_id: "team-1", + model: "gpt-4o", + model_id: "model-1", + call_type: "acompletion", + spend: 0.01, + total_tokens: 10, + prompt_tokens: 5, + completion_tokens: 5, + startTime: "2026-09-02T09:50:13Z", + endTime: "2026-09-02T09:50:14Z", + cache_hit: "false", + messages: [], + response: {}, + ...overrides, +}); + +const guardrailLog = { + id: "provider-victim", + timestamp: "2026-09-02 09:50:13", + action: "passed" as const, + input_snippet: "victim prompt", +}; + +describe("GuardrailsMonitor LogViewer drawer", () => { + beforeEach(() => { + vi.mocked(uiSpendLogsCall).mockReset(); + testQueryClient.clear(); + }); + + it("opens the row whose request_id is the clicked log id even when a newer row carries that id as its call id", async () => { + vi.mocked(uiSpendLogsCall).mockResolvedValue({ + data: [ + spendLog({ request_id: "provider-attacker", litellm_call_id: "provider-victim" }), + spendLog({ request_id: "provider-victim", litellm_call_id: "call-victim" }), + ], + total: 2, + }); + + renderWithProviders(); + await userEvent.click(screen.getByText("victim prompt")); + + await waitFor(() => { + expect(screen.getByTestId("log-details-drawer")).toHaveAttribute("data-log-id", "provider-victim"); + }); + expect(vi.mocked(uiSpendLogsCall)).toHaveBeenCalledWith( + expect.objectContaining({ params: { request_id: "provider-victim" } }), + ); + }); + + it("falls back to the first returned row when none carries the clicked id as its request_id", async () => { + vi.mocked(uiSpendLogsCall).mockResolvedValue({ + data: [spendLog({ request_id: "provider-other", litellm_call_id: "provider-victim" })], + total: 1, + }); + + renderWithProviders(); + await userEvent.click(screen.getByText("victim prompt")); + + await waitFor(() => { + expect(screen.getByTestId("log-details-drawer")).toHaveAttribute("data-log-id", "provider-other"); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/GuardrailsMonitor/LogViewer.tsx b/ui/litellm-dashboard/src/components/GuardrailsMonitor/LogViewer.tsx index 8d073feae82..0703c94c2ed 100644 --- a/ui/litellm-dashboard/src/components/GuardrailsMonitor/LogViewer.tsx +++ b/ui/litellm-dashboard/src/components/GuardrailsMonitor/LogViewer.tsx @@ -92,7 +92,8 @@ export function LogViewer({ enabled: Boolean(accessToken && selectedRequestId && drawerOpen), }); - const selectedLog: ViewLogsLogEntry | null = fullLogResponse?.data?.[0] ?? null; + const selectedLog: ViewLogsLogEntry | null = + fullLogResponse?.data?.find((log) => log.request_id === selectedRequestId) ?? fullLogResponse?.data?.[0] ?? null; const handleLogClick = (log: LogEntry) => { setSelectedRequestId(log.id); diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.test.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.test.tsx index 22e3f635b50..4f788e180d2 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.test.tsx @@ -298,6 +298,36 @@ describe("RequestLogsPanel", () => { expect(byIdCall.page_size).toBe(1); }); + it("opens the drawer when ?log_id= is the log's litellm_call_id rather than its request_id", async () => { + respondWith([logEntry({ request_id: "chatcmpl-provider", litellm_call_id: "call-1" })]); + renderPanel("?log_id=call-1"); + + await waitFor(() => { + expect(drawer()).toHaveTextContent("open"); + }); + expect(drawer()).toHaveAttribute("data-log-id", "chatcmpl-provider"); + }); + + it("fetches by litellm_call_id and opens the drawer when that log is not in the loaded page", async () => { + vi.mocked(uiSpendLogsCall).mockImplementation(async ({ params }) => + params?.request_id === "call-old" + ? { + data: [logEntry({ request_id: "chatcmpl-old", litellm_call_id: "call-old" })], + total: 1, + page: 1, + page_size: 1, + total_pages: 1, + } + : { data: [], total: 0, page: 1, page_size: 50, total_pages: 0 }, + ); + renderPanel("?log_id=call-old"); + + await waitFor(() => { + expect(drawer()).toHaveTextContent("open"); + }); + expect(drawer()).toHaveAttribute("data-log-id", "chatcmpl-old"); + }); + it("closing the drawer removes ?log_id= from the URL and closes the drawer", async () => { const user = userEvent.setup(); respondWith([logEntry({ request_id: "req-1" })]); diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx index 52ea78abf5e..cf73b695044 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx @@ -26,6 +26,7 @@ import { RequestLogsTable } from "./RequestLogsTable"; const PAGE_SIZE = 50; const DEFAULT_INTERVAL = { value: 24, unit: "hours" }; +const matchesLogId = (log: LogEntry, logId: string) => log.request_id === logId || log.litellm_call_id === logId; interface RequestLogsPanelProps { accessToken: string; @@ -133,9 +134,9 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID, page_size: 1, params: { request_id: urlLogId }, }); - return response.data.find((log) => log.request_id === urlLogId) ?? null; + return response.data.find((log) => matchesLogId(log, urlLogId)) ?? null; }, - enabled: urlLogId !== null && selectedLog?.request_id !== urlLogId, + enabled: urlLogId !== null && !(selectedLog !== null && matchesLogId(selectedLog, urlLogId)), staleTime: Infinity, }; @@ -143,8 +144,8 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID, const displayLog = useMemo(() => { if (urlLogId === null) return null; - if (selectedLog?.request_id === urlLogId) return selectedLog; - return filteredLogs.data.find((log) => log.request_id === urlLogId) ?? urlLog ?? null; + if (selectedLog !== null && matchesLogId(selectedLog, urlLogId)) return selectedLog; + return filteredLogs.data.find((log) => matchesLogId(log, urlLogId)) ?? urlLog ?? null; }, [urlLogId, selectedLog, filteredLogs.data, urlLog]); const displaySessionId = useMemo(() => { diff --git a/ui/litellm-dashboard/src/components/view_logs/columns.tsx b/ui/litellm-dashboard/src/components/view_logs/columns.tsx index eef957922d7..520d378db2a 100644 --- a/ui/litellm-dashboard/src/components/view_logs/columns.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/columns.tsx @@ -12,6 +12,7 @@ export type LogsSortField = keyof typeof LOGS_SORT_FIELD_MAP; export type LogEntry = { request_id: string; + litellm_call_id?: string | null; api_key: string; team_id: string; model: string;