From 53a1e31729b105cb61decd359031319ae0205c10 Mon Sep 17 00:00:00 2001 From: Krish Dholakia Date: Thu, 5 Mar 2026 16:58:46 -0800 Subject: [PATCH 1/6] feat(spend-logs): add truncation note when error logs are truncated for DB storage (#22936) When the messages or response JSON fields in spend logs are truncated before being written to the database, the truncation marker now includes a note explaining: - This is a DB storage safeguard - Full, untruncated data is still sent to logging callbacks (OTEL, Datadog, etc.) - The MAX_STRING_LENGTH_PROMPT_IN_DB env var can be used to increase the limit Also emits a verbose_proxy_logger.info message when truncation occurs in the request body or response spend log paths. Adds 3 new tests: - test_truncation_includes_db_safeguard_note - test_response_truncation_logs_info_message - test_request_body_truncation_logs_info_message Co-authored-by: Cursor Agent --- litellm/constants.py | 5 ++ .../spend_tracking/spend_tracking_utils.py | 28 +++++- .../test_spend_tracking_utils.py | 86 +++++++++++++++++-- 3 files changed, 110 insertions(+), 9 deletions(-) diff --git a/litellm/constants.py b/litellm/constants.py index c1bb7da1b73..2ae365300ef 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -1242,6 +1242,11 @@ LITELLM_METADATA_FIELD = "litellm_metadata" OLD_LITELLM_METADATA_FIELD = "metadata" LITELLM_TRUNCATED_PAYLOAD_FIELD = "litellm_truncated" +LITELLM_TRUNCATION_DB_SAFEGUARD_NOTE = ( + "Truncation is a DB storage safeguard. " + "Full, untruncated data is logged to logging callbacks (OTEL, Datadog, etc.). " + "To increase the truncation limit, set `MAX_STRING_LENGTH_PROMPT_IN_DB` in your env." +) ########################### LiteLLM Proxy Specific Constants ########################### ######################################################################################## diff --git a/litellm/proxy/spend_tracking/spend_tracking_utils.py b/litellm/proxy/spend_tracking/spend_tracking_utils.py index 131841f7b59..f381432a089 100644 --- a/litellm/proxy/spend_tracking/spend_tracking_utils.py +++ b/litellm/proxy/spend_tracking/spend_tracking_utils.py @@ -11,6 +11,10 @@ import litellm from litellm._logging import verbose_proxy_logger +from litellm.constants import ( + LITELLM_TRUNCATED_PAYLOAD_FIELD, + LITELLM_TRUNCATION_DB_SAFEGUARD_NOTE, +) from litellm.constants import \ MAX_STRING_LENGTH_PROMPT_IN_DB as DEFAULT_MAX_STRING_LENGTH_PROMPT_IN_DB from litellm.constants import REDACTED_BY_LITELM_STRING @@ -628,7 +632,10 @@ def _sanitize_request_body_for_spend_logs_payload( Recursively sanitize request body to prevent logging large base64 strings or other large values. Truncates strings longer than MAX_STRING_LENGTH_PROMPT_IN_DB characters and handles nested dictionaries. """ - from litellm.constants import LITELLM_TRUNCATED_PAYLOAD_FIELD + from litellm.constants import ( + LITELLM_TRUNCATED_PAYLOAD_FIELD, + LITELLM_TRUNCATION_DB_SAFEGUARD_NOTE, + ) if visited is None: visited = set() @@ -674,7 +681,8 @@ def _sanitize_value(value: Any) -> Any: # Build the truncated string: beginning + truncation marker + end truncated_value = ( f"{value[:start_chars]}" - f"... ({LITELLM_TRUNCATED_PAYLOAD_FIELD} skipped {skipped_chars} chars) ..." + f"... ({LITELLM_TRUNCATED_PAYLOAD_FIELD} skipped {skipped_chars} chars. " + f"{LITELLM_TRUNCATION_DB_SAFEGUARD_NOTE}) ..." f"{value[-end_chars:]}" ) return truncated_value @@ -791,6 +799,11 @@ def _get_proxy_server_request_for_spend_logs_payload( _request_body = _sanitize_request_body_for_spend_logs_payload(_request_body) _request_body_json_str = json.dumps(_request_body, default=str) + if LITELLM_TRUNCATED_PAYLOAD_FIELD in _request_body_json_str: + verbose_proxy_logger.info( + "Spend Log: request body was truncated before storing in DB. %s", + LITELLM_TRUNCATION_DB_SAFEGUARD_NOTE, + ) return _request_body_json_str return "{}" @@ -866,8 +879,15 @@ def _get_response_for_spend_logs_payload( if sanitized_response is None: return "{}" if isinstance(sanitized_response, str): - return sanitized_response - return safe_dumps(sanitized_response) + result_str = sanitized_response + else: + result_str = safe_dumps(sanitized_response) + if LITELLM_TRUNCATED_PAYLOAD_FIELD in result_str: + verbose_proxy_logger.info( + "Spend Log: response was truncated before storing in DB. %s", + LITELLM_TRUNCATION_DB_SAFEGUARD_NOTE, + ) + return result_str return "{}" diff --git a/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py b/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py index 24f45cc5c91..9a64e641b5e 100644 --- a/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py +++ b/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py @@ -16,7 +16,11 @@ from unittest.mock import AsyncMock, MagicMock, patch import litellm -from litellm.constants import LITELLM_TRUNCATED_PAYLOAD_FIELD, REDACTED_BY_LITELM_STRING +from litellm.constants import ( + LITELLM_TRUNCATED_PAYLOAD_FIELD, + LITELLM_TRUNCATION_DB_SAFEGUARD_NOTE, + REDACTED_BY_LITELM_STRING, +) from litellm.litellm_core_utils.safe_json_dumps import safe_dumps from litellm.proxy.spend_tracking.spend_tracking_utils import ( _get_messages_for_spend_logs_payload, @@ -60,7 +64,7 @@ def test_sanitize_request_body_for_spend_logs_payload_long_string(): end_chars = MAX_STRING_LENGTH_PROMPT_IN_DB - start_chars skipped_chars = len(long_string) - (start_chars + end_chars) - expected_truncation_message = f"... ({LITELLM_TRUNCATED_PAYLOAD_FIELD} skipped {skipped_chars} chars) ..." + expected_truncation_message = f"... ({LITELLM_TRUNCATED_PAYLOAD_FIELD} skipped {skipped_chars} chars. {LITELLM_TRUNCATION_DB_SAFEGUARD_NOTE}) ..." expected_length = start_chars + len(expected_truncation_message) + end_chars assert len(sanitized["text"]) == expected_length @@ -86,7 +90,7 @@ def test_sanitize_request_body_for_spend_logs_payload_nested_dict(): end_chars = MAX_STRING_LENGTH_PROMPT_IN_DB - start_chars skipped_chars = len(long_string) - total_keep - expected_truncation_message = f"... ({LITELLM_TRUNCATED_PAYLOAD_FIELD} skipped {skipped_chars} chars) ..." + expected_truncation_message = f"... ({LITELLM_TRUNCATED_PAYLOAD_FIELD} skipped {skipped_chars} chars. {LITELLM_TRUNCATION_DB_SAFEGUARD_NOTE}) ..." expected_length = start_chars + len(expected_truncation_message) + end_chars assert len(sanitized["outer"]["inner"]["text"]) == expected_length @@ -111,7 +115,7 @@ def test_sanitize_request_body_for_spend_logs_payload_nested_list(): end_chars = MAX_STRING_LENGTH_PROMPT_IN_DB - start_chars skipped_chars = len(long_string) - total_keep - expected_truncation_message = f"... ({LITELLM_TRUNCATED_PAYLOAD_FIELD} skipped {skipped_chars} chars) ..." + expected_truncation_message = f"... ({LITELLM_TRUNCATED_PAYLOAD_FIELD} skipped {skipped_chars} chars. {LITELLM_TRUNCATION_DB_SAFEGUARD_NOTE}) ..." expected_length = start_chars + len(expected_truncation_message) + end_chars assert len(sanitized["items"][0]["text"]) == expected_length @@ -151,7 +155,7 @@ def test_sanitize_request_body_for_spend_logs_payload_mixed_types(): end_chars = MAX_STRING_LENGTH_PROMPT_IN_DB - start_chars skipped_chars = len(long_string) - total_keep - expected_truncation_message = f"... ({LITELLM_TRUNCATED_PAYLOAD_FIELD} skipped {skipped_chars} chars) ..." + expected_truncation_message = f"... ({LITELLM_TRUNCATED_PAYLOAD_FIELD} skipped {skipped_chars} chars. {LITELLM_TRUNCATION_DB_SAFEGUARD_NOTE}) ..." expected_length = start_chars + len(expected_truncation_message) + end_chars assert len(sanitized["text"]) == expected_length @@ -396,6 +400,78 @@ def test_get_response_for_spend_logs_payload_truncates_large_embedding(mock_shou assert parsed["data"][0]["other_field"] == "value" +def test_truncation_includes_db_safeguard_note(): + """ + Test that truncated content includes the DB safeguard note explaining + that full data is available in OTEL/other logging integrations. + """ + from litellm.constants import MAX_STRING_LENGTH_PROMPT_IN_DB + + large_error = "Error: " + "x" * (MAX_STRING_LENGTH_PROMPT_IN_DB + 1000) + request_body = {"error_trace": large_error} + sanitized = _sanitize_request_body_for_spend_logs_payload(request_body) + + truncated = sanitized["error_trace"] + assert LITELLM_TRUNCATED_PAYLOAD_FIELD in truncated + assert LITELLM_TRUNCATION_DB_SAFEGUARD_NOTE in truncated + assert "DB storage safeguard" in truncated + assert "logging callbacks" in truncated.lower() or "logging integrations" in truncated.lower() or "logging callbacks" in truncated + + +@patch( + "litellm.proxy.spend_tracking.spend_tracking_utils._should_store_prompts_and_responses_in_spend_logs" +) +def test_response_truncation_logs_info_message(mock_should_store): + """ + Test that when response is truncated before DB storage, an info log is emitted + noting that full data is available in OTEL/other integrations. + """ + from litellm.constants import MAX_STRING_LENGTH_PROMPT_IN_DB + + mock_should_store.return_value = True + large_text = "B" * (MAX_STRING_LENGTH_PROMPT_IN_DB + 500) + payload = cast( + StandardLoggingPayload, + {"response": {"data": [{"content": large_text}]}}, + ) + + with patch( + "litellm.proxy.spend_tracking.spend_tracking_utils.verbose_proxy_logger" + ) as mock_logger: + _get_response_for_spend_logs_payload(payload) + mock_logger.info.assert_called_once() + log_msg = mock_logger.info.call_args[0][0] + assert "response was truncated" in log_msg + + +@patch( + "litellm.proxy.spend_tracking.spend_tracking_utils._should_store_prompts_and_responses_in_spend_logs" +) +def test_request_body_truncation_logs_info_message(mock_should_store): + """ + Test that when request body is truncated before DB storage, an info log is emitted. + """ + from litellm.constants import MAX_STRING_LENGTH_PROMPT_IN_DB + + mock_should_store.return_value = True + large_prompt = "C" * (MAX_STRING_LENGTH_PROMPT_IN_DB + 500) + litellm_params = { + "proxy_server_request": { + "body": {"messages": [{"role": "user", "content": large_prompt}]} + } + } + + with patch( + "litellm.proxy.spend_tracking.spend_tracking_utils.verbose_proxy_logger" + ) as mock_logger: + _get_proxy_server_request_for_spend_logs_payload( + metadata={}, litellm_params=litellm_params, kwargs={} + ) + mock_logger.info.assert_called_once() + log_msg = mock_logger.info.call_args[0][0] + assert "request body was truncated" in log_msg + + def test_safe_dumps_handles_circular_references(): """Test that safe_dumps can handle circular references without raising exceptions""" From 8d539db108dc55cca303e8f2c6757243e7dfaa1e Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Thu, 5 Mar 2026 17:36:46 -0800 Subject: [PATCH 2/6] Fix admin viewer unable to see all organizations The /organization/list endpoint only checked for PROXY_ADMIN role, causing PROXY_ADMIN_VIEW_ONLY users to fall into the else branch which restricts results to orgs the user is a member of. Use the existing _user_has_admin_view() helper to include both roles. --- litellm/proxy/management_endpoints/organization_endpoints.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/management_endpoints/organization_endpoints.py b/litellm/proxy/management_endpoints/organization_endpoints.py index 1c19c4ef313..103b2efcdde 100644 --- a/litellm/proxy/management_endpoints/organization_endpoints.py +++ b/litellm/proxy/management_endpoints/organization_endpoints.py @@ -649,8 +649,8 @@ async def list_organization( "mode": "insensitive", # Case-insensitive search } - # if proxy admin - get all orgs (with optional filters) - if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN: + # if proxy admin or admin viewer - get all orgs (with optional filters) + if _user_has_admin_view(user_api_key_dict): response = await prisma_client.db.litellm_organizationtable.find_many( where=where_conditions if where_conditions else None, include={"litellm_budget_table": True, "members": True, "teams": True}, From ec600aa70a06e3c0d92467472f5e75e474b79485 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Thu, 5 Mar 2026 18:13:04 -0800 Subject: [PATCH 3/6] =?UTF-8?q?feat(ui):=20add=20Chat=20UI=20=E2=80=94=20C?= =?UTF-8?q?hatGPT-like=20interface=20with=20MCP=20tools=20and=20streaming?= =?UTF-8?q?=20(#22937)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ui): add chat message and conversation types * feat(ui): add useChatHistory hook for localStorage-backed conversations * feat(ui): add ConversationList sidebar component * feat(ui): add MCPConnectPicker for attaching MCP servers to chat * feat(ui): add ModelSelector dropdown for chat * feat(ui): add ChatInputBar with MCP tool attachment support * feat(ui): add MCPAppsPanel with list/detail view for MCP servers * feat(ui): add ChatMessages component; remove auto-scrollIntoView that caused scroll-lock bypass * feat(ui): add ChatPage β€” ChatGPT-like UI with scroll lock, MCP tools, streaming * feat(ui): add /chat route wired to ChatPage * feat(ui): remove chat from leftnav β€” chat accessible via navbar button * feat(ui): add Chat button to top navbar * feat(ui): add dismissible Chat UI announcement banner to Playground page * feat(proxy): add Chat UI link to Swagger description * feat(ui): add react-markdown and syntax-highlighter deps for chat UI * fix(ui): replace missing BorderOutlined import with inline stop icon div * fix(ui): apply remark-gfm plugin to ReactMarkdown for GFM support * fix(ui): remove unused isEvenRow variable in MCPAppsPanel * fix(ui): add ellipsis when truncating conversation title * fix(ui): wire search button to chats view; remove non-functional keyboard hint * fix(ui): use serverRootPath in navbar chat link for sub-path deployments * fix(ui): remove unused ChatInputBar and ModelSelector files * fix(ui): correct grid bottom-border condition for odd server count * fix(chat): move localStorage writes out of setConversations updater (React purity) * fix(chat): fix stale closure in handleEditAndResend - compute history before async state update * fix(chat): fix 4 issues in ChatMessages - array redaction, clipboard error, inline detection, remove unused ref --- litellm/proxy/proxy_server.py | 9 +- ui/litellm-dashboard/package-lock.json | 295 +++++++ ui/litellm-dashboard/package.json | 4 +- .../src/app/(dashboard)/playground/page.tsx | 64 +- ui/litellm-dashboard/src/app/chat/page.tsx | 19 + .../src/components/chat/ChatMessages.tsx | 577 ++++++++++++ .../src/components/chat/ChatPage.tsx | 823 ++++++++++++++++++ .../src/components/chat/ConversationList.tsx | 483 ++++++++++ .../src/components/chat/MCPAppsPanel.tsx | 274 ++++++ .../src/components/chat/MCPConnectPicker.tsx | 157 ++++ .../src/components/chat/types.ts | 20 + .../src/components/chat/useChatHistory.ts | 230 +++++ .../src/components/leftnav.tsx | 1 + .../src/components/navbar.tsx | 39 +- 14 files changed, 2988 insertions(+), 7 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/chat/page.tsx create mode 100644 ui/litellm-dashboard/src/components/chat/ChatMessages.tsx create mode 100644 ui/litellm-dashboard/src/components/chat/ChatPage.tsx create mode 100644 ui/litellm-dashboard/src/components/chat/ConversationList.tsx create mode 100644 ui/litellm-dashboard/src/components/chat/MCPAppsPanel.tsx create mode 100644 ui/litellm-dashboard/src/components/chat/MCPConnectPicker.tsx create mode 100644 ui/litellm-dashboard/src/components/chat/types.ts create mode 100644 ui/litellm-dashboard/src/components/chat/useChatHistory.ts diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 9683b37dbb4..7fe0ce6d6f5 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -372,6 +372,9 @@ def generate_feedback_box(): from litellm.proxy.management_endpoints.internal_user_endpoints import ( user_update, ) +from litellm.proxy.management_endpoints.jwt_key_mapping_endpoints import ( + router as jwt_key_mapping_router, +) from litellm.proxy.management_endpoints.key_management_endpoints import ( delete_verification_tokens, duration_in_seconds, @@ -380,9 +383,6 @@ def generate_feedback_box(): from litellm.proxy.management_endpoints.key_management_endpoints import ( router as key_management_router, ) -from litellm.proxy.management_endpoints.jwt_key_mapping_endpoints import ( - router as jwt_key_mapping_router, -) from litellm.proxy.management_endpoints.mcp_management_endpoints import ( router as mcp_management_router, ) @@ -661,6 +661,9 @@ def generate_feedback_box(): ui_message += f"\n\nπŸ”Ž [```LiteLLM Model Hub```]({model_hub_link}). See available models on the proxy. [**Docs**](https://docs.litellm.ai/docs/proxy/ai_hub)" +chat_link = f"{server_root_path}/ui/chat" +ui_message += f"\n\nπŸ’¬ [```LiteLLM Chat UI```]({chat_link}). ChatGPT-like interface for your users to chat with AI models and MCP tools." + custom_swagger_message = "[**Customize Swagger Docs**](https://docs.litellm.ai/docs/proxy/enterprise#swagger-docs---custom-routes--branding)" ### CUSTOM BRANDING [ENTERPRISE FEATURE] ### diff --git a/ui/litellm-dashboard/package-lock.json b/ui/litellm-dashboard/package-lock.json index 200182cf551..69efbf19c38 100644 --- a/ui/litellm-dashboard/package-lock.json +++ b/ui/litellm-dashboard/package-lock.json @@ -19,6 +19,7 @@ "@types/papaparse": "^5.3.15", "antd": "^5.13.2", "cva": "^1.0.0-beta.3", + "dayjs": "^1.11.19", "jwt-decode": "^4.0.0", "lucide-react": "^0.513.0", "moment": "^2.30.1", @@ -31,6 +32,7 @@ "react-json-view-lite": "^2.5.0", "react-markdown": "^9.0.1", "react-syntax-highlighter": "^15.6.6", + "remark-gfm": "^4.0.1", "tailwind-merge": "^3.2.0", "uuid": "^11.1.0" }, @@ -8281,6 +8283,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -8290,6 +8302,34 @@ "node": ">= 0.4" } }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mdast-util-from-markdown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", @@ -8314,6 +8354,107 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-mdx-expression": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", @@ -8528,6 +8669,127 @@ "micromark-util-types": "^2.0.0" } }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/micromark-factory-destination": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", @@ -11006,6 +11268,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-parse": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", @@ -11039,6 +11319,21 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", diff --git a/ui/litellm-dashboard/package.json b/ui/litellm-dashboard/package.json index 567673c0989..ea84ea6f401 100644 --- a/ui/litellm-dashboard/package.json +++ b/ui/litellm-dashboard/package.json @@ -31,6 +31,7 @@ "@types/papaparse": "^5.3.15", "antd": "^5.13.2", "cva": "^1.0.0-beta.3", + "dayjs": "^1.11.19", "jwt-decode": "^4.0.0", "lucide-react": "^0.513.0", "moment": "^2.30.1", @@ -43,6 +44,7 @@ "react-json-view-lite": "^2.5.0", "react-markdown": "^9.0.1", "react-syntax-highlighter": "^15.6.6", + "remark-gfm": "^4.0.1", "tailwind-merge": "^3.2.0", "uuid": "^11.1.0" }, @@ -107,4 +109,4 @@ "node": ">=18.17.0", "npm": ">=8.3.0" } -} \ No newline at end of file +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/page.tsx index 555930a576c..6a694d9bee9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/page.tsx @@ -8,6 +8,7 @@ import ComplianceUI from "@/components/playground/complianceUI/ComplianceUI"; import { TabGroup, TabList, Tab, TabPanels, TabPanel } from "@tremor/react"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { fetchProxySettings } from "@/utils/proxyUtils"; +import { MessageOutlined, CloseOutlined } from "@ant-design/icons"; interface ProxySettings { PROXY_BASE_URL?: string; @@ -17,6 +18,7 @@ interface ProxySettings { export default function PlaygroundPage() { const { accessToken, userRole, userId, disabledPersonalKeyCreation, token } = useAuthorized(); const [proxySettings, setProxySettings] = useState(undefined); + const [chatBannerDismissed, setChatBannerDismissed] = useState(false); useEffect(() => { const initializeProxySettings = async () => { @@ -35,7 +37,66 @@ export default function PlaygroundPage() { }, [accessToken]); return ( - +
+ {!chatBannerDismissed && ( +
+ + New + + + Chat UI + {" "}β€” a ChatGPT-like interface for your users to chat with AI models and MCP tools. Share it with your team. + + + Open Chat UI β†’ + + +
+ )} + Chat Compare @@ -72,5 +133,6 @@ export default function PlaygroundPage() { +
); } diff --git a/ui/litellm-dashboard/src/app/chat/page.tsx b/ui/litellm-dashboard/src/app/chat/page.tsx new file mode 100644 index 00000000000..18fc02e7f73 --- /dev/null +++ b/ui/litellm-dashboard/src/app/chat/page.tsx @@ -0,0 +1,19 @@ +"use client"; + +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import ChatPage from "@/components/chat/ChatPage"; + +const ChatPageRoute = () => { + const { accessToken, userRole, userId, userEmail } = useAuthorized(); + + return ( + + ); +}; + +export default ChatPageRoute; diff --git a/ui/litellm-dashboard/src/components/chat/ChatMessages.tsx b/ui/litellm-dashboard/src/components/chat/ChatMessages.tsx new file mode 100644 index 00000000000..640a8addef0 --- /dev/null +++ b/ui/litellm-dashboard/src/components/chat/ChatMessages.tsx @@ -0,0 +1,577 @@ +"use client"; + +import { ToolOutlined, CopyOutlined, CheckOutlined, EditOutlined } from "@ant-design/icons"; +import { Collapse, Tooltip } from "antd"; +import React, { useEffect, useRef, useState } from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; +import { coy } from "react-syntax-highlighter/dist/esm/styles/prism"; +import ReasoningContent from "../playground/chat_ui/ReasoningContent"; +import { ChatMessage } from "./types"; + +const { Panel } = Collapse; + +// Keys whose values must be redacted in tool args display +const REDACTED_KEY_PATTERNS = /token|key|secret|password|auth/i; + +function redactSensitiveValues(obj: Record): Record { + const result: Record = {}; + for (const [k, v] of Object.entries(obj)) { + if (REDACTED_KEY_PATTERNS.test(k)) { + result[k] = "[redacted]"; + } else if (Array.isArray(v)) { + result[k] = v.map((item) => + item !== null && typeof item === "object" && !Array.isArray(item) + ? redactSensitiveValues(item as Record) + : item, + ); + } else if (v !== null && typeof v === "object") { + result[k] = redactSensitiveValues(v as Record); + } else { + result[k] = v; + } + } + return result; +} + +function formatTimestamp(ts: number): string { + const d = new Date(ts); + const hh = String(d.getHours()).padStart(2, "0"); + const mm = String(d.getMinutes()).padStart(2, "0"); + return `${hh}:${mm}`; +} + +// Shared markdown code renderer matching ReasoningContent style. +// react-markdown v9 removed the `inline` prop; detect fenced blocks via language className. +function MarkdownCodeRenderer({ + node, + className, + children, + ...props +}: React.ComponentPropsWithoutRef<"code"> & { node?: unknown }) { + const match = /language-(\w+)/.exec(className || ""); + return match ? ( + } + language={match[1]} + PreTag="div" + className="rounded-md my-2" + {...(props as Record)} + > + {String(children).replace(/\n$/, "")} + + ) : ( + + {children} + + ); +} + +// ------- Sub-components ------- + +interface UserBubbleProps { + message: ChatMessage; + onEdit?: (messageId: string, newContent: string) => void; + isStreaming?: boolean; +} + +function UserBubble({ message, onEdit, isStreaming }: UserBubbleProps) { + const [hovered, setHovered] = useState(false); + const [editing, setEditing] = useState(false); + const [editValue, setEditValue] = useState(message.content); + const textareaRef = useRef(null); + + useEffect(() => { + if (editing && textareaRef.current) { + textareaRef.current.focus(); + textareaRef.current.selectionStart = textareaRef.current.value.length; + } + }, [editing]); + + // Auto-resize textarea + useEffect(() => { + const ta = textareaRef.current; + if (!ta) return; + ta.style.height = "auto"; + ta.style.height = `${ta.scrollHeight}px`; + }, [editValue, editing]); + + const handleSave = () => { + const trimmed = editValue.trim(); + if (trimmed && trimmed !== message.content && onEdit) { + onEdit(message.id, trimmed); + } + setEditing(false); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSave(); + } + if (e.key === "Escape") { + setEditValue(message.content); + setEditing(false); + } + }; + + if (editing) { + return ( +
+
+