From 52c9cf6058998816f82e7f7c5c61bd88fb86367e Mon Sep 17 00:00:00 2001 From: Murali Date: Fri, 6 Feb 2026 14:50:24 -0500 Subject: [PATCH 1/5] fix(proxy): skip premium check for empty metadata fields on team/key update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #20534 The UI sends the full form on every team update, including premium metadata fields like `policies: []` and `team_member_key_duration: ""`. The backend's `_update_metadata_fields` treated any non-None value as premium feature usage and returned 403 for non-enterprise users — even when the fields were empty and the user was just updating basic settings like team name or budget. Added `_has_non_empty_value` helper and use it in the premium field gate in `_update_metadata_fields` so empty lists, blank strings, and None values skip the premium check entirely. Non-empty values still enforce the enterprise requirement as before. --- .../management_endpoints/common_utils.py | 13 +- .../management_endpoints/test_common_utils.py | 159 ++++++++++++++++++ 2 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 tests/litellm/proxy/management_endpoints/test_common_utils.py diff --git a/litellm/proxy/management_endpoints/common_utils.py b/litellm/proxy/management_endpoints/common_utils.py index 8f7dd4f8dfa..0fa943d96e8 100644 --- a/litellm/proxy/management_endpoints/common_utils.py +++ b/litellm/proxy/management_endpoints/common_utils.py @@ -227,6 +227,17 @@ def _update_metadata_field(updated_kv: dict, field_name: str) -> None: updated_kv["metadata"] = {field_name: _value} +def _has_non_empty_value(value: Any) -> bool: + """Check if a value has real content (not None, not empty list, not blank string).""" + if value is None: + return False + if isinstance(value, list) and len(value) == 0: + return False + if isinstance(value, str) and value.strip() == "": + return False + return True + + def _update_metadata_fields(updated_kv: dict) -> None: """ Helper function to update all metadata fields (both premium and standard). @@ -235,7 +246,7 @@ def _update_metadata_fields(updated_kv: dict) -> None: updated_kv: The key-value dict being used for the update """ for field in LiteLLM_ManagementEndpoint_MetadataFields_Premium: - if field in updated_kv and updated_kv[field] is not None: + if field in updated_kv and _has_non_empty_value(updated_kv[field]): _update_metadata_field(updated_kv=updated_kv, field_name=field) for field in LiteLLM_ManagementEndpoint_MetadataFields: diff --git a/tests/litellm/proxy/management_endpoints/test_common_utils.py b/tests/litellm/proxy/management_endpoints/test_common_utils.py new file mode 100644 index 00000000000..f857db770d0 --- /dev/null +++ b/tests/litellm/proxy/management_endpoints/test_common_utils.py @@ -0,0 +1,159 @@ +""" +Tests for litellm/proxy/management_endpoints/common_utils.py + +Specifically tests that _update_metadata_fields does not trigger premium +user checks when premium fields are present but empty. + +Related: https://github.com/BerriAI/litellm/issues/20534 +""" + +from unittest.mock import patch + +import pytest + +from litellm.proxy.management_endpoints.common_utils import ( + _has_non_empty_value, + _update_metadata_fields, +) + + +class TestHasNonEmptyValue: + """Tests for the _has_non_empty_value helper.""" + + def test_none_is_empty(self): + assert _has_non_empty_value(None) is False + + def test_empty_list_is_empty(self): + assert _has_non_empty_value([]) is False + + def test_empty_string_is_empty(self): + assert _has_non_empty_value("") is False + + def test_blank_string_is_empty(self): + assert _has_non_empty_value(" ") is False + + def test_non_empty_list_has_value(self): + assert _has_non_empty_value(["policy-a"]) is True + + def test_non_empty_string_has_value(self): + assert _has_non_empty_value("30d") is True + + def test_dict_has_value(self): + assert _has_non_empty_value({"key": "val"}) is True + + def test_empty_dict_has_value(self): + # empty dict is not None/list/str, so it counts as non-empty + assert _has_non_empty_value({}) is True + + +class TestUpdateMetadataFieldsPremiumCheck: + """ + Tests that _update_metadata_fields skips premium user checks for empty + values but still enforces them for real values. + + Issue: The UI sends the full form on every team update, including premium + fields like `policies: []`. The backend was treating these empty values + as premium feature usage and returning 403. + """ + + @patch( + "litellm.proxy.management_endpoints.common_utils._premium_user_check", + side_effect=Exception("Should not be called"), + ) + def test_empty_policies_skips_premium_check(self, mock_check): + """policies: [] should NOT trigger premium user check.""" + updated_kv = { + "team_id": "team-123", + "team_alias": "my-team", + "policies": [], + } + _update_metadata_fields(updated_kv) + mock_check.assert_not_called() + + @patch( + "litellm.proxy.management_endpoints.common_utils._premium_user_check", + side_effect=Exception("Should not be called"), + ) + def test_empty_guardrails_skips_premium_check(self, mock_check): + """guardrails: [] should NOT trigger premium user check.""" + updated_kv = { + "team_id": "team-123", + "guardrails": [], + } + _update_metadata_fields(updated_kv) + mock_check.assert_not_called() + + @patch( + "litellm.proxy.management_endpoints.common_utils._premium_user_check", + side_effect=Exception("Should not be called"), + ) + def test_empty_string_team_member_key_duration_skips_premium_check( + self, mock_check + ): + """team_member_key_duration: '' should NOT trigger premium user check.""" + updated_kv = { + "team_id": "team-123", + "team_member_key_duration": "", + } + _update_metadata_fields(updated_kv) + mock_check.assert_not_called() + + @patch( + "litellm.proxy.management_endpoints.common_utils._premium_user_check", + side_effect=Exception("Should not be called"), + ) + def test_full_ui_payload_with_empty_premium_fields_skips_premium_check( + self, mock_check + ): + """A realistic UI payload with all empty premium fields should not 403.""" + updated_kv = { + "team_id": "team-123", + "team_alias": "renamed-team", + "models": ["gpt-4o"], + "max_budget": 200, + "policies": [], + "guardrails": [], + "logging": [], + "team_member_key_duration": "", + "prompts": [], + } + _update_metadata_fields(updated_kv) + mock_check.assert_not_called() + + @patch( + "litellm.proxy.management_endpoints.common_utils._premium_user_check", + ) + def test_non_empty_policies_triggers_premium_check(self, mock_check): + """policies: ['real-policy'] SHOULD trigger premium user check.""" + updated_kv = { + "team_id": "team-123", + "policies": ["real-policy"], + } + _update_metadata_fields(updated_kv) + mock_check.assert_called() + + @patch( + "litellm.proxy.management_endpoints.common_utils._premium_user_check", + ) + def test_non_empty_guardrails_triggers_premium_check(self, mock_check): + """guardrails: ['my-guardrail'] SHOULD trigger premium user check.""" + updated_kv = { + "team_id": "team-123", + "guardrails": ["my-guardrail"], + } + _update_metadata_fields(updated_kv) + mock_check.assert_called() + + @patch( + "litellm.proxy.management_endpoints.common_utils._premium_user_check", + ) + def test_non_empty_team_member_key_duration_triggers_premium_check( + self, mock_check + ): + """team_member_key_duration: '30d' SHOULD trigger premium user check.""" + updated_kv = { + "team_id": "team-123", + "team_member_key_duration": "30d", + } + _update_metadata_fields(updated_kv) + mock_check.assert_called() From 3d9b145b04c257810e302e266c2208dee39abae5 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Thu, 12 Feb 2026 10:00:30 -0800 Subject: [PATCH 2/5] [Feat] Adds support for server-side compaction on the OpenAI Responses API `context_management` (#21058) * test_responses_api_context_management_server_side_compaction * Server-side compaction * docs fix * test_responses_api_shell_tool --- docs/my-website/docs/response_api.md | 70 ++++++++++++ litellm/types/llms/openai.py | 15 +++ .../base_responses_api.py | 41 ++++++- .../context_management_and_shell.json | 20 ++++ .../test_responses_api_request_body.py | 103 ++++++++++++++++++ 5 files changed, 247 insertions(+), 2 deletions(-) create mode 100644 tests/test_litellm/expected_responses_api_request/context_management_and_shell.json create mode 100644 tests/test_litellm/responses/test_responses_api_request_body.py diff --git a/docs/my-website/docs/response_api.md b/docs/my-website/docs/response_api.md index 140dfd4faf8..49ac78b52ed 100644 --- a/docs/my-website/docs/response_api.md +++ b/docs/my-website/docs/response_api.md @@ -1023,6 +1023,76 @@ curl http://localhost:4000/v1/responses \ +## Server-side compaction + +For long-running conversations, you can enable **server-side compaction** so that when the rendered context size crosses a threshold, the server automatically runs compaction in-stream and emits a compaction item—no separate `POST /v1/responses/compact` call is required. + +Supported on the OpenAI Responses API when using the `openai` or `azure` provider. Pass `context_management` with a compaction entry and `compact_threshold` (token count; minimum 1000). When the context crosses the threshold, the server compacts in-stream and continues. Chain turns with `previous_response_id` or by appending output items to your next input array. See [OpenAI Compaction guide](https://developers.openai.com/api/docs/guides/compaction) for details. + +For explicit control over when compaction runs, use the standalone compact endpoint (`POST /v1/responses/compact`) instead. + +### Python SDK + +```python showLineNumbers title="Server-side compaction with LiteLLM Python SDK" +import litellm + +# Non-streaming: enable compaction when context exceeds 200k tokens +response = litellm.responses( + model="openai/gpt-4o", + input="Your conversation input...", + context_management=[{"type": "compaction", "compact_threshold": 200000}], + max_output_tokens=1024, +) +print(response) + +# Streaming: same context_management, compaction runs in-stream if threshold is crossed +stream = litellm.responses( + model="openai/gpt-4o", + input="Your conversation input...", + context_management=[{"type": "compaction", "compact_threshold": 200000}], + stream=True, +) +for event in stream: + print(event) +``` + +### LiteLLM Proxy (AI Gateway) + +Use the OpenAI SDK with your proxy as `base_url`, or call the proxy with curl. The proxy forwards `context_management` to the provider. + +**OpenAI Python SDK (proxy as base_url):** + +```python showLineNumbers title="Server-side compaction via LiteLLM Proxy" +from openai import OpenAI + +client = OpenAI( + base_url="http://localhost:4000", # LiteLLM Proxy (AI Gateway) + api_key="your-proxy-api-key", +) + +response = client.responses.create( + model="openai/gpt-4o", + input="Your conversation input...", + context_management=[{"type": "compaction", "compact_threshold": 200000}], + max_output_tokens=1024, +) +print(response) +``` + +**curl (proxy):** + +```bash title="Server-side compaction via curl to LiteLLM Proxy" +curl -X POST "http://localhost:4000/v1/responses" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer your-proxy-api-key" \ + -d '{ + "model": "openai/gpt-4o", + "input": "Your conversation input...", + "context_management": [{"type": "compaction", "compact_threshold": 200000}], + "max_output_tokens": 1024 + }' +``` + ## Session Management LiteLLM Proxy supports session management for all supported models. This allows you to store and fetch conversation history (state) in LiteLLM Proxy. diff --git a/litellm/types/llms/openai.py b/litellm/types/llms/openai.py index 299b47199ed..6925e2327c6 100644 --- a/litellm/types/llms/openai.py +++ b/litellm/types/llms/openai.py @@ -1074,6 +1074,19 @@ class PromptObject(TypedDict, total=False): """Optional version of the prompt template.""" +class ContextManagementEntry(TypedDict, total=False): + """ + Context management configuration entry for a request. + See https://developers.openai.com/api/docs/guides/compaction. + """ + + type: str + """The context management entry type. Currently only ``'compaction'`` is supported.""" + + compact_threshold: int + """Token threshold at which compaction is triggered for this entry. Minimum 1000.""" + + class ResponsesAPIOptionalRequestParams(TypedDict, total=False): """TypedDict for Optional parameters supported by the responses API.""" @@ -1104,6 +1117,8 @@ class ResponsesAPIOptionalRequestParams(TypedDict, total=False): partial_images: Optional[ int ] # Number of partial images to generate (1-3) for streaming image generation + context_management: Optional[List[ContextManagementEntry]] + """Context management configuration. E.g. [{\"type\": \"compaction\", \"compact_threshold\": 200000}] for server-side compaction (minimum 1000).""" class ResponsesAPIRequestParams(ResponsesAPIOptionalRequestParams, total=False): diff --git a/tests/llm_responses_api_testing/base_responses_api.py b/tests/llm_responses_api_testing/base_responses_api.py index 37ed1a9b08c..b6bcc1664a2 100644 --- a/tests/llm_responses_api_testing/base_responses_api.py +++ b/tests/llm_responses_api_testing/base_responses_api.py @@ -669,7 +669,7 @@ class BaseResponsesAPITest(ABC): async def test_cancel_responses_invalid_response_id(self, sync_mode): """Test cancel_responses with invalid response ID should raise appropriate error""" base_completion_call_args = self.get_base_completion_call_args() - + if sync_mode: with pytest.raises(Exception): litellm.cancel_responses( @@ -679,4 +679,41 @@ class BaseResponsesAPITest(ABC): with pytest.raises(Exception): await litellm.acancel_responses( response_id="invalid_response_id_12345", **base_completion_call_args - ) \ No newline at end of file + ) + + @pytest.mark.parametrize("sync_mode", [True, False]) + @pytest.mark.asyncio + async def test_responses_api_context_management_server_side_compaction(self, sync_mode): + """ + E2E test for server-side compaction (context_management) on OpenAI Responses API. + Passes context_management with compact_threshold; validates that the request is + accepted and returns a valid response. Compaction may not run for short inputs. + """ + base_completion_call_args = self.get_base_completion_call_args() + model = base_completion_call_args.get("model") or "" + # Only run with context_management for OpenAI (OAI) for now + if "openai/" not in str(model) and "azure/" not in str(model): + pytest.skip( + "context_management server-side compaction e2e is only run for OpenAI/Azure" + ) + context_management = [{"type": "compaction", "compact_threshold": 200000}] + try: + if sync_mode: + response = litellm.responses( + input="Short ping to verify context_management is accepted.", + max_output_tokens=20, + context_management=context_management, + **base_completion_call_args, + ) + else: + response = await litellm.aresponses( + input="Short ping to verify context_management is accepted.", + max_output_tokens=20, + context_management=context_management, + **base_completion_call_args, + ) + except litellm.InternalServerError: + pytest.skip("Skipping test due to litellm.InternalServerError") + validate_responses_api_response(response, final_chunk=True) + assert response.get("id") is not None + assert response.get("status") is not None \ No newline at end of file diff --git a/tests/test_litellm/expected_responses_api_request/context_management_and_shell.json b/tests/test_litellm/expected_responses_api_request/context_management_and_shell.json new file mode 100644 index 00000000000..1e34b230182 --- /dev/null +++ b/tests/test_litellm/expected_responses_api_request/context_management_and_shell.json @@ -0,0 +1,20 @@ +{ + "model": "gpt-4o", + "input": "List files in /mnt/data and run python --version.", + "context_management": [ + { + "type": "compaction", + "compact_threshold": 200000 + } + ], + "tools": [ + { + "type": "shell", + "environment": { + "type": "container_auto" + } + } + ], + "tool_choice": "auto", + "max_output_tokens": 256 +} diff --git a/tests/test_litellm/responses/test_responses_api_request_body.py b/tests/test_litellm/responses/test_responses_api_request_body.py new file mode 100644 index 00000000000..9c20d630a1b --- /dev/null +++ b/tests/test_litellm/responses/test_responses_api_request_body.py @@ -0,0 +1,103 @@ +""" +Test that litellm.responses() / litellm.aresponses() send the expected request body +over the wire. Expected JSON bodies are stored in expected_responses_api_request/. +""" +import json +from pathlib import Path +from unittest.mock import AsyncMock, patch + +import httpx +import pytest + +import litellm + + +def _expected_dir() -> Path: + """Path to expected_responses_api_request folder (sibling of test_litellm/responses).""" + return Path(__file__).resolve().parent.parent / "expected_responses_api_request" + + +@pytest.mark.asyncio +async def test_aresponses_context_management_and_shell_request_body_matches_expected(): + """ + Call litellm.aresponses() with context_management and shell tool; + assert the httpx POST request body matches the expected JSON. + """ + expected_path = _expected_dir() / "context_management_and_shell.json" + assert expected_path.exists(), f"Expected file not found: {expected_path}" + with open(expected_path) as f: + expected_body = json.load(f) + + # Minimal Responses API response so parsing succeeds + mock_response = { + "id": "resp_ctx_shell_test", + "object": "response", + "created_at": 1734366691, + "status": "completed", + "model": "gpt-4o", + "output": [ + { + "type": "message", + "id": "msg_1", + "status": "completed", + "role": "assistant", + "content": [ + {"type": "output_text", "text": "Done.", "annotations": []} + ], + } + ], + "parallel_tool_calls": True, + "usage": { + "input_tokens": 10, + "output_tokens": 5, + "total_tokens": 15, + "output_tokens_details": {"reasoning_tokens": 0}, + }, + "error": None, + "incomplete_details": None, + "instructions": None, + "metadata": None, + "temperature": None, + "tool_choice": "auto", + "tools": [], + "top_p": None, + "max_output_tokens": None, + "previous_response_id": None, + "reasoning": None, + "truncation": None, + "user": None, + } + + class MockResponse: + def __init__(self, json_data, status_code=200): + self._json_data = json_data + self.status_code = status_code + self.text = json.dumps(json_data) + self.headers = httpx.Headers({}) + + def json(self): + return self._json_data + + with patch( + "litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post", + new_callable=AsyncMock, + ) as mock_post: + mock_post.return_value = MockResponse(mock_response, 200) + + await litellm.aresponses( + model="openai/gpt-4o", + input=expected_body["input"], + context_management=expected_body["context_management"], + tools=expected_body["tools"], + tool_choice=expected_body["tool_choice"], + max_output_tokens=expected_body["max_output_tokens"], + ) + + mock_post.assert_called_once() + request_body = mock_post.call_args.kwargs["json"] + + for key, expected_value in expected_body.items(): + assert key in request_body, f"Missing key in request body: {key}" + assert request_body[key] == expected_value, ( + f"Mismatch for key {key}: got {request_body[key]!r}, expected {expected_value!r}" + ) From 89565c97ccde3d61d60c8f42b2caaed0883e3eae Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Thu, 12 Feb 2026 10:02:03 -0800 Subject: [PATCH 3/5] [Feat] AI Gateway - Add Tracing for MCP Calls running through AI Gateway (#21018) * commit new expansion * fix MCP * fix: LiteLLMProxyRequestSetup * _process_mcp_tools_without_openai_transform * UI fixes * UI refactor view logs/sessions * index * _add_mcp_tool_metadata_to_final_chunk * add badges * add getEventDisplayName * ui fixes * backend fix * fix * UI fix * UI fix * fix row * fix: address Greptile review feedback on PR #21018 (#21057) - Fix session time range calculation: use Math.min/Math.max across all entries instead of relying on array order (sessionLogs is sorted by type, not time). Other Greptile comments were already addressed in the branch: - LogDetailContent.tsx exists - Clipboard call already wrapped in try/catch - Dedup already uses O(1) Map lookup - model_dump() serialization is documented - GROUP BY performance comment already present --------- Co-authored-by: shin-bot-litellm --- .../proxy/_experimental/mcp_server/server.py | 18 +- .../spend_management_endpoints.py | 97 ++- litellm/responses/main.py | 1 + .../responses/mcp/chat_completions_handler.py | 11 +- .../mcp/litellm_proxy_mcp_handler.py | 30 +- .../LogDetailsDrawer/LogDetailContent.tsx | 442 +++++++++++ .../LogDetailsDrawer/LogDetailsDrawer.tsx | 735 +++++++----------- .../view_logs/LogDetailsDrawer/index.ts | 2 + .../src/components/view_logs/SessionView.tsx | 193 ----- .../src/components/view_logs/TypeBadges.tsx | 30 + .../src/components/view_logs/columns.tsx | 65 +- .../src/components/view_logs/constants.ts | 3 + .../src/components/view_logs/index.tsx | 171 ++-- .../src/components/view_logs/table.tsx | 25 +- .../src/components/view_logs/utils.ts | 20 + 15 files changed, 1075 insertions(+), 768 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.tsx delete mode 100644 ui/litellm-dashboard/src/components/view_logs/SessionView.tsx create mode 100644 ui/litellm-dashboard/src/components/view_logs/TypeBadges.tsx create mode 100644 ui/litellm-dashboard/src/components/view_logs/utils.ts diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index 58cd8c99e7b..fa28b6070b3 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 afbc57360e2..ed8c9a76f60 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 8f524690be1..7105f1ae6fb 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 4a640a61064..377ce396457 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 297ccf4355e..805a1958552 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 00000000000..ef5de9febc3 --- /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 a3f948296eb..8d087fed708 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 e1fdd9d2d60..c2839df27ef 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 ce77ad14a1b..00000000000 --- 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 00000000000..e4195ece9ec --- /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 3e72c8e13b8..452d31aed51 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 84862fa4632..949dab275fe 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 b43ff5220db..a247a1be4a3 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 fb7706cba19..77cb273d4fe 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 00000000000..a46aeccc707 --- /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"; +} From 5152bf4f4ec5001e81fd6d4f57939d97ef70c550 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Thu, 12 Feb 2026 10:34:17 -0800 Subject: [PATCH 4/5] =?UTF-8?q?bump:=20version=200.4.34=20=E2=86=92=200.4.?= =?UTF-8?q?35?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- litellm-proxy-extras/pyproject.toml | 4 ++-- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/litellm-proxy-extras/pyproject.toml b/litellm-proxy-extras/pyproject.toml index e0a769a5edf..ceeb8c8bb49 100644 --- a/litellm-proxy-extras/pyproject.toml +++ b/litellm-proxy-extras/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "litellm-proxy-extras" -version = "0.4.34" +version = "0.4.35" description = "Additional files for the LiteLLM Proxy. Reduces the size of the main litellm package." authors = ["BerriAI"] readme = "README.md" @@ -22,7 +22,7 @@ requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" [tool.commitizen] -version = "0.4.34" +version = "0.4.35" version_files = [ "pyproject.toml:version", "../requirements.txt:litellm-proxy-extras==", diff --git a/pyproject.toml b/pyproject.toml index acb8bc2ada3..8ba7119ec5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,7 +61,7 @@ boto3 = { version = "1.40.76", optional = true } redisvl = {version = "^0.4.1", optional = true, markers = "python_version >= '3.9' and python_version < '3.14'"} mcp = {version = ">=1.25.0,<2.0.0", optional = true, python = ">=3.10"} a2a-sdk = {version = "^0.3.22", optional = true, python = ">=3.10"} -litellm-proxy-extras = {version = "0.4.34", optional = true} +litellm-proxy-extras = {version = "0.4.35", optional = true} rich = {version = "13.7.1", optional = true} litellm-enterprise = {version = "0.1.31", optional = true} diskcache = {version = "^5.6.1", optional = true} diff --git a/requirements.txt b/requirements.txt index bd313b105e5..4db86054905 100644 --- a/requirements.txt +++ b/requirements.txt @@ -55,7 +55,7 @@ sentry_sdk==2.21.0 # for sentry error handling detect-secrets==1.5.0 # Enterprise - secret detection / masking in LLM requests cryptography==44.0.1 tzdata==2025.1 # IANA time zone database -litellm-proxy-extras==0.4.34 # for proxy extras - e.g. prisma migrations +litellm-proxy-extras==0.4.35 # for proxy extras - e.g. prisma migrations llm-sandbox==0.3.31 # for skill execution in sandbox ### LITELLM PACKAGE DEPENDENCIES python-dotenv==1.0.1 # for env From 5147515d785b154d556e8473c50c307b834d1f77 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Thu, 12 Feb 2026 10:34:59 -0800 Subject: [PATCH 5/5] add migration + build files --- ...litellm_proxy_extras-0.4.35-py3-none-any.whl | Bin 0 -> 53616 bytes .../dist/litellm_proxy_extras-0.4.35.tar.gz | Bin 0 -> 24378 bytes .../migration.sql | 3 +++ 3 files changed, 3 insertions(+) create mode 100644 litellm-proxy-extras/dist/litellm_proxy_extras-0.4.35-py3-none-any.whl create mode 100644 litellm-proxy-extras/dist/litellm_proxy_extras-0.4.35.tar.gz create mode 100644 litellm-proxy-extras/litellm_proxy_extras/migrations/20260212103349_adjust_tags_policy_table/migration.sql diff --git a/litellm-proxy-extras/dist/litellm_proxy_extras-0.4.35-py3-none-any.whl b/litellm-proxy-extras/dist/litellm_proxy_extras-0.4.35-py3-none-any.whl new file mode 100644 index 0000000000000000000000000000000000000000..8a443f38ef5e31df9708d7df5ef42da3f378c79d GIT binary patch literal 53616 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 literal 0 HcmV?d00001 diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260212103349_adjust_tags_policy_table/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260212103349_adjust_tags_policy_table/migration.sql new file mode 100644 index 00000000000..f3a0821d37f --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260212103349_adjust_tags_policy_table/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "LiteLLM_PolicyAttachmentTable" ADD COLUMN "tags" TEXT[] DEFAULT ARRAY[]::TEXT[]; +