diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py
index 58cd8c99e7..fa28b6070b 100644
--- a/litellm/proxy/_experimental/mcp_server/server.py
+++ b/litellm/proxy/_experimental/mcp_server/server.py
@@ -41,6 +41,9 @@ from litellm.proxy._experimental.mcp_server.utils import (
)
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.auth.ip_address_utils import IPAddressUtils
+from litellm.proxy.litellm_pre_call_utils import (
+ LiteLLMProxyRequestSetup,
+)
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPInfo, MCPServer
from litellm.types.utils import CallTypes, StandardLoggingMCPToolCall
@@ -842,6 +845,7 @@ if MCP_AVAILABLE:
raw_headers: Optional[Dict[str, str]] = None,
log_list_tools_to_spendlogs: bool = False,
list_tools_log_source: Optional[str] = None,
+ litellm_trace_id: Optional[str] = None,
) -> List[MCPTool]:
"""
Helper method to fetch tools from MCP servers based on server filtering criteria.
@@ -879,6 +883,7 @@ if MCP_AVAILABLE:
"model": "MCP: list_tools",
"call_type": CallTypes.list_mcp_tools.value,
"litellm_call_id": list_tools_call_id,
+ "litellm_trace_id": litellm_trace_id,
"metadata": {
"spend_logs_metadata": spend_logs_metadata,
},
@@ -894,13 +899,14 @@ if MCP_AVAILABLE:
],
}
- # Attach user identifiers when available (matches call_mcp_tool style)
+ # Attach user identifiers using the standard helper
if user_api_key_auth is not None:
- user_api_key = getattr(user_api_key_auth, "api_key", None)
- if user_api_key:
- cast(dict, list_tools_request_data["metadata"])[
- "user_api_key"
- ] = user_api_key
+
+ LiteLLMProxyRequestSetup.add_user_api_key_auth_to_request_metadata(
+ data=list_tools_request_data,
+ user_api_key_dict=user_api_key_auth,
+ _metadata_variable_name="metadata",
+ )
user_identifier = getattr(
user_api_key_auth, "end_user_id", None
diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py
index afbc57360e..ed8c9a76f6 100644
--- a/litellm/proxy/spend_tracking/spend_management_endpoints.py
+++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py
@@ -1878,13 +1878,15 @@ async def ui_view_spend_logs( # noqa: PLR0915
verbose_proxy_logger.debug("data= %s", json.dumps(data, indent=4, default=str))
- return {
- "data": data,
- "total": total_records,
- "page": page,
- "page_size": page_size,
- "total_pages": total_pages,
- }
+ return await _build_ui_spend_logs_response(
+ prisma_client,
+ data,
+ total_records,
+ page,
+ page_size,
+ total_pages,
+ enrich_session_counts=not is_v2,
+ )
except Exception as e:
verbose_proxy_logger.exception(f"Error in ui_view_spend_logs: {e}")
raise handle_exception_on_proxy(e)
@@ -3129,6 +3131,87 @@ async def ui_view_session_spend_logs(
)
+async def _build_ui_spend_logs_response(
+ prisma_client: "PrismaClient",
+ data: list,
+ total_records: int,
+ page: int,
+ page_size: int,
+ total_pages: int,
+ enrich_session_counts: bool = True,
+) -> dict:
+ """
+ Build the paginated response for the UI spend-logs endpoint.
+
+ When ``enrich_session_counts`` is ``True`` (the default for the v1/UI
+ endpoint), each row is enriched with ``session_total_count`` so the
+ frontend knows which sessions are expandable (multi-call sessions).
+ For every row that carries a ``session_id``, a single ``GROUP BY`` query
+ fetches the total number of logs in each referenced session. Rows without
+ a ``session_id`` default to ``1``.
+
+ When ``enrich_session_counts`` is ``False`` (v2 endpoint), rows are
+ serialised without the extra query.
+
+ Args:
+ prisma_client: The connected Prisma client instance.
+ data: A list of Prisma model instances (must support ``.model_dump()``
+ and have a ``session_id`` attribute).
+ total_records: Total number of matching records (for pagination).
+ page: Current page number.
+ page_size: Number of items per page.
+ total_pages: Total number of pages.
+ enrich_session_counts: Whether to add ``session_total_count`` to each
+ row. Defaults to ``True``.
+
+ Returns:
+ A dict with ``data`` (enriched rows), ``total``, ``page``,
+ ``page_size``, and ``total_pages``.
+ """
+ count_map: dict[str, int] = {}
+ if enrich_session_counts:
+ session_ids = list(
+ {row.session_id for row in data if getattr(row, "session_id", None)}
+ )
+ if session_ids:
+ # NOTE: This GROUP BY runs on every v1/UI page load. The IN clause
+ # is bounded by page_size (typically 25-50 distinct session IDs).
+ # If performance degrades at scale, consider short-lived caching or
+ # folding the count into the main query via a window function.
+ counts = await prisma_client.db.litellm_spendlogs.group_by(
+ by=["session_id"],
+ where={"session_id": {"in": session_ids}},
+ count={"session_id": True},
+ )
+ count_map = {
+ r["session_id"]: r["_count"]["session_id"]
+ for r in counts
+ if r.get("session_id")
+ }
+
+ if enrich_session_counts:
+ enriched: List[dict] = []
+ for row in data:
+ row_dict = row.model_dump()
+ sid = row_dict.get("session_id")
+ row_dict["session_total_count"] = count_map.get(sid, 1) if sid else 1
+ enriched.append(row_dict)
+ response_data: list = enriched
+ else:
+ # v2 path: return raw Prisma model instances so FastAPI applies its
+ # own Pydantic-aware serialisation (preserves alias handling, custom
+ # serializers, etc.).
+ response_data = data # type: ignore[assignment]
+
+ return {
+ "data": response_data,
+ "total": total_records,
+ "page": page,
+ "page_size": page_size,
+ "total_pages": total_pages,
+ }
+
+
def _build_status_filter_condition(status_filter: Optional[str]) -> Dict[str, Any]:
"""
Helper function to build the status filter condition for database queries.
diff --git a/litellm/responses/main.py b/litellm/responses/main.py
index 8f524690be..7105f1ae6f 100644
--- a/litellm/responses/main.py
+++ b/litellm/responses/main.py
@@ -181,6 +181,7 @@ async def aresponses_api_with_mcp(
) = await LiteLLM_Proxy_MCP_Handler._process_mcp_tools_without_openai_transform(
user_api_key_auth=user_api_key_auth,
mcp_tools_with_litellm_proxy=mcp_tools_with_litellm_proxy,
+ litellm_trace_id=kwargs.get("litellm_trace_id"),
)
openai_tools = LiteLLM_Proxy_MCP_Handler._transform_mcp_tools_to_openai(
original_mcp_tools
diff --git a/litellm/responses/mcp/chat_completions_handler.py b/litellm/responses/mcp/chat_completions_handler.py
index 4a640a6106..377ce39645 100644
--- a/litellm/responses/mcp/chat_completions_handler.py
+++ b/litellm/responses/mcp/chat_completions_handler.py
@@ -127,6 +127,7 @@ async def acompletion_with_mcp( # noqa: PLR0915
) = await LiteLLM_Proxy_MCP_Handler._process_mcp_tools_without_openai_transform(
user_api_key_auth=user_api_key_auth,
mcp_tools_with_litellm_proxy=mcp_tools_with_litellm_proxy,
+ litellm_trace_id=kwargs.get("litellm_trace_id"),
)
openai_tools = LiteLLM_Proxy_MCP_Handler._transform_mcp_tools_to_openai(
@@ -235,7 +236,10 @@ async def acompletion_with_mcp( # noqa: PLR0915
def _add_mcp_list_tools_to_chunk(self, chunk: ModelResponseStream) -> ModelResponseStream:
"""Add mcp_list_tools to the first chunk."""
- from litellm.types.utils import StreamingChoices, add_provider_specific_fields
+ from litellm.types.utils import (
+ StreamingChoices,
+ add_provider_specific_fields,
+ )
if not self.openai_tools:
return chunk
@@ -258,7 +262,10 @@ async def acompletion_with_mcp( # noqa: PLR0915
def _add_mcp_tool_metadata_to_final_chunk(self, chunk: ModelResponseStream) -> ModelResponseStream:
"""Add mcp_tool_calls and mcp_call_results to the final chunk."""
- from litellm.types.utils import StreamingChoices, add_provider_specific_fields
+ from litellm.types.utils import (
+ StreamingChoices,
+ add_provider_specific_fields,
+ )
if hasattr(chunk, "choices") and chunk.choices:
for choice in chunk.choices:
diff --git a/litellm/responses/mcp/litellm_proxy_mcp_handler.py b/litellm/responses/mcp/litellm_proxy_mcp_handler.py
index 297ccf4355..805a195855 100644
--- a/litellm/responses/mcp/litellm_proxy_mcp_handler.py
+++ b/litellm/responses/mcp/litellm_proxy_mcp_handler.py
@@ -6,10 +6,10 @@ from typing import (
Dict,
Iterable,
List,
+ Literal,
Optional,
Tuple,
Union,
- Literal,
)
from litellm._logging import verbose_logger
@@ -29,6 +29,7 @@ from litellm.utils import Rules, function_setup
if TYPE_CHECKING:
from mcp.types import Tool as MCPTool
+
from litellm.proxy.utils import ProxyLogging
else:
MCPTool = Any
@@ -97,6 +98,7 @@ class LiteLLM_Proxy_MCP_Handler:
async def _get_mcp_tools_from_manager(
user_api_key_auth: Any,
mcp_tools_with_litellm_proxy: Optional[Iterable[ToolParam]],
+ litellm_trace_id: Optional[str] = None,
) -> tuple[List[MCPTool], List[str]]:
"""
Get available tools from the MCP server manager.
@@ -109,13 +111,13 @@ class LiteLLM_Proxy_MCP_Handler:
List of MCP tools
List names of allowed MCP servers
"""
- from litellm.proxy._experimental.mcp_server.server import (
- _get_tools_from_mcp_servers,
- _get_allowed_mcp_servers_from_mcp_server_names,
- )
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
+ from litellm.proxy._experimental.mcp_server.server import (
+ _get_allowed_mcp_servers_from_mcp_server_names,
+ _get_tools_from_mcp_servers,
+ )
mcp_servers: List[str] = []
if mcp_tools_with_litellm_proxy:
@@ -136,6 +138,7 @@ class LiteLLM_Proxy_MCP_Handler:
mcp_server_auth_headers=None,
log_list_tools_to_spendlogs=True,
list_tools_log_source="responses",
+ litellm_trace_id=litellm_trace_id,
)
allowed_mcp_server_ids = (
await global_mcp_server_manager.get_allowed_mcp_servers(user_api_key_auth)
@@ -239,7 +242,9 @@ class LiteLLM_Proxy_MCP_Handler:
@staticmethod
async def _process_mcp_tools_to_openai_format(
- user_api_key_auth: Any, mcp_tools_with_litellm_proxy: List[ToolParam]
+ user_api_key_auth: Any,
+ mcp_tools_with_litellm_proxy: List[ToolParam],
+ litellm_trace_id: Optional[str] = None,
) -> tuple[List[Any], dict[str, str]]:
"""
Centralized method to process MCP tools through the complete pipeline.
@@ -247,6 +252,7 @@ class LiteLLM_Proxy_MCP_Handler:
Args:
user_api_key_auth: User authentication info for access control
mcp_tools_with_litellm_proxy: ToolParam objects with server_url starting with "litellm_proxy"
+ litellm_trace_id: Optional trace ID for linking list_mcp_tools spend logs to parent request
Returns:
List of tools in OpenAI format ready to be sent to the LLM
@@ -258,6 +264,7 @@ class LiteLLM_Proxy_MCP_Handler:
) = await LiteLLM_Proxy_MCP_Handler._process_mcp_tools_without_openai_transform(
user_api_key_auth,
mcp_tools_with_litellm_proxy,
+ litellm_trace_id=litellm_trace_id,
)
openai_tools = LiteLLM_Proxy_MCP_Handler._transform_mcp_tools_to_openai(
@@ -268,7 +275,9 @@ class LiteLLM_Proxy_MCP_Handler:
@staticmethod
async def _process_mcp_tools_without_openai_transform(
- user_api_key_auth: Any, mcp_tools_with_litellm_proxy: List[ToolParam]
+ user_api_key_auth: Any,
+ mcp_tools_with_litellm_proxy: List[ToolParam],
+ litellm_trace_id: Optional[str] = None,
) -> tuple[List[Any], dict[str, str]]:
"""
Process MCP tools through filtering and deduplication pipeline without OpenAI transformation.
@@ -291,6 +300,7 @@ class LiteLLM_Proxy_MCP_Handler:
) = await LiteLLM_Proxy_MCP_Handler._get_mcp_tools_from_manager(
user_api_key_auth=user_api_key_auth,
mcp_tools_with_litellm_proxy=mcp_tools_with_litellm_proxy,
+ litellm_trace_id=litellm_trace_id,
)
# Step 2: Filter tools based on allowed_tools parameter
@@ -495,14 +505,13 @@ class LiteLLM_Proxy_MCP_Handler:
"""Execute tool calls and return results."""
from fastapi import HTTPException
+ from litellm._uuid import uuid
from litellm.exceptions import BlockedPiiEntityError, GuardrailRaisedException
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.proxy.proxy_server import proxy_logging_obj
- from litellm._uuid import uuid
-
tool_results = []
tool_call_id: Optional[str] = None
rules_obj = Rules()
@@ -1025,7 +1034,6 @@ class LiteLLM_Proxy_MCP_Handler:
List of MCP tool execution events for streaming
"""
from litellm._uuid import uuid
-
from litellm.responses.mcp.mcp_streaming_iterator import create_mcp_call_events
tool_execution_events: List[Any] = []
@@ -1108,8 +1116,8 @@ class LiteLLM_Proxy_MCP_Handler:
"""Add custom output elements to the final response for MCP tool execution."""
# Import the required classes for creating output items
import json
- from litellm._uuid import uuid
+ from litellm._uuid import uuid
from litellm.types.responses.main import GenericResponseOutputItem, OutputText
# Create output element for initial MCP tools
diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.tsx
new file mode 100644
index 0000000000..ef5de9febc
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.tsx
@@ -0,0 +1,442 @@
+import { useState } from "react";
+import { Typography, Descriptions, Card, Tag, Tabs, Alert, Collapse, Radio, Space } from "antd";
+import moment from "moment";
+import { LogEntry } from "../columns";
+import { formatNumberWithCommas } from "@/utils/dataUtils";
+import GuardrailViewer from "../GuardrailViewer/GuardrailViewer";
+import { CostBreakdownViewer } from "../CostBreakdownViewer";
+import { ConfigInfoMessage } from "../ConfigInfoMessage";
+import { VectorStoreViewer } from "../VectorStoreViewer";
+import { TruncatedValue } from "./TruncatedValue";
+import { TokenFlow } from "./TokenFlow";
+import { JsonViewer } from "./JsonViewer";
+import {
+ formatData,
+ checkHasMessages,
+ checkHasResponse,
+ normalizeGuardrailEntries,
+ calculateTotalMaskedEntities,
+ getGuardrailLabel,
+ checkHasVectorStoreData,
+} from "./utils";
+import {
+ DRAWER_CONTENT_PADDING,
+ API_BASE_MAX_WIDTH,
+ METADATA_MAX_HEIGHT,
+ TAB_REQUEST,
+ TAB_RESPONSE,
+ FONT_SIZE_SMALL,
+ FONT_FAMILY_MONO,
+ SPACING_XLARGE,
+ SPACING_MEDIUM,
+} from "./constants";
+import { ToolsSection } from "../ToolsSection";
+import { PrettyMessagesView } from "./PrettyMessagesView";
+
+const { Text } = Typography;
+
+export interface LogDetailContentProps {
+ logEntry: LogEntry;
+ onOpenSettings?: () => void;
+}
+
+/**
+ * The scrollable detail content for a single log entry.
+ * Renders request details, metrics, cost breakdown, request/response,
+ * guardrails, vector store data, and metadata.
+ *
+ * Designed to be placed inside LogDetailsDrawer's right panel so it can
+ * be reused for both single-log and session-mode views.
+ */
+export function LogDetailContent({ logEntry, onOpenSettings }: LogDetailContentProps) {
+ const metadata = logEntry.metadata || {};
+ const hasError = metadata.status === "failure";
+ const errorInfo = hasError ? metadata.error_information : null;
+
+ const hasMessages = checkHasMessages(logEntry.messages);
+ const hasResponse = checkHasResponse(logEntry.response);
+ const missingData = !hasMessages && !hasResponse && !hasError;
+
+ // Guardrail data
+ const guardrailInfo = metadata?.guardrail_information;
+ const guardrailEntries = normalizeGuardrailEntries(guardrailInfo);
+ const hasGuardrailData = guardrailEntries.length > 0;
+ const totalMaskedEntities = calculateTotalMaskedEntities(guardrailEntries);
+ const primaryGuardrailLabel = getGuardrailLabel(guardrailEntries);
+
+ // Vector store data
+ const hasVectorStoreData = checkHasVectorStoreData(metadata);
+
+ const getRawRequest = () => {
+ return formatData(logEntry.proxy_server_request || logEntry.messages);
+ };
+
+ const getFormattedResponse = () => {
+ if (hasError && errorInfo) {
+ return {
+ error: {
+ message: errorInfo.error_message || "An error occurred",
+ type: errorInfo.error_class || "error",
+ code: errorInfo.error_code || "unknown",
+ param: null,
+ },
+ };
+ }
+ return formatData(logEntry.response);
+ };
+
+ return (
+
+ {/* Error Alert */}
+ {hasError && errorInfo && (
+
}
+ className="mb-6"
+ />
+ )}
+
+ {/* Tags */}
+ {logEntry.request_tags && Object.keys(logEntry.request_tags).length > 0 && (
+
+ )}
+
+ {/* Request Details */}
+
+
+
+ {logEntry.model}
+ {logEntry.custom_llm_provider || "-"}
+ {logEntry.call_type}
+
+
+
+
+
+
+ {logEntry.requester_ip_address && (
+ {logEntry.requester_ip_address}
+ )}
+ {hasGuardrailData && (
+
+
+
+ )}
+
+
+
+
+ {/* Metrics */}
+
+
+ {/* Cost Breakdown */}
+
+
+ {/* Tools */}
+
+
+ {/* Configuration Info Message */}
+ {missingData && (
+
+
+
+ )}
+
+ {/* Request/Response JSON */}
+
+
+ {/* Guardrail Data */}
+ {hasGuardrailData &&
}
+
+ {/* Vector Store Data */}
+ {hasVectorStoreData &&
}
+
+ {/* Metadata */}
+ {logEntry.metadata && Object.keys(logEntry.metadata).length > 0 && (
+
+ )}
+
+ {/* Bottom spacing */}
+
+
+ );
+}
+
+// ============================================================================
+// Helper Components
+// ============================================================================
+
+function ErrorDescription({ errorInfo }: { errorInfo: any }) {
+ return (
+
+ {errorInfo.error_code && (
+
+ Error Code: {errorInfo.error_code}
+
+ )}
+ {errorInfo.error_message && (
+
+ Message: {errorInfo.error_message}
+
+ )}
+
+ );
+}
+
+function TagsSection({ tags }: { tags: Record }) {
+ return (
+
+
+ Tags
+
+
+ {Object.entries(tags).map(([key, value]) => (
+
+ {key}: {String(value)}
+
+ ))}
+
+
+ );
+}
+
+function GuardrailLabel({ label, maskedCount }: { label: string; maskedCount: number }) {
+ return (
+
+ {label}
+ {maskedCount > 0 && (
+
+ {maskedCount} masked
+
+ )}
+
+ );
+}
+
+function MetricsSection({ logEntry, metadata }: { logEntry: LogEntry; metadata: Record }) {
+ const hasCacheActivity =
+ logEntry.cache_hit ||
+ (metadata?.additional_usage_values?.cache_read_input_tokens &&
+ metadata.additional_usage_values.cache_read_input_tokens > 0);
+
+ return (
+
+
+
+
+
+
+ ${formatNumberWithCommas(logEntry.spend || 0, 8)}
+ {logEntry.duration?.toFixed(3)} s
+
+ {hasCacheActivity && (
+ <>
+
+ {logEntry.cache_hit || "None"}
+
+ {metadata?.additional_usage_values?.cache_read_input_tokens > 0 && (
+
+ {formatNumberWithCommas(metadata.additional_usage_values.cache_read_input_tokens)}
+
+ )}
+ {metadata?.additional_usage_values?.cache_creation_input_tokens > 0 && (
+
+ {formatNumberWithCommas(metadata.additional_usage_values.cache_creation_input_tokens)}
+
+ )}
+ >
+ )}
+
+ {metadata?.litellm_overhead_time_ms !== undefined && metadata.litellm_overhead_time_ms !== null && (
+
+ {metadata.litellm_overhead_time_ms.toFixed(2)} ms
+
+ )}
+
+
+ {moment(logEntry.startTime).format("YYYY-MM-DDTHH:mm:ss.SSS[Z]")}
+
+
+ {moment(logEntry.endTime).format("YYYY-MM-DDTHH:mm:ss.SSS[Z]")}
+
+
+
+
+ );
+}
+
+interface RequestResponseSectionProps {
+ hasResponse: boolean;
+ hasError: boolean;
+ getRawRequest: () => any;
+ getFormattedResponse: () => any;
+ logEntry: LogEntry;
+}
+
+function RequestResponseSection({
+ hasResponse,
+ hasError,
+ getRawRequest,
+ getFormattedResponse,
+ logEntry,
+}: RequestResponseSectionProps) {
+ const [activeTab, setActiveTab] = useState(TAB_REQUEST);
+ const [viewMode, setViewMode] = useState<'pretty' | 'json'>('pretty');
+
+ const getCopyText = () => {
+ const data = activeTab === TAB_REQUEST ? getRawRequest() : getFormattedResponse();
+ return JSON.stringify(data, null, 2);
+ };
+
+ const totalSpend = logEntry.spend || 0;
+ const promptTokens = logEntry.prompt_tokens || 0;
+ const completionTokens = logEntry.completion_tokens || 0;
+ const totalTokens = promptTokens + completionTokens;
+ const inputCost = totalTokens > 0 ? (totalSpend * promptTokens) / totalTokens : 0;
+ const outputCost = totalTokens > 0 ? (totalSpend * completionTokens) / totalTokens : 0;
+
+ return (
+
+ {
+ const target = e.target as HTMLElement;
+ if (target.closest('.ant-radio-group')) {
+ e.stopPropagation();
+ }
+ }}
+ >
+ Request & Response
+ setViewMode(e.target.value)}
+ >
+ Pretty
+ JSON
+
+
+ ),
+ children: (
+
+ {viewMode === 'pretty' ? (
+
+ ) : (
+
setActiveTab(key as typeof TAB_REQUEST | typeof TAB_RESPONSE)}
+ tabBarExtraContent={
+
+ }
+ items={[
+ {
+ key: TAB_REQUEST,
+ label: "Request",
+ children: (
+
+
+
+ ),
+ },
+ {
+ key: TAB_RESPONSE,
+ label: "Response",
+ children: (
+
+ {hasResponse || hasError ? (
+
+ ) : (
+
+ Response data not available
+
+ )}
+
+ ),
+ },
+ ]}
+ />
+ )}
+
+ ),
+ },
+ ]}
+ />
+
+ );
+}
+
+function MetadataSection({ metadata }: { metadata: Record }) {
+ return (
+
+
Metadata,
+ children: (
+
+
+
+
+
+ {JSON.stringify(metadata, null, 2)}
+
+
+ ),
+ },
+ ]}
+ />
+
+ );
+}
diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx
index a3f948296e..8d087fed70 100644
--- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx
+++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx
@@ -1,52 +1,92 @@
-import { useState } from "react";
-import { Drawer, Typography, Descriptions, Card, Tag, Tabs, Alert, Collapse, Radio, Space } from "antd";
-import moment from "moment";
+import { useEffect, useMemo, useState } from "react";
+import { Button, Drawer } from "antd";
+import {
+ CheckOutlined,
+ CopyOutlined,
+ LeftOutlined,
+ RightOutlined,
+} from "@ant-design/icons";
+import { Sparkles, Wrench } from "lucide-react";
import { LogEntry } from "../columns";
-import { formatNumberWithCommas } from "@/utils/dataUtils";
-import GuardrailViewer from "../GuardrailViewer/GuardrailViewer";
-import { CostBreakdownViewer } from "../CostBreakdownViewer";
-import { ConfigInfoMessage } from "../ConfigInfoMessage";
-import { VectorStoreViewer } from "../VectorStoreViewer";
-import { TruncatedValue } from "./TruncatedValue";
-import { TokenFlow } from "./TokenFlow";
-import { JsonViewer } from "./JsonViewer";
+import { MCP_CALL_TYPES } from "../constants";
+import { getEventDisplayName } from "../utils";
import { DrawerHeader } from "./DrawerHeader";
import { useKeyboardNavigation } from "./useKeyboardNavigation";
-import {
- formatData,
- checkHasMessages,
- checkHasResponse,
- normalizeGuardrailEntries,
- calculateTotalMaskedEntities,
- getGuardrailLabel,
- checkHasVectorStoreData,
-} from "./utils";
-import {
- DRAWER_WIDTH,
- DRAWER_CONTENT_PADDING,
- API_BASE_MAX_WIDTH,
- METADATA_MAX_HEIGHT,
- TAB_REQUEST,
- TAB_RESPONSE,
- FONT_SIZE_SMALL,
- FONT_FAMILY_MONO,
- SPACING_XLARGE,
- SPACING_MEDIUM,
-} from "./constants";
-import { ToolsSection } from "../ToolsSection";
-import { PrettyMessagesView } from "./PrettyMessagesView";
-
-const { Text } = Typography;
+import { LogDetailContent } from "./LogDetailContent";
+import { sessionSpendLogsCall } from "../../networking";
+import { useQuery } from "@tanstack/react-query";
+import { getSpendString } from "@/utils/dataUtils";
+import { DRAWER_WIDTH } from "./constants";
export interface LogDetailsDrawerProps {
open: boolean;
onClose: () => void;
logEntry: LogEntry | null;
+ sessionId?: string | null;
+ accessToken?: string | null;
onOpenSettings?: () => void;
allLogs?: LogEntry[];
onSelectLog?: (log: LogEntry) => void;
}
+const SIDEBAR_WIDTH_PX = 224;
+
+/* ------------------------------------------------------------------ */
+/* TraceEventRow — compact event row used in both session & non- */
+/* session sidebar lists. Extracted to avoid JSX duplication. */
+/* ------------------------------------------------------------------ */
+interface TraceEventRowProps {
+ row: LogEntry;
+ isSelected: boolean;
+ onClick: () => void;
+}
+
+function TraceEventRow({ row, isSelected, onClick }: TraceEventRowProps) {
+ const isMcp = MCP_CALL_TYPES.includes(row.call_type);
+ const durationValue =
+ row.duration != null
+ ? row.duration.toFixed(3)
+ : row.startTime && row.endTime
+ ? ((Date.parse(row.endTime) - Date.parse(row.startTime)) / 1000).toFixed(3)
+ : "-";
+
+ return (
+
+
+ {isMcp ? (
+
+ ) : (
+
+ )}
+
+ {getEventDisplayName(row.call_type, row.model)}
+
+
+
+ {durationValue}s
+ {row.spend ? (
+ <>
+ ·
+ {getSpendString(row.spend)}
+ >
+ ) : null}
+ {row.total_tokens ? (
+ <>
+ ·
+ {row.total_tokens} tok
+ >
+ ) : null}
+
+
+ );
+}
+
/**
* Right-side drawer panel for displaying detailed log information.
* Features:
@@ -61,64 +101,118 @@ export function LogDetailsDrawer({
open,
onClose,
logEntry,
+ sessionId,
+ accessToken,
onOpenSettings,
allLogs = [],
onSelectLog,
}: LogDetailsDrawerProps) {
- const [activeTab, setActiveTab] = useState(TAB_REQUEST);
+ const isSessionMode = Boolean(sessionId);
+ const [selectedSessionRequestId, setSelectedSessionRequestId] = useState(null);
+ const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
+ const [copiedLeftPanelId, setCopiedLeftPanelId] = useState(false);
+
+ const { data: sessionLogs = [] } = useQuery({
+ queryKey: ["sessionLogs", sessionId],
+ queryFn: async () => {
+ if (!sessionId || !accessToken) return [];
+ const response = await sessionSpendLogsCall(accessToken, sessionId);
+ const allSessionLogs: LogEntry[] = response.data || response || [];
+ return allSessionLogs
+ .map((row) => ({
+ ...row,
+ duration: (Date.parse(row.endTime) - Date.parse(row.startTime)) / 1000,
+ }))
+ .sort((a, b) => {
+ const aIsMcp = MCP_CALL_TYPES.includes(a.call_type) ? 1 : 0;
+ const bIsMcp = MCP_CALL_TYPES.includes(b.call_type) ? 1 : 0;
+ if (aIsMcp !== bIsMcp) return aIsMcp - bIsMcp;
+ return new Date(a.startTime).getTime() - new Date(b.startTime).getTime();
+ });
+ },
+ enabled: Boolean(open && isSessionMode && sessionId && accessToken),
+ });
+
+ const currentLog = useMemo(() => {
+ if (!isSessionMode) return logEntry;
+ if (!sessionLogs.length) return null;
+ if (selectedSessionRequestId) {
+ return sessionLogs.find((row) => row.request_id === selectedSessionRequestId) || sessionLogs[0];
+ }
+ if (logEntry?.request_id) {
+ const clickedLog = sessionLogs.find((row) => row.request_id === logEntry.request_id);
+ return clickedLog || sessionLogs[0];
+ }
+ return sessionLogs[0];
+ }, [isSessionMode, logEntry, selectedSessionRequestId, sessionLogs]);
+
+ useEffect(() => {
+ if (!isSessionMode || !sessionLogs.length) return;
+ if (!selectedSessionRequestId || !sessionLogs.some((row) => row.request_id === selectedSessionRequestId)) {
+ const fallbackRequestId = logEntry?.request_id && sessionLogs.some((row) => row.request_id === logEntry.request_id)
+ ? logEntry.request_id
+ : sessionLogs[0].request_id;
+ setSelectedSessionRequestId(fallbackRequestId);
+ }
+ }, [isSessionMode, logEntry, selectedSessionRequestId, sessionLogs]);
+
+ // Reset transient UI state when the drawer opens or closes.
+ useEffect(() => {
+ if (open) {
+ setIsSidebarCollapsed(false);
+ } else {
+ if (isSessionMode) setSelectedSessionRequestId(null);
+ setCopiedLeftPanelId(false);
+ }
+ }, [open, isSessionMode]);
// Keyboard navigation
const { selectNextLog, selectPreviousLog } = useKeyboardNavigation({
isOpen: open,
- currentLog: logEntry,
- allLogs,
+ currentLog,
+ allLogs: isSessionMode ? sessionLogs : allLogs,
onClose,
- onSelectLog,
+ onSelectLog: (selected) => {
+ if (isSessionMode) {
+ setSelectedSessionRequestId(selected.request_id);
+ }
+ onSelectLog?.(selected);
+ },
});
- if (!logEntry) return null;
-
- const metadata = logEntry.metadata || {};
- const hasError = metadata.status === "failure";
- const errorInfo = hasError ? metadata.error_information : null;
-
- // Check if request/response data is present
- const hasMessages = checkHasMessages(logEntry.messages);
- const hasResponse = checkHasResponse(logEntry.response);
- const missingData = !hasMessages && !hasResponse && !hasError;
-
- // Guardrail data
- const guardrailInfo = metadata?.guardrail_information;
- const guardrailEntries = normalizeGuardrailEntries(guardrailInfo);
- const hasGuardrailData = guardrailEntries.length > 0;
- const totalMaskedEntities = calculateTotalMaskedEntities(guardrailEntries);
- const primaryGuardrailLabel = getGuardrailLabel(guardrailEntries);
-
- // Vector store data
- const hasVectorStoreData = checkHasVectorStoreData(metadata);
+ const metadata = currentLog?.metadata || {};
// Status display values
const statusLabel = metadata.status === "failure" ? "Failure" : "Success";
const statusColor = metadata.status === "failure" ? ("error" as const) : ("success" as const);
const environment = metadata?.user_api_key_team_alias || "default";
- const getRawRequest = () => {
- return formatData(logEntry.proxy_server_request || logEntry.messages);
+ const totalSessionCost = sessionLogs.reduce((sum, row) => sum + (row.spend || 0), 0);
+ const sessionStart = sessionLogs.length > 0
+ ? new Date(Math.min(...sessionLogs.map((r) => new Date(r.startTime).getTime())))
+ : null;
+ const sessionEnd = sessionLogs.length > 0
+ ? new Date(Math.max(...sessionLogs.map((r) => new Date(r.endTime).getTime())))
+ : null;
+ const sessionDurationSeconds =
+ sessionStart && sessionEnd ? ((sessionEnd.getTime() - sessionStart.getTime()) / 1000).toFixed(2) : "0.00";
+ const llmCount = sessionLogs.filter((row) => !MCP_CALL_TYPES.includes(row.call_type)).length;
+ const mcpCount = sessionLogs.filter((row) => MCP_CALL_TYPES.includes(row.call_type)).length;
+ const logsForList = isSessionMode ? sessionLogs : currentLog ? [currentLog] : [];
+ const leftPanelId = isSessionMode ? sessionId || "" : currentLog?.request_id || "";
+ const leftPanelDisplayId =
+ leftPanelId.length > 14 ? `${leftPanelId.slice(0, 11)}...` : leftPanelId;
+
+ const handleCopyLeftPanelId = async () => {
+ if (!leftPanelId) return;
+ try {
+ await navigator.clipboard.writeText(leftPanelId);
+ setCopiedLeftPanelId(true);
+ setTimeout(() => setCopiedLeftPanelId(false), 1200);
+ } catch { /* clipboard unavailable in non-secure contexts */ }
};
- const getFormattedResponse = () => {
- if (hasError && errorInfo) {
- return {
- error: {
- message: errorInfo.error_message || "An error occurred",
- type: errorInfo.error_class || "error",
- code: errorInfo.error_code || "unknown",
- param: null,
- },
- };
- }
- return formatData(logEntry.response);
- };
+ if (!currentLog) return null;
return (
-
+
+ {!isSidebarCollapsed ? (
+
}
+ onClick={() => setIsSidebarCollapsed(true)}
+ className="absolute top-2 left-2 z-20 !bg-white !border !border-slate-200 !rounded-md"
+ aria-label="Collapse trace sidebar"
+ />
+ ) : (
+
}
+ onClick={() => setIsSidebarCollapsed(false)}
+ className="absolute top-2 left-2 z-20 !bg-white !border !border-slate-200 !rounded-md"
+ aria-label="Expand trace sidebar"
+ />
+ )}
+ {!isSidebarCollapsed && (
+
+
+
+
+
+ {isSessionMode ? "Session" : "Trace"}
+
+
+ {leftPanelDisplayId}
+
+ {copiedLeftPanelId ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {logsForList.length} req
+ ·
+ {isSessionMode
+ ? `${llmCount} LLM`
+ : `${logsForList.filter((row) => !MCP_CALL_TYPES.includes(row.call_type)).length} LLM`}
+ ·
+ {isSessionMode
+ ? `${mcpCount} MCP`
+ : `${logsForList.filter((row) => MCP_CALL_TYPES.includes(row.call_type)).length} MCP`}
+ ·
+ {isSessionMode
+ ? getSpendString(totalSessionCost)
+ : getSpendString(currentLog.spend || 0)}
+ {isSessionMode && (
+ <>
+ ·
+ {sessionDurationSeconds}s
+ >
+ )}
+
+
-
- {/* Error Alert - Show prominently at top for failures */}
- {hasError && errorInfo && (
-
}
- className="mb-6"
- />
- )}
-
- {/* Tags - Only show if present */}
- {logEntry.request_tags && Object.keys(logEntry.request_tags).length > 0 && (
-
- )}
-
- {/* Request Details Section */}
-
-
-
- {logEntry.model}
- {logEntry.custom_llm_provider || "-"}
- {logEntry.call_type}
-
-
-
-
-
-
- {logEntry.requester_ip_address && (
- {logEntry.requester_ip_address}
- )}
- {hasGuardrailData && (
-
-
-
- )}
-
-
-
-
- {/* Metrics Section */}
-
-
- {/* Cost Breakdown - Show if cost breakdown data is available */}
-
-
- {/* Tools Section - Show if tools are present in request */}
-
-
- {/* Configuration Info Message - Show when data is missing */}
- {missingData && (
-
-
+
+ {isSessionMode ? (
+
+ {/* Child events — vertical tree line with horizontal connectors */}
+
+
+ {logsForList.map((row, idx) => {
+ const isLast = idx === logsForList.length - 1;
+ return (
+
+
+ {isLast &&
}
+
{
+ setSelectedSessionRequestId(row.request_id);
+ onSelectLog?.(row);
+ }}
+ />
+
+ );
+ })}
+
+
+ ) : (
+
+ {logsForList.map((row) => (
+ onSelectLog?.(row)}
+ />
+ ))}
+
+ )}
+
- )}
+ )}
- {/* Request/Response JSON - Collapsible */}
-
-
- {/* Guardrail Data - Show only if present */}
- {hasGuardrailData &&
}
-
- {/* Vector Store Request Data - Show only if present */}
- {hasVectorStoreData &&
}
-
- {/* Metadata Card - Only show if there's metadata */}
- {logEntry.metadata && Object.keys(logEntry.metadata).length > 0 && (
-
- )}
-
- {/* Bottom spacing for scroll area */}
-
-
+
+
);
}
-
-// ============================================================================
-// Helper Components
-// ============================================================================
-
-function ErrorDescription({ errorInfo }: { errorInfo: any }) {
- return (
-
- {errorInfo.error_code && (
-
- Error Code: {errorInfo.error_code}
-
- )}
- {errorInfo.error_message && (
-
- Message: {errorInfo.error_message}
-
- )}
-
- );
-}
-
-function TagsSection({ tags }: { tags: Record
}) {
- return (
-
-
- Tags
-
-
- {Object.entries(tags).map(([key, value]) => (
-
- {key}: {String(value)}
-
- ))}
-
-
- );
-}
-
-function GuardrailLabel({ label, maskedCount }: { label: string; maskedCount: number }) {
- return (
-
- {label}
- {maskedCount > 0 && (
-
- {maskedCount} masked
-
- )}
-
- );
-}
-
-function MetricsSection({ logEntry, metadata }: { logEntry: LogEntry; metadata: Record }) {
- const hasCacheActivity =
- logEntry.cache_hit ||
- (metadata?.additional_usage_values?.cache_read_input_tokens &&
- metadata.additional_usage_values.cache_read_input_tokens > 0);
-
- return (
-
-
-
-
-
-
- ${formatNumberWithCommas(logEntry.spend || 0, 8)}
- {logEntry.duration?.toFixed(3)} s
-
- {/* Only show cache fields if there's cache activity */}
- {hasCacheActivity && (
- <>
-
- {logEntry.cache_hit || "None"}
-
- {metadata?.additional_usage_values?.cache_read_input_tokens > 0 && (
-
- {formatNumberWithCommas(metadata.additional_usage_values.cache_read_input_tokens)}
-
- )}
- {metadata?.additional_usage_values?.cache_creation_input_tokens > 0 && (
-
- {formatNumberWithCommas(metadata.additional_usage_values.cache_creation_input_tokens)}
-
- )}
- >
- )}
-
- {metadata?.litellm_overhead_time_ms !== undefined && metadata.litellm_overhead_time_ms !== null && (
-
- {metadata.litellm_overhead_time_ms.toFixed(2)} ms
-
- )}
-
-
- {moment(logEntry.startTime).format("YYYY-MM-DDTHH:mm:ss.SSS[Z]")}
-
-
- {moment(logEntry.endTime).format("YYYY-MM-DDTHH:mm:ss.SSS[Z]")}
-
-
-
-
- );
-}
-
-interface RequestResponseSectionProps {
- hasResponse: boolean;
- hasError: boolean;
- getRawRequest: () => any;
- getFormattedResponse: () => any;
- logEntry: LogEntry;
-}
-
-function RequestResponseSection({
- hasResponse,
- hasError,
- getRawRequest,
- getFormattedResponse,
- logEntry,
-}: RequestResponseSectionProps) {
- const [activeTab, setActiveTab] = useState(TAB_REQUEST);
- const [viewMode, setViewMode] = useState<'pretty' | 'json'>('pretty');
-
- const getCopyText = () => {
- const data = activeTab === TAB_REQUEST ? getRawRequest() : getFormattedResponse();
- return JSON.stringify(data, null, 2);
- };
-
- // Calculate input and output costs
- // Assume average cost if not explicitly provided
- const totalSpend = logEntry.spend || 0;
- const promptTokens = logEntry.prompt_tokens || 0;
- const completionTokens = logEntry.completion_tokens || 0;
- const totalTokens = promptTokens + completionTokens;
-
- // Estimate input/output costs proportionally if not available
- const inputCost = totalTokens > 0 ? (totalSpend * promptTokens) / totalTokens : 0;
- const outputCost = totalTokens > 0 ? (totalSpend * completionTokens) / totalTokens : 0;
-
- return (
-
- {
- // Only prevent if clicking on the Radio.Group area
- const target = e.target as HTMLElement;
- if (target.closest('.ant-radio-group')) {
- e.stopPropagation();
- }
- }}
- >
- Request & Response
- {/* View Mode Toggle - In the header */}
- setViewMode(e.target.value)}
- >
- Pretty
- JSON
-
-
- ),
- children: (
-
- {viewMode === 'pretty' ? (
-
- ) : (
-
setActiveTab(key as typeof TAB_REQUEST | typeof TAB_RESPONSE)}
- tabBarExtraContent={
-
- }
- items={[
- {
- key: TAB_REQUEST,
- label: "Request",
- children: (
-
-
-
- ),
- },
- {
- key: TAB_RESPONSE,
- label: "Response",
- children: (
-
- {hasResponse || hasError ? (
-
- ) : (
-
- Response data not available
-
- )}
-
- ),
- },
- ]}
- />
- )}
-
- ),
- },
- ]}
- />
-
- );
-}
-
-function MetadataSection({ metadata }: { metadata: Record }) {
- return (
-
-
Metadata,
- children: (
-
-
-
-
-
- {JSON.stringify(metadata, null, 2)}
-
-
- ),
- },
- ]}
- />
-
- );
-}
-
diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/index.ts b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/index.ts
index e1fdd9d2d6..c2839df27e 100644
--- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/index.ts
+++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/index.ts
@@ -1,2 +1,4 @@
export { LogDetailsDrawer } from "./LogDetailsDrawer";
export type { LogDetailsDrawerProps } from "./LogDetailsDrawer";
+export { LogDetailContent } from "./LogDetailContent";
+export type { LogDetailContentProps } from "./LogDetailContent";
diff --git a/ui/litellm-dashboard/src/components/view_logs/SessionView.tsx b/ui/litellm-dashboard/src/components/view_logs/SessionView.tsx
deleted file mode 100644
index ce77ad14a1..0000000000
--- a/ui/litellm-dashboard/src/components/view_logs/SessionView.tsx
+++ /dev/null
@@ -1,193 +0,0 @@
-import React, { useState } from "react";
-import { LogEntry } from "./columns";
-import { DataTable } from "./table";
-import { columns } from "./columns";
-import { Card, Title, Text, Metric, Button as TremorButton } from "@tremor/react";
-import { RequestViewer } from "./index";
-import { formatNumberWithCommas } from "@/utils/dataUtils";
-import { ArrowLeftIcon } from "@heroicons/react/outline";
-import { Button } from "antd";
-import { copyToClipboard as utilCopyToClipboard } from "../../utils/dataUtils";
-import { CheckIcon, CopyIcon } from "lucide-react";
-import { Tooltip } from "antd";
-
-interface SessionViewProps {
- sessionId: string;
- logs: LogEntry[];
- onBack: () => void;
-}
-
-export const SessionView: React.FC = ({ sessionId, logs, onBack }) => {
- // Track which log row is expanded
- const [expandedRequestId, setExpandedRequestId] = useState(null);
- const [copiedStates, setCopiedStates] = useState>({});
-
- // Calculate session metrics
- const totalCost = logs.reduce((sum, log) => sum + (log.spend || 0), 0);
- const totalTokens = logs.reduce((sum, log) => sum + (log.total_tokens || 0), 0);
-
- // Calculate cache token totals from metadata
- const totalCacheReadTokens = logs.reduce((sum, log) => {
- const cacheReadTokens = log.metadata?.additional_usage_values?.cache_read_input_tokens || 0;
- return sum + cacheReadTokens;
- }, 0);
-
- const totalCacheCreationTokens = logs.reduce((sum, log) => {
- const cacheCreationTokens = log.metadata?.additional_usage_values?.cache_creation_input_tokens || 0;
- return sum + cacheCreationTokens;
- }, 0);
-
- // Calculate total tokens including cache tokens
- const totalTokensWithCache = totalTokens + totalCacheReadTokens + totalCacheCreationTokens;
-
- const startTime = logs.length > 0 ? new Date(logs[0].startTime) : new Date();
- const endTime = logs.length > 0 ? new Date(logs[logs.length - 1].endTime) : new Date();
- const durationMs = endTime.getTime() - startTime.getTime();
- const durationSec = (durationMs / 1000).toFixed(2);
-
- // Prepare data for the timeline chart
- const timelineData = logs.map((log) => ({
- time: new Date(log.startTime).toISOString(),
- tokens: log.total_tokens || 0,
- cost: log.spend || 0,
- }));
-
- const copyToClipboard = async (text: string, key: string) => {
- const success = await utilCopyToClipboard(text);
- if (success) {
- setCopiedStates((prev) => ({ ...prev, [key]: true }));
- setTimeout(() => {
- setCopiedStates((prev) => ({ ...prev, [key]: false }));
- }, 2000);
- }
- };
-
- return (
-
- {/* Header with back button */}
-
-
- Back to All Logs
-
-
-
Session Details
-
-
-
{sessionId}
-
:
}
- onClick={() => copyToClipboard(sessionId, "session-id")}
- className={`left-2 z-10 transition-all duration-200 ${
- copiedStates["session-id"]
- ? "text-green-600 bg-green-50 border-green-200"
- : "text-gray-500 hover:text-gray-700 hover:bg-gray-100"
- }`}
- />
-
-
- Get started with session management here
-
-
-
-
-
-
-
-
- {/* Session Overview Cards */}
-
-
- Total Requests
- {logs.length}
-
-
- Total Cost
- ${formatNumberWithCommas(totalCost, 6)}
-
-
- Usage breakdown
-
-
-
Input usage:
-
-
- input:
-
- {formatNumberWithCommas(logs.reduce((sum, log) => sum + (log.prompt_tokens || 0), 0))}
-
-
- {totalCacheReadTokens > 0 && (
-
- input_cached_tokens:
- {formatNumberWithCommas(totalCacheReadTokens)}
-
- )}
- {totalCacheCreationTokens > 0 && (
-
- input_cache_creation_tokens:
- {formatNumberWithCommas(totalCacheCreationTokens)}
-
- )}
-
-
-
-
Output usage:
-
-
- output:
-
- {formatNumberWithCommas(logs.reduce((sum, log) => sum + (log.completion_tokens || 0), 0))}
-
-
-
-
-
-
- Total usage:
- {formatNumberWithCommas(totalTokensWithCache)}
-
-
-
-
- }
- placement="top"
- overlayStyle={{ minWidth: "300px" }}
- >
-
-
- Total Tokens
- ⓘ
-
- {formatNumberWithCommas(totalTokensWithCache)}
-
-
-
- {/* Request Timeline */}
- Session Logs
-
- true}
- loadingMessage="Loading logs..."
- noDataMessage="No logs found"
- />
-
-
- );
-};
diff --git a/ui/litellm-dashboard/src/components/view_logs/TypeBadges.tsx b/ui/litellm-dashboard/src/components/view_logs/TypeBadges.tsx
new file mode 100644
index 0000000000..e4195ece9e
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/view_logs/TypeBadges.tsx
@@ -0,0 +1,30 @@
+/**
+ * Compact type-indicator badges for LLM and MCP log entries.
+ * Used in the request logs table and session type column.
+ */
+
+export const SparkleIcon = ({ size = 12 }: { size?: number }) => (
+
+
+
+);
+
+export const WrenchIcon = ({ size = 10 }: { size?: number }) => (
+
+
+
+);
+
+export const LlmBadge = ({ count }: { count?: number }) => (
+
+
+ {count != null ? count : "LLM"}
+
+);
+
+export const McpBadge = ({ count }: { count?: number }) => (
+
+
+ {count != null ? count : "MCP"}
+
+);
diff --git a/ui/litellm-dashboard/src/components/view_logs/columns.tsx b/ui/litellm-dashboard/src/components/view_logs/columns.tsx
index 3e72c8e13b..452d31aed5 100644
--- a/ui/litellm-dashboard/src/components/view_logs/columns.tsx
+++ b/ui/litellm-dashboard/src/components/view_logs/columns.tsx
@@ -5,6 +5,8 @@ import { Tooltip } from "antd";
import React, { useState } from "react";
import { getProviderLogoAndName } from "../provider_info_helpers";
import { TimeCell } from "./time_cell";
+import { MCP_CALL_TYPES } from "./constants";
+import { LlmBadge, McpBadge, SparkleIcon, WrenchIcon } from "./TypeBadges";
// Helper to get the appropriate logo URL
const getLogoUrl = (row: LogEntry, provider: string) => {
@@ -44,6 +46,12 @@ export type LogEntry = {
session_id?: string;
status?: string;
duration?: number;
+ session_total_count?: number;
+ session_total_spend?: number;
+ mcp_tool_call_count?: number;
+ mcp_tool_call_spend?: number;
+ session_llm_count?: number;
+ session_mcp_count?: number;
onKeyHashClick?: (keyHash: string) => void;
onSessionClick?: (sessionId: string) => void;
};
@@ -54,6 +62,40 @@ export const columns: ColumnDef[] = [
accessorKey: "startTime",
cell: (info: any) => ,
},
+ {
+ header: "Type",
+ id: "type",
+ cell: (info: any) => {
+ const row = info.row.original;
+ const sessionCount = row.session_total_count || 1;
+ const isMcp = MCP_CALL_TYPES.includes(row.call_type);
+ const sessionLlmCount = row.session_llm_count ?? (isMcp ? 0 : sessionCount);
+ const sessionMcpCount = row.session_mcp_count ?? (isMcp ? sessionCount : 0);
+
+ if (isMcp) return ;
+ if (sessionCount <= 1) return ;
+
+ // Multi-call session — show total count, plus MCP indicator when mixed.
+ const sessionTypeBadge = (
+
+
+ {sessionCount}
+ {sessionMcpCount > 0 && (
+ <>
+ ·
+
+ >
+ )}
+
+ );
+
+ return (
+
+ {sessionTypeBadge}
+
+ );
+ },
+ },
{
header: "Status",
accessorKey: "metadata.status",
@@ -105,11 +147,24 @@ export const columns: ColumnDef[] = [
{
header: "Cost",
accessorKey: "spend",
- cell: (info: any) => (
-
- {getSpendString(info.getValue() || 0)}
-
- ),
+ cell: (info: any) => {
+ const row = info.row.original;
+ const mcpCount = row.mcp_tool_call_count || 0;
+ const mcpSpend = row.mcp_tool_call_spend || 0;
+
+ return (
+
+
+ {getSpendString(info.getValue() || 0)}
+
+ {mcpCount > 0 && mcpSpend > 0 && (
+
+ incl. {getSpendString(mcpSpend)} from {mcpCount} MCP
+
+ )}
+
+ );
+ },
},
{
header: "Duration (s)",
diff --git a/ui/litellm-dashboard/src/components/view_logs/constants.ts b/ui/litellm-dashboard/src/components/view_logs/constants.ts
index 84862fa463..949dab275f 100644
--- a/ui/litellm-dashboard/src/components/view_logs/constants.ts
+++ b/ui/litellm-dashboard/src/components/view_logs/constants.ts
@@ -12,6 +12,9 @@ export const ERROR_CODE_OPTIONS: { label: string; value: string }[] = [
{ label: "529 - Overloaded", value: "529" },
];
+/** Call types that represent MCP tool invocations (shared across columns, index, drawer). */
+export const MCP_CALL_TYPES = ["call_mcp_tool", "list_mcp_tools"];
+
export const QUICK_SELECT_OPTIONS: { label: string; value: number; unit: string }[] = [
{ label: "Last 15 Minutes", value: 15, unit: "minutes" },
{ label: "Last Hour", value: 1, unit: "hours" },
diff --git a/ui/litellm-dashboard/src/components/view_logs/index.tsx b/ui/litellm-dashboard/src/components/view_logs/index.tsx
index b43ff5220d..a247a1be4a 100644
--- a/ui/litellm-dashboard/src/components/view_logs/index.tsx
+++ b/ui/litellm-dashboard/src/components/view_logs/index.tsx
@@ -17,12 +17,12 @@ import { fetchAllKeyAliases } from "../key_team_helpers/filter_helpers";
import { KeyResponse, Team } from "../key_team_helpers/key_list";
import { PaginatedModelSelect } from "../ModelSelect/PaginatedModelSelect/PaginatedModelSelect";
import FilterComponent, { FilterOption } from "../molecules/filter";
-import { allEndUsersCall, keyInfoV1Call, keyListCall, sessionSpendLogsCall, uiSpendLogsCall } from "../networking";
+import { allEndUsersCall, keyInfoV1Call, keyListCall, uiSpendLogsCall } from "../networking";
import KeyInfoView from "../templates/key_info_view";
import AuditLogs from "./audit_logs";
import { columns, LogEntry } from "./columns";
import { ConfigInfoMessage } from "./ConfigInfoMessage";
-import { ERROR_CODE_OPTIONS, QUICK_SELECT_OPTIONS } from "./constants";
+import { ERROR_CODE_OPTIONS, MCP_CALL_TYPES, QUICK_SELECT_OPTIONS } from "./constants";
import { CostBreakdownViewer } from "./CostBreakdownViewer";
import { ErrorViewer } from "./ErrorViewer";
import { useLogFilterLogic } from "./log_filter_logic";
@@ -30,7 +30,6 @@ import { LogDetailsDrawer } from "./LogDetailsDrawer";
import { getTimeRangeDisplay } from "./logs_utils";
import { prefetchLogDetails } from "./prefetch";
import { RequestResponsePanel } from "./RequestResponsePanel";
-import { SessionView } from "./SessionView";
import SpendLogsSettingsModal from "./SpendLogsSettingsModal/SpendLogsSettingsModal";
import { DataTable } from "./table";
import { VectorStoreViewer } from "./VectorStoreViewer";
@@ -304,57 +303,70 @@ export default function SpendLogsTable({
}
}, [filters, accessToken, fetchKeyHashForAlias]);
- // Fetch logs for a session if selected
- const sessionLogs = useQuery({
- queryKey: ["sessionLogs", selectedSessionId],
- queryFn: async () => {
- if (!accessToken || !selectedSessionId) return { data: [], total: 0, page: 1, page_size: 50, total_pages: 1 };
- const response = await sessionSpendLogsCall(accessToken, selectedSessionId);
- // If the API returns an array, wrap it in the same shape as PaginatedResponse
- return {
- data: response.data || response || [],
- total: (response.data || response || []).length,
- page: 1,
- page_size: 1000,
- total_pages: 1,
- };
- },
- enabled: !!accessToken && !!selectedSessionId,
- });
-
-
if (!accessToken || !token || !userRole || !userID) {
return null;
}
+ const searchedLogs = filteredLogs.data.filter((log) => {
+ const matchesSearch =
+ !searchTerm ||
+ log.request_id.includes(searchTerm) ||
+ log.model.includes(searchTerm) ||
+ (log.user && log.user.includes(searchTerm));
+
+ // No need for additional filtering since we're now handling this in the API call
+ return matchesSearch;
+ });
+
+ const sessionCompositionById = searchedLogs.reduce>((acc, log) => {
+ if (!log.session_id) return acc;
+ if (!acc[log.session_id]) {
+ acc[log.session_id] = { llm: 0, mcp: 0 };
+ }
+ if (MCP_CALL_TYPES.includes(log.call_type)) {
+ acc[log.session_id].mcp += 1;
+ } else {
+ acc[log.session_id].llm += 1;
+ }
+ return acc;
+ }, {});
+
+ // Build a single-pass map of session_id → representative request_id.
+ // Prefers an LLM row over an MCP row as the representative.
+ const sessionRepresentativeMap = new Map();
+ for (const log of searchedLogs) {
+ if (!log.session_id || (log.session_total_count || 1) <= 1) continue;
+ const isMcp = MCP_CALL_TYPES.includes(log.call_type);
+ const existing = sessionRepresentativeMap.get(log.session_id);
+ if (!existing || (existing.isMcp && !isMcp)) {
+ sessionRepresentativeMap.set(log.session_id, { requestId: log.request_id, isMcp });
+ }
+ }
+
const filteredData =
- filteredLogs.data
- .filter((log) => {
- const matchesSearch =
- !searchTerm ||
- log.request_id.includes(searchTerm) ||
- log.model.includes(searchTerm) ||
- (log.user && log.user.includes(searchTerm));
-
- // No need for additional filtering since we're now handling this in the API call
- return matchesSearch;
+ searchedLogs
+ .map((log) => {
+ const sessionComposition = log.session_id ? sessionCompositionById[log.session_id] : undefined;
+ return {
+ ...log,
+ duration: (Date.parse(log.endTime) - Date.parse(log.startTime)) / 1000,
+ session_llm_count: sessionComposition?.llm ?? undefined,
+ session_mcp_count: sessionComposition?.mcp ?? undefined,
+ onKeyHashClick: (keyHash: string) => setSelectedKeyIdInfoView(keyHash),
+ onSessionClick: (sessionId: string) => {
+ if (sessionId) {
+ setSelectedSessionId(sessionId);
+ setSelectedLog(log);
+ setIsDrawerOpen(true);
+ }
+ },
+ };
})
- .map((log) => ({
- ...log,
- duration: (Date.parse(log.endTime) - Date.parse(log.startTime)) / 1000,
- onKeyHashClick: (keyHash: string) => setSelectedKeyIdInfoView(keyHash),
- onSessionClick: (sessionId: string) => {
- if (sessionId) setSelectedSessionId(sessionId);
- },
- })) || [];
-
- // For session logs, add onKeyHashClick/onSessionClick as well
- const sessionData =
- sessionLogs.data?.data?.map((log) => ({
- ...log,
- onKeyHashClick: (keyHash: string) => setSelectedKeyIdInfoView(keyHash),
- onSessionClick: (sessionId: string) => { },
- })) || [];
+ // Deduplicate multi-call sessions using the pre-built map (O(1) per row).
+ .filter((log) => {
+ if (!log.session_id || (log.session_total_count || 1) <= 1) return true;
+ return sessionRepresentativeMap.get(log.session_id)?.requestId === log.request_id;
+ }) || [];
// Add this function to handle manual refresh
const handleRefresh = () => {
@@ -362,13 +374,22 @@ export default function SpendLogsTable({
};
const handleRowClick = (log: LogEntry) => {
+ // Multi-call session row: open in the same right-side drawer (session mode)
+ if (log.session_id && (log.session_total_count || 1) > 1) {
+ setSelectedSessionId(log.session_id);
+ setSelectedLog(log);
+ setIsDrawerOpen(true);
+ return;
+ }
+ // Single-call row: open the detail drawer
+ setSelectedSessionId(null);
setSelectedLog(log);
setIsDrawerOpen(true);
};
const handleCloseDrawer = () => {
setIsDrawerOpen(false);
- // Optionally keep selectedLog for animation purposes
+ setSelectedSessionId(null);
};
const handleSelectLog = (log: LogEntry) => {
@@ -462,19 +483,6 @@ export default function SpendLogsTable({
},
];
- // When a session is selected, render the SessionView component
- if (selectedSessionId && sessionLogs.data) {
- return (
-
- setSelectedSessionId(null)}
- />
-
- );
- }
-
const formatTimeUnit = (value: number, unit: string) => {
if (value === 1) {
if (unit === "minutes") return "minute";
@@ -502,29 +510,12 @@ export default function SpendLogsTable({
-
- {selectedSessionId ? (
- <>
- Session: {selectedSessionId}
- setSelectedSessionId(null)}
- >
- ← Back to All Logs
-
- >
- ) : (
- "Request Logs"
- )}
-
- {!selectedSessionId && (
- }
- onClick={() => setIsSpendLogsSettingsModalVisible(true)}
- title="Spend Logs Settings"
- />
-
- )}
+ Request Logs
+ }
+ onClick={() => setIsSpendLogsSettingsModalVisible(true)}
+ title="Spend Logs Settings"
+ />
{selectedKeyInfo && selectedKeyIdInfoView && selectedKeyInfo.api_key === selectedKeyIdInfoView ? (
setSelectedKeyIdInfoView(null)}
backButtonText="Back to Logs"
/>
- ) : selectedSessionId ? (
-
-
-
) : (
<>
setIsSpendLogsSettingsModalVisible(true)}
allLogs={filteredData}
onSelectLog={handleSelectLog}
diff --git a/ui/litellm-dashboard/src/components/view_logs/table.tsx b/ui/litellm-dashboard/src/components/view_logs/table.tsx
index fb7706cba1..77cb273d4f 100644
--- a/ui/litellm-dashboard/src/components/view_logs/table.tsx
+++ b/ui/litellm-dashboard/src/components/view_logs/table.tsx
@@ -7,8 +7,10 @@ interface DataTableProps {
data: TData[];
columns: ColumnDef[];
onRowClick?: (row: TData) => void;
- // Legacy props for backward compatibility (audit logs)
+ /** Renders inside a single colspan cell (used by audit logs) */
renderSubComponent?: (props: { row: Row }) => React.ReactElement;
+ /** Renders directly in tbody as sibling table rows (used by MCP children) */
+ renderChildRows?: (props: { row: Row }) => React.ReactNode;
getRowCanExpand?: (row: Row) => boolean;
isLoading?: boolean;
loadingMessage?: string;
@@ -20,24 +22,24 @@ export function DataTable({
columns,
onRowClick,
renderSubComponent,
+ renderChildRows,
getRowCanExpand,
isLoading = false,
loadingMessage = "🚅 Loading logs...",
noDataMessage = "No logs found",
}: DataTableProps) {
- // Determine if we're in legacy expansion mode or new drawer mode
- const isLegacyMode = !!renderSubComponent && !!getRowCanExpand;
+ const supportsExpansion = !!(renderSubComponent || renderChildRows) && !!getRowCanExpand;
const table = useReactTable({
data,
columns,
- ...(isLegacyMode && { getRowCanExpand }),
+ ...(supportsExpansion && { getRowCanExpand }),
getRowId: (row: TData, index: number) => {
const _row: any = row as any;
return _row?.request_id ?? String(index);
},
getCoreRowModel: getCoreRowModel(),
- ...(isLegacyMode && { getExpandedRowModel: getExpandedRowModel() }),
+ ...(supportsExpansion && { getExpandedRowModel: getExpandedRowModel() }),
});
return (
@@ -69,8 +71,8 @@ export function DataTable({
table.getRowModel().rows.map((row) => (
!isLegacyMode && onRowClick?.(row.original)}
+ className={`h-8 ${onRowClick ? "cursor-pointer hover:bg-gray-50" : ""}`}
+ onClick={() => onRowClick?.(row.original)}
>
{row.getVisibleCells().map((cell) => (
@@ -79,8 +81,13 @@ export function DataTable({
))}
- {/* Legacy expansion mode for audit logs */}
- {isLegacyMode && row.getIsExpanded() && renderSubComponent && (
+ {/* Child rows rendered as real table rows (MCP children) */}
+ {supportsExpansion && row.getIsExpanded() && renderChildRows && (
+ renderChildRows({ row })
+ )}
+
+ {/* Legacy sub-component in colspan cell (audit logs) */}
+ {supportsExpansion && row.getIsExpanded() && renderSubComponent && !renderChildRows && (
{renderSubComponent({ row })}
diff --git a/ui/litellm-dashboard/src/components/view_logs/utils.ts b/ui/litellm-dashboard/src/components/view_logs/utils.ts
new file mode 100644
index 0000000000..a46aeccc70
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/view_logs/utils.ts
@@ -0,0 +1,20 @@
+import { MCP_CALL_TYPES } from "./constants";
+
+/**
+ * Derive a short, human-readable display name for a log entry.
+ * Strips provider prefixes, date suffixes, and version tags.
+ */
+export function getEventDisplayName(callType: string, model: string): string {
+ const raw = (model || "").trim();
+ const isMcp = MCP_CALL_TYPES.includes(callType);
+
+ if (isMcp) {
+ return raw.replace(/^mcp:\s*/i, "").split("/").pop() || raw || "mcp_tool";
+ }
+
+ const lastSegment = raw.split("/").pop() || raw;
+ const noSuffix = lastSegment.replace(/-20\d{6}.*$/i, "").replace(/:.*$/, "");
+ const claudeMatch = noSuffix.match(/claude-[a-z0-9-]+/i);
+ if (claudeMatch) return claudeMatch[0];
+ return noSuffix || "llm_call";
+}