diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index 3611a4a1401..a8926d26047 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -177,6 +177,11 @@ class UISettings(BaseModel): description="If true, org admins cannot generate API keys via /key/generate.", ) + enable_chat_ui: bool = Field( + default=False, + description="If true, shows the Chat page in the UI sidebar, letting users chat with an LLM and connect their own MCP server credentials via OAuth.", + ) + class UISettingsResponse(SettingsResponse): """Response model for UI settings""" @@ -200,6 +205,7 @@ ALLOWED_UI_SETTINGS_FIELDS = { "scope_user_search_to_org", "disable_custom_api_keys", "disable_key_generate_for_org_admin", + "enable_chat_ui", } # Flags that must be synced from the persisted UISettings into diff --git a/ui/litellm-dashboard/eslint-metrics.json b/ui/litellm-dashboard/eslint-metrics.json index 4475ce0e74b..cf754a1bb75 100644 --- a/ui/litellm-dashboard/eslint-metrics.json +++ b/ui/litellm-dashboard/eslint-metrics.json @@ -1,5 +1,5 @@ { "@typescript-eslint/no-explicit-any": 1991, - "complexity": 126, + "complexity": 128, "max-depth": 59 } diff --git a/ui/litellm-dashboard/package-lock.json b/ui/litellm-dashboard/package-lock.json index 298c3f9321a..8b92683db67 100644 --- a/ui/litellm-dashboard/package-lock.json +++ b/ui/litellm-dashboard/package-lock.json @@ -34,6 +34,7 @@ "react-json-view-lite": "2.5.0", "react-markdown": "9.1.0", "react-syntax-highlighter": "15.6.6", + "remark-gfm": "4.0.1", "tailwind-merge": "3.4.0", "uuid": "14.0.0" }, @@ -10488,6 +10489,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", @@ -10497,6 +10508,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.3", "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", @@ -10521,6 +10560,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", @@ -10736,6 +10876,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", @@ -13251,6 +13512,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", @@ -13284,6 +13563,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 d4ca1805fb7..cf2c234938d 100644 --- a/ui/litellm-dashboard/package.json +++ b/ui/litellm-dashboard/package.json @@ -50,6 +50,7 @@ "react-json-view-lite": "2.5.0", "react-markdown": "9.1.0", "react-syntax-highlighter": "15.6.6", + "remark-gfm": "4.0.1", "tailwind-merge": "3.4.0", "uuid": "14.0.0" }, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.tsx b/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.tsx index 1e091314ecd..4629def86f3 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.tsx @@ -15,6 +15,7 @@ const SidebarProvider = ({ setPage, defaultSelectedKey, sidebarCollapsed }: Side const { accessToken } = useAuthorized(); const [enabledPagesInternalUsers, setEnabledPagesInternalUsers] = useState(null); const [enableProjectsUI, setEnableProjectsUI] = useState(false); + const [enableChatUI, setEnableChatUI] = useState(false); const [disableAgentsForInternalUsers, setDisableAgentsForInternalUsers] = useState(false); const [allowAgentsForTeamAdmins, setAllowAgentsForTeamAdmins] = useState(false); const [disableVectorStoresForInternalUsers, setDisableVectorStoresForInternalUsers] = useState(false); @@ -46,6 +47,10 @@ const SidebarProvider = ({ setPage, defaultSelectedKey, sidebarCollapsed }: Side setEnableProjectsUI(Boolean(settings.values.enable_projects_ui)); } + if (settings?.values?.enable_chat_ui !== undefined) { + setEnableChatUI(Boolean(settings.values.enable_chat_ui)); + } + if (settings?.values?.disable_agents_for_internal_users !== undefined) { setDisableAgentsForInternalUsers(Boolean(settings.values.disable_agents_for_internal_users)); } @@ -76,6 +81,7 @@ const SidebarProvider = ({ setPage, defaultSelectedKey, sidebarCollapsed }: Side collapsed={sidebarCollapsed} enabledPagesInternalUsers={enabledPagesInternalUsers} enableProjectsUI={enableProjectsUI} + enableChatUI={enableChatUI} disableAgentsForInternalUsers={disableAgentsForInternalUsers} allowAgentsForTeamAdmins={allowAgentsForTeamAdmins} disableVectorStoresForInternalUsers={disableVectorStoresForInternalUsers} diff --git a/ui/litellm-dashboard/src/app/chat/page.test.tsx b/ui/litellm-dashboard/src/app/chat/page.test.tsx new file mode 100644 index 00000000000..658142d47a6 --- /dev/null +++ b/ui/litellm-dashboard/src/app/chat/page.test.tsx @@ -0,0 +1,83 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import ChatPageRoute from "./page"; + +const { mockUseAuthorized, mockUseUISettings, mockUseUIConfig, mockReplace, state } = vi.hoisted(() => { + const state = { + userRole: "Internal User" as string, + enableChatUI: false, + isUISettingsLoading: false, + serverRootPath: undefined as string | undefined, + }; + return { + state, + mockReplace: vi.fn(), + mockUseAuthorized: vi.fn(() => ({ + accessToken: "token-123", + userRole: state.userRole, + userId: "user-1", + userEmail: "user@example.com", + })), + mockUseUISettings: vi.fn(() => ({ + data: { values: { enable_chat_ui: state.enableChatUI } }, + isLoading: state.isUISettingsLoading, + })), + mockUseUIConfig: vi.fn(() => ({ data: { server_root_path: state.serverRootPath } })), + }; +}); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ replace: mockReplace }), +})); +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: mockUseAuthorized })); +vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({ useUISettings: mockUseUISettings })); +vi.mock("@/app/(dashboard)/hooks/uiConfig/useUIConfig", () => ({ useUIConfig: mockUseUIConfig })); +vi.mock("@/components/chat/ChatPage", () => ({ default: () =>
})); + +describe("ChatPageRoute", () => { + afterEach(() => { + state.userRole = "Internal User"; + state.enableChatUI = false; + state.isUISettingsLoading = false; + state.serverRootPath = undefined; + mockReplace.mockClear(); + }); + + it("renders the chat page when enable_chat_ui is on", () => { + state.enableChatUI = true; + render(); + expect(screen.getByTestId("chat-page")).toBeInTheDocument(); + expect(mockReplace).not.toHaveBeenCalled(); + }); + + it("redirects non-admins to the dashboard when enable_chat_ui is off", () => { + state.enableChatUI = false; + state.userRole = "Internal User"; + render(); + expect(screen.queryByTestId("chat-page")).not.toBeInTheDocument(); + expect(mockReplace).toHaveBeenCalledWith("/ui/"); + }); + + it("redirects admins to the dashboard when enable_chat_ui is off", () => { + state.enableChatUI = false; + state.userRole = "Admin"; + render(); + expect(screen.queryByTestId("chat-page")).not.toBeInTheDocument(); + expect(mockReplace).toHaveBeenCalledWith("/ui/"); + }); + + it("renders nothing while UI settings are still loading", () => { + state.isUISettingsLoading = true; + render(); + expect(screen.queryByTestId("chat-page")).not.toBeInTheDocument(); + expect(mockReplace).not.toHaveBeenCalled(); + }); + + it("respects a custom server_root_path when redirecting", () => { + state.enableChatUI = false; + state.userRole = "Internal User"; + state.serverRootPath = "/api/v1"; + render(); + expect(mockReplace).toHaveBeenCalledWith("/api/v1/ui/"); + }); +}); 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..f1053bec78d --- /dev/null +++ b/ui/litellm-dashboard/src/app/chat/page.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { Suspense, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { useUIConfig } from "@/app/(dashboard)/hooks/uiConfig/useUIConfig"; +import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; +import ChatPage from "@/components/chat/ChatPage"; + +// ChatPage uses useSearchParams() which requires a Suspense boundary for static export. +const ChatPageContent = () => { + const { accessToken, userRole, userId, userEmail } = useAuthorized(); + const { data: uiSettings, isLoading: isUISettingsLoading } = useUISettings(); + const { data: uiConfig } = useUIConfig(); + const router = useRouter(); + + const uiRoot = + uiConfig?.server_root_path && uiConfig.server_root_path !== "/" + ? uiConfig.server_root_path.replace(/\/+$/, "") + : ""; + const chatEnabled = Boolean(uiSettings?.values?.enable_chat_ui); + const blocked = !isUISettingsLoading && !chatEnabled; + + useEffect(() => { + if (blocked) router.replace(`${uiRoot}/ui/`); + }, [blocked, uiRoot, router]); + + if (isUISettingsLoading || blocked) return null; + + return ( + + ); +}; + +const ChatPageRoute = () => ( + + + +); + +export default ChatPageRoute; diff --git a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.test.tsx b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.test.tsx index 50f0de193d1..a13af8b2bef 100644 --- a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.test.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.test.tsx @@ -1,32 +1,56 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import ViewSwitcher from "./ViewSwitcher"; -const { mockUsePluginMode, state } = vi.hoisted(() => { +const { mockUsePluginMode, mockUseUISettings, state } = vi.hoisted(() => { const state = { mode: "ai-gateway" as string, setMode: vi.fn(), plugins: [] as { name: string; display_name: string; url: string }[], activePlugin: null as { name: string; display_name: string; url: string } | null, + enableChatUI: false, + }; + return { + state, + mockUsePluginMode: vi.fn(() => ({ + mode: state.mode, + setMode: state.setMode, + plugins: state.plugins, + activePlugin: state.activePlugin, + })), + mockUseUISettings: vi.fn(() => ({ data: { values: { enable_chat_ui: state.enableChatUI } } })), }; - return { mockUsePluginMode: vi.fn(() => state), state }; }); vi.mock("@/contexts/PluginModeContext", () => ({ usePluginMode: mockUsePluginMode })); +vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({ useUISettings: mockUseUISettings })); +// Deterministic hrefs so navigation assertions don't depend on server_root_path. +vi.mock("@/utils/migratedPages", () => ({ migratedHref: (seg: string) => `/ui/${seg}` })); describe("ViewSwitcher", () => { + let assignSpy: ReturnType; + + beforeEach(() => { + assignSpy = vi.fn(); + Object.defineProperty(window, "location", { + configurable: true, + value: { assign: assignSpy }, + }); + }); + afterEach(() => { state.mode = "ai-gateway"; state.plugins = []; + state.enableChatUI = false; state.setMode.mockClear(); }); - it("renders nothing when there are no plugins", () => { + it("renders nothing with no plugins, chat disabled, and a non-admin user", () => { const { container } = render(); expect(container.firstChild).toBeNull(); }); - it("labels the button from the active plugin's display_name and lists AI Gateway + each plugin", async () => { + it("labels the button from the active plugin and lists AI Gateway + each plugin", async () => { state.plugins = [ { name: "litellm-platform-plugin", display_name: "Chat UI", url: "http://localhost:3300" }, { name: "obs", display_name: "Observability", url: "http://localhost:9000" }, @@ -35,7 +59,6 @@ describe("ViewSwitcher", () => { render(); expect(screen.getByRole("button")).toHaveTextContent("Chat UI"); - expect(screen.queryByText("Agent Control Plane")).not.toBeInTheDocument(); act(() => { fireEvent.click(screen.getByRole("button")); @@ -44,22 +67,7 @@ describe("ViewSwitcher", () => { expect(screen.getByText("Observability")).toBeInTheDocument(); }); - it("switches to AI Gateway when that entry is picked", async () => { - state.plugins = [{ name: "litellm-platform-plugin", display_name: "Chat UI", url: "http://localhost:3300" }]; - state.mode = "litellm-platform-plugin"; - render(); - - act(() => { - fireEvent.click(screen.getByRole("button")); - }); - await waitFor(() => expect(screen.getByText("AI Gateway")).toBeInTheDocument()); - act(() => { - fireEvent.click(screen.getByText("AI Gateway")); - }); - expect(state.setMode).toHaveBeenCalledWith("ai-gateway"); - }); - - it("switches to a plugin by name when its entry is picked", async () => { + it("switches plugin mode when a mode entry is picked", async () => { state.plugins = [{ name: "litellm-platform-plugin", display_name: "Chat UI", url: "http://localhost:3300" }]; state.mode = "ai-gateway"; render(); @@ -72,5 +80,35 @@ describe("ViewSwitcher", () => { fireEvent.click(screen.getByText("Chat UI")); }); expect(state.setMode).toHaveBeenCalledWith("litellm-platform-plugin"); + expect(assignSpy).not.toHaveBeenCalled(); + }); + + it("shows a clickable Chat entry that navigates to the chat app when enabled", async () => { + state.enableChatUI = true; + render(); + + act(() => { + fireEvent.click(screen.getByRole("button")); + }); + await waitFor(() => expect(screen.getByText("Chat")).toBeInTheDocument()); + expect(screen.queryByText(/Enable in Admin Settings/i)).not.toBeInTheDocument(); + + act(() => { + fireEvent.click(screen.getByText("Chat")); + }); + expect(assignSpy).toHaveBeenCalledWith("/ui/chat"); + expect(state.setMode).not.toHaveBeenCalled(); + }); + + it("hides the Chat entry from everyone when disabled", async () => { + state.enableChatUI = false; + state.plugins = [{ name: "obs", display_name: "Observability", url: "http://localhost:9000" }]; + render(); + + act(() => { + fireEvent.click(screen.getByRole("button")); + }); + await waitFor(() => expect(screen.getByText("Observability")).toBeInTheDocument()); + expect(screen.queryByText("Chat")).not.toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx index 6d2718720e9..a64b00f885c 100644 --- a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx @@ -3,33 +3,58 @@ import { Dropdown } from "antd"; import { AppstoreOutlined, CheckOutlined, DownOutlined } from "@ant-design/icons"; import type { MenuProps } from "antd"; import { usePluginMode } from "@/contexts/PluginModeContext"; +import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; +import { migratedHref } from "@/utils/migratedPages"; const GATEWAY = "ai-gateway"; +const CHAT = "chat"; export default function ViewSwitcher() { const { mode, setMode, plugins } = usePluginMode(); + const { data: uiSettings } = useUISettings(); - // Only a switcher when there is at least one plugin to switch to. - if (plugins.length === 0) return null; + const chatEnabled = Boolean(uiSettings?.values?.enable_chat_ui); + + if (plugins.length === 0 && !chatEnabled) return null; const activeLabel = plugins.find((p) => p.name === mode)?.display_name ?? "AI Gateway"; - const entries = [ - { value: GATEWAY, label: "AI Gateway" }, - ...plugins.map((p) => ({ value: p.name, label: p.display_name })), + const modeEntries = [ + { key: GATEWAY, label: "AI Gateway" }, + ...plugins.map((p) => ({ key: p.name, label: p.display_name })), ]; - const items: MenuProps["items"] = entries.map((e) => ({ - key: e.value, - label: ( -
- {e.label} - {e.value === mode && } -
- ), - })); + const items: MenuProps["items"] = [ + ...modeEntries.map((e) => ({ + key: e.key, + label: ( +
+ {e.label} + {e.key === mode && } +
+ ), + })), + ...(chatEnabled + ? [ + { + key: CHAT, + label: ( +
+ Chat +
+ ), + }, + ] + : []), + ]; - const onClick: MenuProps["onClick"] = ({ key }) => setMode(key); + const onClick: MenuProps["onClick"] = ({ key }) => { + if (key === CHAT) { + window.location.assign(migratedHref(CHAT)); + return; + } + setMode(key); + }; return ( diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx index 9ce0d908838..ec970c34873 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx @@ -19,6 +19,7 @@ export default function UISettings() { const forwardClientHeadersProperty = schema?.properties?.forward_client_headers_to_llm_api; const forwardLLMProviderAuthHeadersProperty = schema?.properties?.forward_llm_provider_auth_headers; const enableProjectsUIProperty = schema?.properties?.enable_projects_ui; + const enableChatUIProperty = schema?.properties?.enable_chat_ui; const enabledPagesProperty = schema?.properties?.enabled_ui_pages_internal_users; const disableAgentsProperty = schema?.properties?.disable_agents_for_internal_users; const allowAgentsTeamAdminsProperty = schema?.properties?.allow_agents_for_team_admins; @@ -114,6 +115,21 @@ export default function UISettings() { ); }; + const handleToggleEnableChatUI = (checked: boolean) => { + updateSettings( + { enable_chat_ui: checked }, + { + onSuccess: () => { + NotificationManager.success("UI settings updated successfully. Refreshing page..."); + setTimeout(() => window.location.reload(), 1000); + }, + onError: (error) => { + NotificationManager.fromBackend(error); + }, + }, + ); + }; + const handleToggleRequireAuthForPublicAIHub = (checked: boolean) => { updateSettings( { require_auth_for_public_ai_hub: checked }, @@ -335,6 +351,23 @@ export default function UISettings() { )} + + + + [BETA] Enable Chat page (page will refresh) + + {enableChatUIProperty?.description ?? + "If enabled, shows the Chat page in the UI sidebar, letting users chat with an LLM and connect their own MCP server credentials via OAuth."} + + + + {/* Agents access control */} 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..f29adcfb6a5 --- /dev/null +++ b/ui/litellm-dashboard/src/components/chat/ChatMessages.tsx @@ -0,0 +1,585 @@ +"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 "@/components/chat_ui/ReasoningContent"; +import MCPEventsDisplay from "@/components/chat_ui/MCPEventsDisplay"; +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 ( +
+
+