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 ( + + ); +} + /** * 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 ? ( + +
+ + +
+ {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}

-
- - 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} - - - ) : ( - "Request Logs" - )} -

- {!selectedSessionId && ( -
{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"; +}