mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
fix(proxy): bound client x-litellm-call-id, open log deep links by call id, prefer exact request_id rows
This commit is contained in:
parent
808fac0d7e
commit
cbeef3b98c
9 changed files with 160 additions and 7 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ verbose_logger.setLevel(logging.DEBUG)
|
|||
|
||||
ignored_keys = [
|
||||
"request_id",
|
||||
"litellm_call_id",
|
||||
"metadata.litellm_call_id",
|
||||
"session_id",
|
||||
"startTime",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<typeof import("@/components/networking")>();
|
||||
return { ...actual, uiSpendLogsCall: vi.fn() };
|
||||
});
|
||||
|
||||
vi.mock("@/components/view_logs/LogDetailsDrawer", () => ({
|
||||
LogDetailsDrawer: function LogDetailsDrawerMock({
|
||||
open,
|
||||
logEntry,
|
||||
}: {
|
||||
open: boolean;
|
||||
logEntry?: { request_id: string } | null;
|
||||
}) {
|
||||
return (
|
||||
<div data-testid="log-details-drawer" data-log-id={logEntry?.request_id ?? ""}>
|
||||
{open ? "open" : "closed"}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
import { uiSpendLogsCall } from "@/components/networking";
|
||||
|
||||
const spendLog = (overrides: Partial<SpendLogEntry>): 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(<LogViewer logs={[guardrailLog]} accessToken="sk-test" />);
|
||||
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(<LogViewer logs={[guardrailLog]} accessToken="sk-test" />);
|
||||
await userEvent.click(screen.getByText("victim prompt"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("log-details-drawer")).toHaveAttribute("data-log-id", "provider-other");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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" })]);
|
||||
|
|
|
|||
|
|
@ -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<LogEntry | null>(() => {
|
||||
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<string | null>(() => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue