diff --git a/litellm/proxy/management_endpoints/mcp_management_endpoints.py b/litellm/proxy/management_endpoints/mcp_management_endpoints.py index f29a721ede..e4bb288cda 100644 --- a/litellm/proxy/management_endpoints/mcp_management_endpoints.py +++ b/litellm/proxy/management_endpoints/mcp_management_endpoints.py @@ -1214,17 +1214,9 @@ if MCP_AVAILABLE: "error": "User does not have permission to create mcp servers. You can only create mcp servers if you are a PROXY_ADMIN." }, ) - elif payload.server_id is not None: - # fail if the mcp server with id already exists - mcp_server = await get_mcp_server(prisma_client, payload.server_id) - if mcp_server is not None: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail={ - "error": f"MCP Server with id {payload.server_id} already exists. Cannot create another." - }, - ) - elif ( + + # Block reserved special server IDs + if ( SpecialMCPServerName.all_team_servers == payload.server_id or SpecialMCPServerName.all_proxy_servers == payload.server_id ): @@ -1235,6 +1227,17 @@ if MCP_AVAILABLE: }, ) + if payload.server_id is not None: + # fail if the mcp server with id already exists + mcp_server = await get_mcp_server(prisma_client, payload.server_id) + if mcp_server is not None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "error": f"MCP Server with id {payload.server_id} already exists. Cannot create another." + }, + ) + # TODO: audit log for create # Admin-created servers are always active — clear any submission lifecycle diff --git a/ui/litellm-dashboard/src/app/(dashboard)/api-reference/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/api-reference/page.tsx index f9f26c0eac..02bed1adbe 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/api-reference/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/api-reference/page.tsx @@ -1,16 +1,10 @@ "use client"; import APIReferenceView from "@/app/(dashboard)/api-reference/APIReferenceView"; -import { useState } from "react"; - -interface ProxySettings { - PROXY_BASE_URL: string; - PROXY_LOGOUT_URL: string; - LITELLM_UI_API_DOC_BASE_URL?: string | null; -} +import useProxySettings from "@/app/(dashboard)/hooks/proxySettings/useProxySettings"; const APIReferencePage = () => { - const [proxySettings, setProxySettings] = useState({ PROXY_BASE_URL: "", PROXY_LOGOUT_URL: "" }); + const proxySettings = useProxySettings(); return ; }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx b/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx index 18ab475f22..27a6e6c13b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx @@ -195,7 +195,7 @@ const menuItems: MenuItemCfg[] = [ icon: , roles: all_admin_roles, }, - { key: "14", page: "api_ref", label: "API Reference", icon: }, + { key: "14", page: "api-reference", label: "API Reference", icon: }, { key: "16", page: "model-hub-table", diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/common/queryKeysFactory.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/common/queryKeysFactory.test.ts new file mode 100644 index 0000000000..39afd04409 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/common/queryKeysFactory.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from "vitest"; +import { createQueryKeys } from "./queryKeysFactory"; + +describe("createQueryKeys", () => { + const keys = createQueryKeys("books"); + + it("should return the resource name as the base key", () => { + expect(keys.all).toEqual(["books"]); + }); + + it("should generate a lists key", () => { + expect(keys.lists()).toEqual(["books", "list"]); + }); + + it("should generate a list key with params", () => { + expect(keys.list({ page: 1, limit: 10 })).toEqual([ + "books", + "list", + { params: { page: 1, limit: 10 } }, + ]); + }); + + it("should generate a list key with undefined params when none provided", () => { + expect(keys.list()).toEqual(["books", "list", { params: undefined }]); + }); + + it("should generate a details key", () => { + expect(keys.details()).toEqual(["books", "detail"]); + }); + + it("should generate a detail key for a specific ID", () => { + expect(keys.detail("123")).toEqual(["books", "detail", "123"]); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/proxySettings/useProxySettings.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/proxySettings/useProxySettings.ts new file mode 100644 index 0000000000..d4fb307385 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/proxySettings/useProxySettings.ts @@ -0,0 +1,21 @@ +import { useState, useEffect } from "react"; +import { fetchProxySettings } from "@/utils/proxyUtils"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; + +export default function useProxySettings() { + const { accessToken } = useAuthorized(); + const [proxySettings, setProxySettings] = useState({ + PROXY_BASE_URL: "", + PROXY_LOGOUT_URL: "", + LITELLM_UI_API_DOC_BASE_URL: null as string | null, + }); + + useEffect(() => { + if (!accessToken) return; + fetchProxySettings(accessToken).then((settings) => { + if (settings) setProxySettings(settings); + }); + }, [accessToken]); + + return proxySettings; +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsHeaderTabs.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsHeaderTabs.test.tsx new file mode 100644 index 0000000000..50a7f10f04 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsHeaderTabs.test.tsx @@ -0,0 +1,54 @@ +import { render, screen } from "@testing-library/react"; +import React from "react"; +import { describe, expect, it, vi } from "vitest"; +import TeamsHeaderTabs from "./TeamsHeaderTabs"; + +vi.mock("@tremor/react", () => ({ + TabGroup: ({ children, ...props }: any) =>
{children}
, + TabList: ({ children, ...props }: any) =>
{children}
, + Tab: ({ children, ...props }: any) => , + TabPanels: ({ children, ...props }: any) =>
{children}
, + Text: ({ children, ...props }: any) => {children}, + Icon: ({ onClick, ...props }: any) => + + ); + } + + return ( + + columns={teamColumns} + dataSource={displayTeams} + rowKey="team_id" + pagination={false} + onChange={handleTableSort} + locale={{ + emptyText: ( +
+ +
+ No teams yet +
+
+ + Create your first team to organize members and manage access to models. + +
+ {canCreateOrManageTeams(userRole, userID, organizations) && ( + + )} +
+ ), + }} + scroll={{ x: 1000 }} + size="middle" + /> + ); + }; + + const tabItems = [ + { + key: "your-teams", + label: "Your Teams", + children: ( + <> + + + + } + suffix={isSearching ? : null} + placeholder="Search teams by name..." + onChange={(e) => handleSearchChange(e.target.value)} + allowClear + style={{ maxWidth: 400 }} + /> + handleFilterChange("organization_id", value || "")} + loading={isLoading} + /> + + { + setCurrentPage(page); + setPageSize(size); + fetchTeamsV2({ page, size }); + }} + size="small" + showTotal={(total) => `${total} teams`} + showSizeChanger + pageSizeOptions={["10", "20", "50"]} + /> + + + {renderTeamsContent()} + + + + + ), + }, + { + key: "available-teams", + label: "Available Teams", + children: , + }, + ...(isProxyAdminRole(userRole || "") + ? [ + { + key: "default-settings", + label: "Default Team Settings", + children: , + }, + ] + : []), + ]; + return ( -
- - - {canCreateOrManageTeams(userRole, userID, organizations) && ( - - )} - {selectedTeamId ? ( - { - setTeams((teams) => { - if (teams == null) { - return teams; - } - const updated = teams.map((team) => { - if (data.team_id === team.team_id) { - return updateExistingKeys(team, data); - } - return team; - }); - // Minimal fix: refresh the full team list after an update - if (accessToken) { - fetchTeams(accessToken, userID, userRole, currentOrg, setTeams); - } - return updated; - }); - }} - onClose={() => { - setSelectedTeamId(null); - setEditTeam(false); - }} - accessToken={accessToken} - is_team_admin={is_team_admin(teams?.find((team) => team.team_id === selectedTeamId))} - is_proxy_admin={userRole == "Admin"} - userModels={userModels} - editTeam={editTeam} - premiumUser={premiumUser} - /> - ) : ( - - -
- Your Teams - Available Teams - {isProxyAdminRole(userRole || "") && Default Team Settings} -
-
- {lastRefreshed && Last Refreshed: {lastRefreshed}} - -
-
- - - - Click on “Team ID” to view team details and manage team members. - - - - -
-
- {/* Search and Filter Controls */} -
- {/* Team Alias Search */} - handleFilterChange("team_alias", value)} - icon={Search} - /> + + {selectedTeamId ? ( + { + setTeams((teams) => { + if (teams == null) { + return teams; + } + return teams.map((team) => { + if (data.team_id === team.team_id) { + return updateExistingKeys(team, data); + } + return team; + }); + }); + fetchTeamsV2(); + }} + onClose={() => { + setSelectedTeamId(null); + setEditTeam(false); + }} + accessToken={accessToken} + is_team_admin={is_team_admin(teams?.find((team) => team.team_id === selectedTeamId))} + is_proxy_admin={userRole == "Admin"} + userModels={userModels} + editTeam={editTeam} + premiumUser={premiumUser} + /> + ) : ( + <> + + + + <TeamOutlined style={{ marginRight: 8 }} /> + Teams + + + Manage teams, members, and their access to models and budgets + + + {canCreateOrManageTeams(userRole, userID, organizations) && ( + + )} + - {/* Filter Button */} - setShowFilters(!showFilters)} - active={showFilters} - hasActiveFilters={!!(filters.team_id || filters.team_alias || filters.organization_id)} - /> + + + )} - {/* Reset Filters Button */} - -
- - {/* Additional Filters */} - {showFilters && ( -
- {/* Team ID Search */} - handleFilterChange("team_id", value)} - icon={User} - /> - - {/* Organization Dropdown */} -
- -
-
- )} -
-
- - - - Team Name - Team ID - Created - Spend (USD) - Budget (USD) - Models - Organization - Info - Actions - - - - - {teams && teams.length > 0 ? ( - teams - .filter((team) => { - if (!currentOrg) return true; - return team.organization_id === currentOrg.organization_id; - }) - .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) - .map((team: any) => ( - - - {team["team_alias"]} - - -
- - - -
-
- - {team.created_at ? new Date(team.created_at).toLocaleDateString() : "N/A"} - - - {formatNumberWithCommas(team["spend"], 4)} - - - {team["max_budget"] !== null && team["max_budget"] !== undefined - ? team["max_budget"] - : "No limit"} - - 3 ? "px-0" : ""} - > -
- {Array.isArray(team.models) ? ( -
- {team.models.length === 0 ? ( - - All Proxy Models - - ) : ( - <> -
- {team.models.length > 3 && ( -
- { - setExpandedAccordions((prev) => ({ - ...prev, - [team.team_id]: !prev[team.team_id], - })); - }} - /> -
- )} -
- {team.models.slice(0, 3).map((model: string, index: number) => - model === "all-proxy-models" ? ( - - All Proxy Models - - ) : ( - - - {model.length > 30 - ? `${getModelDisplayName(model).slice(0, 30)}...` - : getModelDisplayName(model)} - - - ), - )} - {team.models.length > 3 && !expandedAccordions[team.team_id] && ( - - - +{team.models.length - 3}{" "} - {team.models.length - 3 === 1 ? "more model" : "more models"} - - - )} - {expandedAccordions[team.team_id] && ( -
- {team.models.slice(3).map((model: string, index: number) => - model === "all-proxy-models" ? ( - - All Proxy Models - - ) : ( - - - {model.length > 30 - ? `${getModelDisplayName(model).slice(0, 30)}...` - : getModelDisplayName(model)} - - - ), - )} -
- )} -
-
- - )} -
- ) : null} -
-
- - - {getOrganizationAlias(team.organization_id, organizationsData || organizations)} - - - - {perTeamInfo && - team.team_id && - perTeamInfo[team.team_id] && - perTeamInfo[team.team_id].keys && - perTeamInfo[team.team_id].keys.length}{" "} - Keys - - - {perTeamInfo && - team.team_id && - perTeamInfo[team.team_id] && - perTeamInfo[team.team_id].team_info && - perTeamInfo[team.team_id].team_info.members_with_roles && - perTeamInfo[team.team_id].team_info.members_with_roles.length}{" "} - Members - - - - {userRole == "Admin" ? ( - <> - { - setSelectedTeamId(team.team_id); - setEditTeam(true); - }} - dataTestId="edit-team-button" - tooltipText="Edit team" - /> - handleDelete(team)} - dataTestId="delete-team-button" - tooltipText="Delete team" - /> - - ) : null} - -
- )) - ) : ( - - -
- No teams found - Adjust your filters or create a new team -
-
-
- )} -
-
- -
- -
-
- - - - {isProxyAdminRole(userRole || "") && ( - - - - )} -
-
- )} - {canCreateOrManageTeams(userRole, userID, organizations) && ( + {canCreateOrManageTeams(userRole, userID, organizations) && ( = ({ : "" } > - = ({ optionFilterProp="children" > {adminOrgs?.map((org) => ( - + {org.organization_alias}{" "} ({org.organization_id}) - + ))} - + {/* Show message when org admin needs to select organization */} {isOrgAdmin && !isSingleOrg && adminOrgs.length > 1 && (
- + Please select an organization to create a team for. You can only create teams within organizations where you are an admin. @@ -1190,11 +1211,11 @@ const Teams: React.FC = ({ - - daily - weekly - monthly - + @@ -1313,7 +1334,7 @@ const Teams: React.FC = ({ className="mt-8" help="Select existing guardrails or enter new ones" > - = ({ className="mt-8" help="Select existing policies or enter new ones" > - = ({
- + Create custom aliases for models that can be used by team members in API calls. This allows you to create shortcuts for specific models. @@ -1548,14 +1569,12 @@ const Teams: React.FC = ({
- Create Team +
)} - - -
+ ); }; diff --git a/ui/litellm-dashboard/src/components/Projects/ProjectModals/CreateProjectModal.tsx b/ui/litellm-dashboard/src/components/Projects/ProjectModals/CreateProjectModal.tsx index e490f89303..bbf56e4930 100644 --- a/ui/litellm-dashboard/src/components/Projects/ProjectModals/CreateProjectModal.tsx +++ b/ui/litellm-dashboard/src/components/Projects/ProjectModals/CreateProjectModal.tsx @@ -1,5 +1,6 @@ -import { Modal, Form, Button, Typography, message } from "antd"; +import { Modal, Form, Button, Typography } from "antd"; import { FolderAddOutlined } from "@ant-design/icons"; +import MessageManager from "@/components/molecules/message_manager"; import { useCreateProject, ProjectCreateParams, @@ -32,12 +33,12 @@ export function CreateProjectModal({ createMutation.mutate(params, { onSuccess: () => { - message.success("Project created successfully"); + MessageManager.success("Project created successfully"); form.resetFields(); onClose(); }, onError: (error) => { - message.error(error.message || "Failed to create project"); + MessageManager.error(error.message || "Failed to create project"); }, }); } catch (error) { diff --git a/ui/litellm-dashboard/src/components/Projects/ProjectModals/EditProjectModal.tsx b/ui/litellm-dashboard/src/components/Projects/ProjectModals/EditProjectModal.tsx index 75f56b1373..dc3b43ef73 100644 --- a/ui/litellm-dashboard/src/components/Projects/ProjectModals/EditProjectModal.tsx +++ b/ui/litellm-dashboard/src/components/Projects/ProjectModals/EditProjectModal.tsx @@ -1,6 +1,7 @@ import { useEffect } from "react"; -import { Modal, Form, Button, Typography, message } from "antd"; +import { Modal, Form, Button, Typography } from "antd"; import { SaveOutlined } from "@ant-design/icons"; +import MessageManager from "@/components/molecules/message_manager"; import { ProjectResponse } from "@/app/(dashboard)/hooks/projects/useProjects"; import { useUpdateProject, @@ -80,12 +81,12 @@ export function EditProjectModal({ { projectId: project.project_id, params }, { onSuccess: () => { - message.success("Project updated successfully"); + MessageManager.success("Project updated successfully"); onSuccess?.(); onClose(); }, onError: (error) => { - message.error(error.message || "Failed to update project"); + MessageManager.error(error.message || "Failed to update project"); }, }, ); diff --git a/ui/litellm-dashboard/src/components/SearchTools/SearchToolTester.tsx b/ui/litellm-dashboard/src/components/SearchTools/SearchToolTester.tsx index 28b34b0608..d1cb5077b1 100644 --- a/ui/litellm-dashboard/src/components/SearchTools/SearchToolTester.tsx +++ b/ui/litellm-dashboard/src/components/SearchTools/SearchToolTester.tsx @@ -1,5 +1,6 @@ import React, { useState } from "react"; -import { Button, Input, Typography, Spin, message } from "antd"; +import { Button, Input, Typography, Spin } from "antd"; +import MessageManager from "@/components/molecules/message_manager"; import { SearchOutlined, LoadingOutlined } from "@ant-design/icons"; import { searchToolQueryCall } from "../networking"; import NotificationsManager from "../molecules/notifications_manager"; @@ -39,7 +40,7 @@ export const SearchToolTester: React.FC = ({ searchToolNa const handleSearch = async () => { if (!query.trim()) { - message.warning("Please enter a search query"); + MessageManager.warning("Please enter a search query"); return; } diff --git a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/AddFallbacks.tsx b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/AddFallbacks.tsx index 34f059516a..e55089d27d 100644 --- a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/AddFallbacks.tsx +++ b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/AddFallbacks.tsx @@ -5,8 +5,9 @@ */ import { Button as TremorButton } from "@tremor/react"; -import { Button, message } from "antd"; +import { Button } from "antd"; import React, { useEffect, useState } from "react"; +import MessageManager from "@/components/molecules/message_manager"; import NotificationManager from "../../../molecules/notifications_manager"; import { fetchAvailableModels, ModelGroup } from "../../../playground/llm_calls/fetch_models"; import { AddFallbacksModal } from "./AddFallbacksModal"; @@ -90,7 +91,7 @@ export default function AddFallbacks({ (g) => !g.primaryModel || g.fallbackModels.length === 0, ); if (invalidGroups.length > 0) { - message.error( + MessageManager.error( `Please complete configuration for all groups. ${invalidGroups.length} group(s) incomplete.`, ); return; diff --git a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/FallbackSelectionForm.tsx b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/FallbackSelectionForm.tsx index 08b031c683..0482161bc6 100644 --- a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/FallbackSelectionForm.tsx +++ b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/FallbackSelectionForm.tsx @@ -5,9 +5,10 @@ */ import { Button } from "@tremor/react"; -import { message, Tabs } from "antd"; +import { Tabs } from "antd"; import { Plus } from "lucide-react"; import React, { useEffect, useState } from "react"; +import MessageManager from "@/components/molecules/message_manager"; import { FallbackGroup, FallbackGroupConfig } from "./FallbackGroupConfig"; interface FallbackSelectionFormProps { @@ -60,7 +61,7 @@ export function FallbackSelectionForm({ const handleRemoveGroup = (targetId: string) => { if (groups.length === 1) { - message.warning("At least one group is required"); + MessageManager.warning("At least one group is required"); return; } const newGroups = groups.filter((g) => g.id !== targetId); diff --git a/ui/litellm-dashboard/src/components/agents/add_agent_form.tsx b/ui/litellm-dashboard/src/components/agents/add_agent_form.tsx index c5518596b8..b6d96f0445 100644 --- a/ui/litellm-dashboard/src/components/agents/add_agent_form.tsx +++ b/ui/litellm-dashboard/src/components/agents/add_agent_form.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect } from "react"; -import { Modal, Form, message, Select, Input, Steps, Radio, Tag, Divider, Switch, InputNumber, Collapse } from "antd"; +import { Modal, Form, Select, Input, Steps, Radio, Tag, Divider, Switch, InputNumber, Collapse } from "antd"; +import MessageManager from "@/components/molecules/message_manager"; import { Button } from "@tremor/react"; import { CheckCircleFilled, KeyOutlined, RobotOutlined, AppstoreOutlined, InfoCircleOutlined } from "@ant-design/icons"; import CreatedKeyDisplay from "../shared/CreatedKeyDisplay"; @@ -216,7 +217,7 @@ const AddAgentForm: React.FC = ({ const handleCreateAgent = async () => { if (!accessToken) { - message.error("No access token available"); + MessageManager.error("No access token available"); return; } @@ -226,7 +227,7 @@ const AddAgentForm: React.FC = ({ const values = { ...form.getFieldsValue(true) }; const agentData = buildAgentData(values); if (!agentData) { - message.error("Failed to build agent data"); + MessageManager.error("Failed to build agent data"); setIsSubmitting(false); return; } @@ -301,7 +302,7 @@ const AddAgentForm: React.FC = ({ setCreatedKeyValue(keyResponse.key || null); } else if (keyAssignOption === "existing_key") { if (!selectedExistingKey) { - message.error("Please select an existing key to assign"); + MessageManager.error("Please select an existing key to assign"); setIsSubmitting(false); return; } @@ -318,7 +319,7 @@ const AddAgentForm: React.FC = ({ } catch (error) { console.error("Error creating agent:", error); const errorMessage = error instanceof Error ? error.message : String(error); - message.error(errorMessage ? `Failed to create agent: ${errorMessage}` : "Failed to create agent"); + MessageManager.error(errorMessage ? `Failed to create agent: ${errorMessage}` : "Failed to create agent"); } finally { setIsSubmitting(false); } diff --git a/ui/litellm-dashboard/src/components/agents/agent_info.tsx b/ui/litellm-dashboard/src/components/agents/agent_info.tsx index b41e318a76..d543be8356 100644 --- a/ui/litellm-dashboard/src/components/agents/agent_info.tsx +++ b/ui/litellm-dashboard/src/components/agents/agent_info.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect } from "react"; import { Card, Title, Text, Button as TremorButton, Tab, TabGroup, TabList, TabPanel, TabPanels} from "@tremor/react"; -import { Form, Input, InputNumber, Button as AntButton, message, Spin, Descriptions, Divider } from "antd"; +import { Form, Input, InputNumber, Button as AntButton, Spin, Descriptions, Divider } from "antd"; +import MessageManager from "@/components/molecules/message_manager"; import { ArrowLeftIcon } from "@heroicons/react/outline"; import { getAgentInfo, patchAgentCall, getAgentCreateMetadata, AgentCreateInfo } from "../networking"; import { Agent } from "./types"; @@ -72,7 +73,7 @@ const AgentInfoView: React.FC = ({ } } catch (error) { console.error("Error fetching agent info:", error); - message.error("Failed to load agent information"); + MessageManager.error("Failed to load agent information"); } finally { setIsLoading(false); } @@ -111,12 +112,12 @@ const AgentInfoView: React.FC = ({ } await patchAgentCall(accessToken, agentId, updateData); - message.success("Agent updated successfully"); + MessageManager.success("Agent updated successfully"); setIsEditing(false); fetchAgentInfo(); } catch (error) { console.error("Error updating agent:", error); - message.error("Failed to update agent"); + MessageManager.error("Failed to update agent"); } finally { setIsSaving(false); } diff --git a/ui/litellm-dashboard/src/components/chat/ChatPage.tsx b/ui/litellm-dashboard/src/components/chat/ChatPage.tsx index ccf39d2147..b547f74917 100644 --- a/ui/litellm-dashboard/src/components/chat/ChatPage.tsx +++ b/ui/litellm-dashboard/src/components/chat/ChatPage.tsx @@ -1,7 +1,8 @@ "use client"; import React, { useCallback, useEffect, useRef, useState, useLayoutEffect } from "react"; -import { Tooltip, Skeleton, Popover, message } from "antd"; +import { Tooltip, Skeleton, Popover } from "antd"; +import MessageManager from "@/components/molecules/message_manager"; import { SettingOutlined, PlusOutlined, @@ -212,7 +213,7 @@ const ChatPage: React.FC = ({ accessToken, userRole, userId, user localStorage.setItem(LOCALSTORAGE_MODEL_KEY, JSON.stringify([names[0]])); } }) - .catch(() => message.error("Could not load models")) + .catch(() => MessageManager.error("Could not load models")) .finally(() => setIsLoadingModels(false)); }, [accessToken]); diff --git a/ui/litellm-dashboard/src/components/chat/MCPAppsPanel.tsx b/ui/litellm-dashboard/src/components/chat/MCPAppsPanel.tsx index b805cab71e..d25db73ae4 100644 --- a/ui/litellm-dashboard/src/components/chat/MCPAppsPanel.tsx +++ b/ui/litellm-dashboard/src/components/chat/MCPAppsPanel.tsx @@ -5,7 +5,7 @@ import { Spin, Input, Button, Skeleton } from "antd"; import { SearchOutlined, ArrowLeftOutlined, RightOutlined, ToolOutlined, CheckCircleOutlined } from "@ant-design/icons"; import { deleteMCPOAuthUserCredential, fetchMCPServers, getMCPOAuthUserCredentialStatus, listMCPTools } from "../networking"; import { AUTH_TYPE, MCPServer, MCPTool, handleTransport } from "../mcp_tools/types"; -import { message } from "antd"; +import MessageManager from "@/components/molecules/message_manager"; import { useUserMcpOAuthFlow } from "@/hooks/useUserMcpOAuthFlow"; // ── OAuth2 connect button ───────────────────────────────────────────────────── @@ -198,7 +198,7 @@ const MCPAppsPanel: React.FC = ({ accessToken, selectedServers, onChange const idToFetch = serverId ?? serverName; const result = await listMCPTools(accessToken, idToFetch); if (result?.error) { - message.warning(`Could not load tools for ${serverName}`); + MessageManager.warning(`Could not load tools for ${serverName}`); return; } // Use the ref so we read the most up-to-date list; guard against duplicates @@ -207,7 +207,7 @@ const MCPAppsPanel: React.FC = ({ accessToken, selectedServers, onChange onChange([...selectedServersRef.current, serverName]); } } catch { - message.warning(`Could not load tools for ${serverName}`); + MessageManager.warning(`Could not load tools for ${serverName}`); } finally { setTogglingOn((prev) => { const next = new Set(prev); diff --git a/ui/litellm-dashboard/src/components/chat/MCPConnectPicker.tsx b/ui/litellm-dashboard/src/components/chat/MCPConnectPicker.tsx index a52ce18156..6ef3aecc46 100644 --- a/ui/litellm-dashboard/src/components/chat/MCPConnectPicker.tsx +++ b/ui/litellm-dashboard/src/components/chat/MCPConnectPicker.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useState } from "react"; -import { Switch, Spin, message } from "antd"; +import { Switch, Spin } from "antd"; +import MessageManager from "@/components/molecules/message_manager"; import { fetchMCPServers, listMCPTools } from "../networking"; import { MCPServer } from "../mcp_tools/types"; @@ -57,7 +58,7 @@ const MCPConnectPicker: React.FC = ({ accessToken, selectedServers, onCha const result = await listMCPTools(accessToken, serverName); // listMCPTools never throws; it returns { tools, error, message } on failure if (result?.error) { - message.warning( + MessageManager.warning( `Could not load tools for ${serverName} — it will be excluded from this message.` ); // Do not add to selectedServers @@ -65,7 +66,7 @@ const MCPConnectPicker: React.FC = ({ accessToken, selectedServers, onCha } onChange([...selectedServers, serverName]); } catch { - message.warning( + MessageManager.warning( `Could not load tools for ${serverName} — it will be excluded from this message.` ); // Do not add to selectedServers diff --git a/ui/litellm-dashboard/src/components/chat/MCPCredentialsTab.tsx b/ui/litellm-dashboard/src/components/chat/MCPCredentialsTab.tsx index 363ec8c0e4..e2d879b22a 100644 --- a/ui/litellm-dashboard/src/components/chat/MCPCredentialsTab.tsx +++ b/ui/litellm-dashboard/src/components/chat/MCPCredentialsTab.tsx @@ -8,7 +8,8 @@ */ import React, { useCallback, useEffect, useState } from "react"; -import { Spin, message } from "antd"; +import { Spin } from "antd"; +import MessageManager from "@/components/molecules/message_manager"; import { DeleteOutlined, LinkOutlined } from "@ant-design/icons"; import { Badge, Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from "@tremor/react"; import { @@ -77,7 +78,7 @@ const MCPCredentialsTab: React.FC = ({ accessToken }) => { await deleteMCPOAuthUserCredential(accessToken, serverId); setCredentials((prev) => prev.filter((c) => c.server_id !== serverId)); } catch { - message.error("Failed to revoke connection. Please try again."); + MessageManager.error("Failed to revoke connection. Please try again."); } finally { setRevoking((prev) => { const n = new Set(prev); n.delete(serverId); return n; }); } diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/add_plugin_form.tsx b/ui/litellm-dashboard/src/components/claude_code_plugins/add_plugin_form.tsx index 217851f128..d5e417a9a8 100644 --- a/ui/litellm-dashboard/src/components/claude_code_plugins/add_plugin_form.tsx +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/add_plugin_form.tsx @@ -1,5 +1,6 @@ import React, { useState } from "react"; -import { Modal, Form, Input, Select, message } from "antd"; +import { Modal, Form, Input, Select } from "antd"; +import MessageManager from "@/components/molecules/message_manager"; import { Button } from "@tremor/react"; import { registerClaudeCodePlugin } from "../networking"; import { @@ -43,13 +44,13 @@ const AddPluginForm: React.FC = ({ const handleSubmit = async (values: any) => { if (!accessToken) { - message.error("No access token available"); + MessageManager.error("No access token available"); return; } // Validate plugin name if (!validatePluginName(values.name)) { - message.error( + MessageManager.error( "Plugin name must be kebab-case (lowercase letters, numbers, and hyphens only)" ); return; @@ -57,7 +58,7 @@ const AddPluginForm: React.FC = ({ // Validate semantic version if provided if (values.version && !isValidSemanticVersion(values.version)) { - message.error( + MessageManager.error( "Version must be in semantic versioning format (e.g., 1.0.0)" ); return; @@ -65,13 +66,13 @@ const AddPluginForm: React.FC = ({ // Validate email if provided if (values.authorEmail && !isValidEmail(values.authorEmail)) { - message.error("Invalid email format"); + MessageManager.error("Invalid email format"); return; } // Validate homepage URL if provided if (values.homepage && !isValidUrl(values.homepage)) { - message.error("Invalid homepage URL format"); + MessageManager.error("Invalid homepage URL format"); return; } @@ -119,14 +120,14 @@ const AddPluginForm: React.FC = ({ } await registerClaudeCodePlugin(accessToken, pluginData); - message.success("Plugin registered successfully"); + MessageManager.success("Plugin registered successfully"); form.resetFields(); setSourceType("github"); onSuccess(); onClose(); } catch (error) { console.error("Error registering plugin:", error); - message.error("Failed to register plugin"); + MessageManager.error("Failed to register plugin"); } finally { setIsSubmitting(false); } diff --git a/ui/litellm-dashboard/src/components/common_components/IconActionButton/TableIconActionButtons/TableIconActionButton.tsx b/ui/litellm-dashboard/src/components/common_components/IconActionButton/TableIconActionButtons/TableIconActionButton.tsx index 488913a734..2f146aab72 100644 --- a/ui/litellm-dashboard/src/components/common_components/IconActionButton/TableIconActionButtons/TableIconActionButton.tsx +++ b/ui/litellm-dashboard/src/components/common_components/IconActionButton/TableIconActionButtons/TableIconActionButton.tsx @@ -6,6 +6,7 @@ import { ChevronUpIcon, ChevronDownIcon, ExternalLinkIcon, + ClipboardCopyIcon, } from "@heroicons/react/outline"; import { Tooltip } from "antd"; import BaseActionButton from "../BaseActionButton"; @@ -32,6 +33,7 @@ export const TableIconActionButtonMap: Record void; disabled?: boolean; loading?: boolean; + style?: React.CSSProperties; } const OrganizationDropdown: React.FC = ({ @@ -16,16 +19,18 @@ const OrganizationDropdown: React.FC = ({ onChange, disabled, loading, + style, }) => { return ( diff --git a/ui/litellm-dashboard/src/components/key_team_helpers/filter_helpers.test.ts b/ui/litellm-dashboard/src/components/key_team_helpers/filter_helpers.test.ts new file mode 100644 index 0000000000..e3f6a5989f --- /dev/null +++ b/ui/litellm-dashboard/src/components/key_team_helpers/filter_helpers.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, vi } from "vitest"; +import { fetchTeamFilterOptions } from "./filter_helpers"; + +const mockKeyListCall = vi.fn(); + +vi.mock("@/components/networking", () => ({ + keyListCall: (...args: unknown[]) => mockKeyListCall(...args), + teamListCall: vi.fn(), + organizationListCall: vi.fn(), +})); + +describe("fetchTeamFilterOptions", () => { + it("should return empty arrays when accessToken is null", async () => { + const result = await fetchTeamFilterOptions(null, "team-1"); + + expect(result).toEqual({ keyAliases: [], organizationIds: [], userIds: [] }); + expect(mockKeyListCall).not.toHaveBeenCalled(); + }); + + it("should return empty arrays when teamId is empty", async () => { + const result = await fetchTeamFilterOptions("tok-123", ""); + + expect(result).toEqual({ keyAliases: [], organizationIds: [], userIds: [] }); + expect(mockKeyListCall).not.toHaveBeenCalled(); + }); + + it("should return sorted key aliases from fetched keys", async () => { + mockKeyListCall.mockResolvedValue({ + keys: [ + { key_alias: "zeta-key" }, + { key_alias: "alpha-key" }, + { key_alias: "mid-key" }, + ], + total_pages: 1, + }); + + const result = await fetchTeamFilterOptions("tok-123", "team-1"); + + expect(result.keyAliases).toEqual(["alpha-key", "mid-key", "zeta-key"]); + }); + + it("should deduplicate organization IDs across pages", async () => { + mockKeyListCall + .mockResolvedValueOnce({ + keys: [ + { organization_id: "org-b" }, + { organization_id: "org-a" }, + ], + total_pages: 2, + }) + .mockResolvedValueOnce({ + keys: [ + { organization_id: "org-a" }, + { organization_id: "org-c" }, + ], + total_pages: 2, + }); + + const result = await fetchTeamFilterOptions("tok-123", "team-1"); + + expect(result.organizationIds).toEqual(["org-a", "org-b", "org-c"]); + }); + + it("should map user IDs with email addresses", async () => { + mockKeyListCall.mockResolvedValue({ + keys: [ + { user_id: "u1", user: { user_email: "alice@example.com" } }, + { user_id: "u2", user: { user_email: "bob@example.com" } }, + ], + total_pages: 1, + }); + + const result = await fetchTeamFilterOptions("tok-123", "team-1"); + + expect(result.userIds).toEqual( + expect.arrayContaining([ + { id: "u1", email: "alice@example.com" }, + { id: "u2", email: "bob@example.com" }, + ]), + ); + }); + + it("should handle API errors gracefully and return empty arrays", async () => { + mockKeyListCall.mockRejectedValue(new Error("Network error")); + + const result = await fetchTeamFilterOptions("tok-123", "team-1"); + + expect(result).toEqual({ keyAliases: [], organizationIds: [], userIds: [] }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/key_team_helpers/transform_key_info.test.ts b/ui/litellm-dashboard/src/components/key_team_helpers/transform_key_info.test.ts new file mode 100644 index 0000000000..a1139addbf --- /dev/null +++ b/ui/litellm-dashboard/src/components/key_team_helpers/transform_key_info.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from "vitest"; +import { transformKeyInfo } from "./transform_key_info"; + +describe("transformKeyInfo", () => { + it("should combine key and info fields into a single object", () => { + const apiResponse = { + key: "sk-abc123", + info: { + token_id: "tok_1", + key_name: "my-key", + spend: 10.5, + }, + }; + const result = transformKeyInfo(apiResponse); + expect(result).toEqual({ + token: "sk-abc123", + token_id: "tok_1", + key_name: "my-key", + spend: 10.5, + }); + }); + + it("should set the token field from the key property", () => { + const apiResponse = { + key: "sk-xyz789", + info: { key_name: "test" }, + }; + const result = transformKeyInfo(apiResponse); + expect(result.token).toBe("sk-xyz789"); + }); + + it("should preserve all info fields in the result", () => { + const apiResponse = { + key: "sk-abc", + info: { + token_id: "tok_2", + key_name: "prod-key", + spend: 42, + models: ["gpt-4"], + team_id: "team-1", + metadata: { env: "production" }, + }, + }; + const result = transformKeyInfo(apiResponse); + expect(result.token_id).toBe("tok_2"); + expect(result.key_name).toBe("prod-key"); + expect(result.spend).toBe(42); + expect(result.models).toEqual(["gpt-4"]); + expect(result.team_id).toBe("team-1"); + expect(result.metadata).toEqual({ env: "production" }); + }); + + it("should handle empty info object", () => { + const apiResponse = { + key: "sk-empty", + info: {}, + }; + const result = transformKeyInfo(apiResponse); + expect(result.token).toBe("sk-empty"); + expect(Object.keys(result)).toContain("token"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index d3789fcffa..09ab380942 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -232,8 +232,8 @@ const menuGroups: MenuGroup[] = [ groupLabel: "DEVELOPER TOOLS", items: [ { - key: "api_ref", - page: "api_ref", + key: "api-reference", + page: "api-reference", label: "API Reference", icon: , }, diff --git a/ui/litellm-dashboard/src/components/mcp_tools/ByokCredentialModal.tsx b/ui/litellm-dashboard/src/components/mcp_tools/ByokCredentialModal.tsx index 473918c126..58c2a965e0 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/ByokCredentialModal.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/ByokCredentialModal.tsx @@ -1,7 +1,8 @@ "use client"; import React, { useState } from "react"; -import { Modal, Input, Switch, message } from "antd"; +import { Modal, Input, Switch } from "antd"; +import MessageManager from "@/components/molecules/message_manager"; import { KeyOutlined, LockOutlined, @@ -46,7 +47,7 @@ export const ByokCredentialModal: React.FC = ({ const handleAuthorize = async () => { if (!apiKey.trim()) { - message.error("Please enter your API key"); + MessageManager.error("Please enter your API key"); return; } setLoading(true); @@ -63,11 +64,11 @@ export const ByokCredentialModal: React.FC = ({ const err = await response.json(); throw new Error(err?.detail?.error || "Failed to save credential"); } - message.success(`Connected to ${serverDisplayName}`); + MessageManager.success(`Connected to ${serverDisplayName}`); onSuccess(server.server_id); handleClose(); } catch (e: any) { - message.error(e.message || "Failed to connect"); + MessageManager.error(e.message || "Failed to connect"); } finally { setLoading(false); } diff --git a/ui/litellm-dashboard/src/components/molecules/message_manager.tsx b/ui/litellm-dashboard/src/components/molecules/message_manager.tsx new file mode 100644 index 0000000000..e4c1552d5e --- /dev/null +++ b/ui/litellm-dashboard/src/components/molecules/message_manager.tsx @@ -0,0 +1,38 @@ +import { message as staticMessage } from "antd"; +import type { MessageInstance } from "antd/es/message/interface"; + +let messageInstance: MessageInstance | null = null; + +export const setMessageInstance = (instance: MessageInstance) => { + messageInstance = instance; +}; + +const getMessageApi = () => messageInstance || staticMessage; + +const MessageManager = { + success(content: string, duration?: number) { + getMessageApi().success(content, duration); + }, + + error(content: string, duration?: number) { + getMessageApi().error(content, duration); + }, + + warning(content: string, duration?: number) { + getMessageApi().warning(content, duration); + }, + + info(content: string, duration?: number) { + getMessageApi().info(content, duration); + }, + + loading(content: string, duration?: number) { + return getMessageApi().loading(content, duration); + }, + + destroy() { + getMessageApi().destroy(); + }, +}; + +export default MessageManager; diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 80170a6986..c33ca700fd 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -68,7 +68,7 @@ export const getInProductNudgesCall = async (accessToken: string) => { /** * Helper file for calls being made to proxy */ -import { message } from "antd"; +import MessageManager from "@/components/molecules/message_manager"; import { clearTokenCookies } from "@/utils/cookieUtils"; import { TagNewRequest, TagUpdateRequest, TagListResponse, TagInfoResponse } from "./tag_management/types"; import { Team } from "./key_team_helpers/key_list"; @@ -613,7 +613,7 @@ export const modelCreateCall = async (accessToken: string, formValues: Model) => console.log("API Response:", data); // Close any existing messages before showing new ones - message.destroy(); + MessageManager.destroy(); // Sequential success messages NotificationsManager.success(`Model ${formValues.model_name} created successfully`); diff --git a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx index d0a7a909d6..df5b3a4b32 100644 --- a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx +++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx @@ -8,7 +8,7 @@ import { formatNumberWithCommas } from "@/utils/dataUtils"; import { InfoCircleOutlined } from "@ant-design/icons"; import { useQueryClient } from "@tanstack/react-query"; import { Accordion, AccordionBody, AccordionHeader, Button, Col, Grid, Text, TextInput, Title } from "@tremor/react"; -import { Button as Button2, Form, Input, message, Modal, Radio, Select, Switch, Tag, Tooltip } from "antd"; +import { Button as Button2, Form, Input, Modal, Radio, Select, Switch, Tag, Tooltip } from "antd"; import debounce from "lodash/debounce"; import React, { useCallback, useEffect, useState } from "react"; import { rolesWithWriteAccess } from "../../utils/roles"; diff --git a/ui/litellm-dashboard/src/components/page_metadata.ts b/ui/litellm-dashboard/src/components/page_metadata.ts index a910373d66..fdfc321f38 100644 --- a/ui/litellm-dashboard/src/components/page_metadata.ts +++ b/ui/litellm-dashboard/src/components/page_metadata.ts @@ -24,7 +24,7 @@ export const pageDescriptions: Record = { projects: "Manage projects within teams", "access-groups": "Manage access groups for role-based permissions", budgets: "Set and monitor spending budgets", - api_ref: "Browse API documentation and endpoints", + "api-reference": "Browse API documentation and endpoints", "model-hub-table": "Explore available AI models and providers", "learning-resources": "Access tutorials and documentation", caching: "Configure response caching settings", diff --git a/ui/litellm-dashboard/src/components/playground/chat_ui/ChatUI.tsx b/ui/litellm-dashboard/src/components/playground/chat_ui/ChatUI.tsx index ef57a75062..c0eb89a598 100644 --- a/ui/litellm-dashboard/src/components/playground/chat_ui/ChatUI.tsx +++ b/ui/litellm-dashboard/src/components/playground/chat_ui/ChatUI.tsx @@ -28,7 +28,6 @@ import ReactMarkdown from "react-markdown"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { coy } from "react-syntax-highlighter/dist/esm/styles/prism"; import { v4 as uuidv4 } from "uuid"; -import { truncateString } from "../../../utils/textUtils"; import GuardrailSelector from "../../guardrails/GuardrailSelector"; import PolicySelector from "../../policies/PolicySelector"; import MCPToolArgumentsForm, { MCPToolArgumentsFormRef } from "../../mcp_tools/MCPToolArgumentsForm"; @@ -61,9 +60,8 @@ import CodeInterpreterTool from "./CodeInterpreterTool"; import { generateCodeSnippet } from "./CodeSnippets"; import EndpointSelector from "./EndpointSelector"; import FilePreviewCard from "./FilePreviewCard"; -import MCPEventsDisplay from "./MCPEventsDisplay"; -import type { MCPEvent } from "../../mcp_tools/types"; import ChatMessageBubble from "./ChatMessageBubble"; +import MCPEventsDisplay from "./MCPEventsDisplay"; import { EndpointType, getEndpointType } from "./mode_endpoint_mapping"; import ReasoningContent from "./ReasoningContent"; import ResponseMetrics, { TokenUsage } from "./ResponseMetrics"; @@ -75,6 +73,7 @@ import SessionManagement from "./SessionManagement"; import RealtimePlayground from "./RealtimePlayground"; import { A2ATaskMetadata, MessageType } from "./types"; import { useCodeInterpreter } from "./useCodeInterpreter"; +import { useChatHistory } from "./useChatHistory"; const { TextArea } = Input; const { Dragger } = Upload; @@ -135,6 +134,34 @@ const ChatUI: React.FC = ({ return {}; } }); + const { + chatHistory, + setChatHistory, + mcpEvents, + setMCPEvents, + messageTraceId, + setMessageTraceId, + responsesSessionId, + setResponsesSessionId, + useApiSessionManagement, + setUseApiSessionManagement, + updateTextUI, + updateReasoningContent, + updateTimingData, + updateUsageData, + updateA2AMetadata, + updateTotalLatency, + updateSearchResults, + handleResponseId, + handleToggleSessionManagement, + handleMCPEvent, + updateImageUI, + updateEmbeddingsUI, + updateAudioUI, + updateChatImageUI, + clearChatHistory: clearChatHistoryHook, + clearMCPEvents, + } = useChatHistory({ simplified }); const [apiKeySource, setApiKeySource] = useState<"session" | "custom">(() => { const saved = sessionStorage.getItem("apiKeySource"); if (saved) { @@ -151,16 +178,6 @@ const ChatUI: React.FC = ({ () => sessionStorage.getItem("customProxyBaseUrl") || "", ); const [inputMessage, setInputMessage] = useState(""); - const [chatHistory, setChatHistory] = useState(() => { - if (simplified) return []; - try { - const saved = sessionStorage.getItem("chatHistory"); - return saved ? JSON.parse(saved) : []; - } catch (error) { - console.error("Error parsing chatHistory from sessionStorage", error); - return []; - } - }); const [selectedModel, setSelectedModel] = useState(simplified ? fixedModel : undefined); const [showCustomModelInput, setShowCustomModelInput] = useState(false); const [modelInfo, setModelInfo] = useState([]); @@ -218,16 +235,6 @@ const ChatUI: React.FC = ({ return []; } }); - const [messageTraceId, setMessageTraceId] = useState( - () => sessionStorage.getItem("messageTraceId") || null, - ); - const [responsesSessionId, setResponsesSessionId] = useState( - () => sessionStorage.getItem("responsesSessionId") || null, - ); - const [useApiSessionManagement, setUseApiSessionManagement] = useState(() => { - const saved = sessionStorage.getItem("useApiSessionManagement"); - return saved ? JSON.parse(saved) : true; // Default to API session management - }); const [uploadedImages, setUploadedImages] = useState([]); const [imagePreviewUrls, setImagePreviewUrls] = useState([]); const [responsesUploadedImage, setResponsesUploadedImage] = useState(null); @@ -238,7 +245,6 @@ const ChatUI: React.FC = ({ const [isGetCodeModalVisible, setIsGetCodeModalVisible] = useState(false); const [generatedCode, setGeneratedCode] = useState(""); const [selectedSdk, setSelectedSdk] = useState<"openai" | "azure">("openai"); - const [mcpEvents, setMCPEvents] = useState([]); const [temperature, setTemperature] = useState(1.0); const [maxTokens, setMaxTokens] = useState(2048); const [useAdvancedParams, setUseAdvancedParams] = useState(false); @@ -332,17 +338,6 @@ const ChatUI: React.FC = ({ proxySettings, ]); - useEffect(() => { - if (simplified) return; // Do not persist chat history in simplified (embedded) mode - const handler = setTimeout(() => { - sessionStorage.setItem("chatHistory", JSON.stringify(chatHistory)); - }, 500); // Debounce by 500ms - - return () => { - clearTimeout(handler); - }; - }, [chatHistory, simplified]); - useEffect(() => { sessionStorage.setItem("apiKeySource", JSON.stringify(apiKeySource)); sessionStorage.setItem("apiKey", apiKey); @@ -363,17 +358,6 @@ const ChatUI: React.FC = ({ sessionStorage.removeItem("selectedModel"); } } - if (messageTraceId) { - sessionStorage.setItem("messageTraceId", messageTraceId); - } else { - sessionStorage.removeItem("messageTraceId"); - } - if (responsesSessionId) { - sessionStorage.setItem("responsesSessionId", responsesSessionId); - } else { - sessionStorage.removeItem("responsesSessionId"); - } - sessionStorage.setItem("useApiSessionManagement", JSON.stringify(useApiSessionManagement)); // Note: codeInterpreterEnabled and selectedContainerId are persisted by useCodeInterpreter hook }, [ simplified, @@ -385,9 +369,6 @@ const ChatUI: React.FC = ({ selectedVectorStores, selectedGuardrails, selectedPolicies, - messageTraceId, - responsesSessionId, - useApiSessionManagement, selectedMCPServers, mcpServerToolRestrictions, selectedVoice, @@ -479,264 +460,6 @@ const ChatUI: React.FC = ({ } }, [chatHistory]); - const updateTextUI = (role: string, chunk: string, model?: string) => { - console.log("updateTextUI called with:", role, chunk, model); - setChatHistory((prev) => { - const last = prev[prev.length - 1]; - // if the last message is already from this same role, append - if (last && last.role === role && !last.isImage && !last.isAudio) { - // build a new object, but only set `model` if it wasn't there already - const updated: MessageType = { - ...last, - content: last.content + chunk, - model: last.model ?? model, // ← only use the passed‐in model on the first chunk - }; - return [...prev.slice(0, -1), updated]; - } else { - // otherwise start a brand new assistant bubble - return [ - ...prev, - { - role, - content: chunk, - model, // model set exactly once here - }, - ]; - } - }); - }; - - const updateReasoningContent = (chunk: string) => { - setChatHistory((prevHistory) => { - const lastMessage = prevHistory[prevHistory.length - 1]; - - if (lastMessage && lastMessage.role === "assistant" && !lastMessage.isImage && !lastMessage.isAudio) { - return [ - ...prevHistory.slice(0, prevHistory.length - 1), - { - ...lastMessage, - reasoningContent: (lastMessage.reasoningContent || "") + chunk, - }, - ]; - } else { - // If there's no assistant message yet, we'll create one with empty content - // but with reasoning content - if (prevHistory.length > 0 && prevHistory[prevHistory.length - 1].role === "user") { - return [ - ...prevHistory, - { - role: "assistant", - content: "", - reasoningContent: chunk, - }, - ]; - } - - return prevHistory; - } - }); - }; - - const updateTimingData = (timeToFirstToken: number) => { - console.log("updateTimingData called with:", timeToFirstToken); - setChatHistory((prevHistory) => { - const lastMessage = prevHistory[prevHistory.length - 1]; - console.log("Current last message:", lastMessage); - - if (lastMessage && lastMessage.role === "assistant") { - console.log("Updating assistant message with timeToFirstToken:", timeToFirstToken); - const updatedHistory = [ - ...prevHistory.slice(0, prevHistory.length - 1), - { - ...lastMessage, - timeToFirstToken, - }, - ]; - console.log("Updated chat history:", updatedHistory); - return updatedHistory; - } - // If the last message is a user message and no assistant message exists yet, - // create a new assistant message with empty content - else if (lastMessage && lastMessage.role === "user") { - console.log("Creating new assistant message with timeToFirstToken:", timeToFirstToken); - return [ - ...prevHistory, - { - role: "assistant", - content: "", - timeToFirstToken, - }, - ]; - } - - console.log("No appropriate message found to update timing"); - return prevHistory; - }); - }; - - const updateUsageData = (usage: TokenUsage, toolName?: string) => { - console.log("Received usage data:", usage); - setChatHistory((prevHistory) => { - const lastMessage = prevHistory[prevHistory.length - 1]; - - if (lastMessage && lastMessage.role === "assistant") { - console.log("Updating message with usage data:", usage); - const updatedMessage = { - ...lastMessage, - usage, - toolName, - }; - console.log("Updated message:", updatedMessage); - - return [...prevHistory.slice(0, prevHistory.length - 1), updatedMessage]; - } - - return prevHistory; - }); - }; - - const updateA2AMetadata = (a2aMetadata: A2ATaskMetadata) => { - console.log("Received A2A metadata:", a2aMetadata); - setChatHistory((prevHistory) => { - const lastMessage = prevHistory[prevHistory.length - 1]; - - if (lastMessage && lastMessage.role === "assistant") { - const updatedMessage = { - ...lastMessage, - a2aMetadata, - }; - return [...prevHistory.slice(0, prevHistory.length - 1), updatedMessage]; - } - - return prevHistory; - }); - }; - - const updateTotalLatency = (totalLatency: number) => { - setChatHistory((prevHistory) => { - const lastMessage = prevHistory[prevHistory.length - 1]; - - if (lastMessage && lastMessage.role === "assistant") { - return [ - ...prevHistory.slice(0, prevHistory.length - 1), - { - ...lastMessage, - totalLatency, - }, - ]; - } - - return prevHistory; - }); - }; - - const updateSearchResults = (searchResults: any[]) => { - console.log("Received search results:", searchResults); - setChatHistory((prevHistory) => { - const lastMessage = prevHistory[prevHistory.length - 1]; - - if (lastMessage && lastMessage.role === "assistant") { - console.log("Updating message with search results"); - const updatedMessage = { - ...lastMessage, - searchResults, - }; - - return [...prevHistory.slice(0, prevHistory.length - 1), updatedMessage]; - } - - return prevHistory; - }); - }; - - const handleResponseId = (responseId: string) => { - console.log("Received response ID for session management:", responseId); - if (useApiSessionManagement) { - setResponsesSessionId(responseId); - } - }; - - const handleToggleSessionManagement = (useApi: boolean) => { - setUseApiSessionManagement(useApi); - if (!useApi) { - // Clear API session when switching to UI mode - setResponsesSessionId(null); - } - }; - - const handleMCPEvent = (event: MCPEvent) => { - console.log("ChatUI: Received MCP event:", event); - setMCPEvents((prev) => { - // Check if this is a duplicate event (same item_id and type) - // Only check for duplicates if item_id is defined (for mcp_list_tools, item_id is "mcp_list_tools") - const isDuplicate = event.item_id - ? prev.some( - (existingEvent) => - existingEvent.item_id === event.item_id && - existingEvent.type === event.type && - (existingEvent.sequence_number === event.sequence_number || - (existingEvent.sequence_number === undefined && event.sequence_number === undefined)), - ) - : false; - - if (isDuplicate) { - console.log("ChatUI: Duplicate MCP event, skipping"); - return prev; - } - - const newEvents = [...prev, event]; - console.log("ChatUI: Updated MCP events:", newEvents); - return newEvents; - }); - }; - - const updateImageUI = (imageUrl: string, model: string) => { - setChatHistory((prevHistory) => [...prevHistory, { role: "assistant", content: imageUrl, model, isImage: true }]); - }; - - const updateEmbeddingsUI = (embeddings: string, model?: string) => { - setChatHistory((prevHistory) => [ - ...prevHistory, - { role: "assistant", content: truncateString(embeddings, 100), model, isEmbeddings: true }, - ]); - }; - - const updateAudioUI = (audioUrl: string, model: string) => { - setChatHistory((prevHistory) => [...prevHistory, { role: "assistant", content: audioUrl, model, isAudio: true }]); - }; - - const updateChatImageUI = (imageUrl: string, model?: string) => { - setChatHistory((prev) => { - const last = prev[prev.length - 1]; - // If the last message is from assistant and has content, add image to it - if (last && last.role === "assistant" && !last.isImage && !last.isAudio) { - const updated = { - ...last, - image: { - url: imageUrl, - detail: "auto", - }, - model: last.model ?? model, - }; - return [...prev.slice(0, -1), updated]; - } else { - // Otherwise create a new assistant message with just the image - return [ - ...prev, - { - role: "assistant", - content: "", - model, - image: { - url: imageUrl, - detail: "auto", - }, - }, - ]; - } - }); - }; - const handleKeyDown = (event: React.KeyboardEvent) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); // Prevent default to avoid newline @@ -967,7 +690,7 @@ const ChatUI: React.FC = ({ } setChatHistory([...chatHistory, displayMessage]); - setMCPEvents([]); // Clear previous MCP events for new conversation turn + clearMCPEvents(); // Clear previous MCP events for new conversation turn codeInterpreter.clearResult(); // Clear previous code interpreter results setIsLoading(true); @@ -1223,26 +946,11 @@ const ChatUI: React.FC = ({ }; const clearChatHistory = () => { - // Clean up audio object URLs before clearing history - chatHistory.forEach((message) => { - if (message.isAudio && typeof message.content === "string") { - URL.revokeObjectURL(message.content); - } - }); - - setChatHistory([]); - setMessageTraceId(null); - setResponsesSessionId(null); // Clear responses session ID - setMCPEvents([]); // Clear MCP events - handleRemoveAllImages(); // Clear any uploaded images for image edits - handleRemoveResponsesImage(); // Clear any uploaded images for responses - handleRemoveChatImage(); // Clear any uploaded images for chat completions - handleRemoveAudio(); // Clear any uploaded audio for transcription - if (!simplified) { - sessionStorage.removeItem("chatHistory"); - sessionStorage.removeItem("messageTraceId"); - sessionStorage.removeItem("responsesSessionId"); - } + clearChatHistoryHook(); + handleRemoveAllImages(); + handleRemoveResponsesImage(); + handleRemoveChatImage(); + handleRemoveAudio(); NotificationsManager.success("Chat history cleared."); }; diff --git a/ui/litellm-dashboard/src/components/playground/chat_ui/CodeInterpreterTool.tsx b/ui/litellm-dashboard/src/components/playground/chat_ui/CodeInterpreterTool.tsx index 04711ede9f..dd37769556 100644 --- a/ui/litellm-dashboard/src/components/playground/chat_ui/CodeInterpreterTool.tsx +++ b/ui/litellm-dashboard/src/components/playground/chat_ui/CodeInterpreterTool.tsx @@ -1,5 +1,6 @@ import React from "react"; -import { Switch, Tooltip, message } from "antd"; +import { Switch, Tooltip } from "antd"; +import MessageManager from "@/components/molecules/message_manager"; import { CodeOutlined, InfoCircleOutlined, ExclamationCircleOutlined } from "@ant-design/icons"; import { Text } from "@tremor/react"; @@ -38,7 +39,7 @@ const CodeInterpreterTool: React.FC = ({ const handleToggle = (checked: boolean) => { if (checked && !isOpenAI) { - message.warning("Code Interpreter is only available for OpenAI models"); + MessageManager.warning("Code Interpreter is only available for OpenAI models"); return; } onEnabledChange(checked); diff --git a/ui/litellm-dashboard/src/components/playground/chat_ui/useChatHistory.test.ts b/ui/litellm-dashboard/src/components/playground/chat_ui/useChatHistory.test.ts new file mode 100644 index 0000000000..c2067fc698 --- /dev/null +++ b/ui/litellm-dashboard/src/components/playground/chat_ui/useChatHistory.test.ts @@ -0,0 +1,591 @@ +import { renderHook, act } from "@testing-library/react"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { useChatHistory } from "./useChatHistory"; + +describe("useChatHistory", () => { + beforeEach(() => { + sessionStorage.clear(); + }); + + describe("updateTextUI", () => { + it("should create a new assistant message when chat is empty", () => { + const { result } = renderHook(() => useChatHistory({ simplified: false })); + + act(() => { + result.current.updateTextUI("assistant", "Hello", "gpt-4"); + }); + + expect(result.current.chatHistory).toEqual([ + { role: "assistant", content: "Hello", model: "gpt-4" }, + ]); + }); + + it("should append to the last assistant message", () => { + const { result } = renderHook(() => useChatHistory({ simplified: false })); + + act(() => { + result.current.updateTextUI("assistant", "Hello", "gpt-4"); + }); + act(() => { + result.current.updateTextUI("assistant", " world"); + }); + + expect(result.current.chatHistory).toEqual([ + { role: "assistant", content: "Hello world", model: "gpt-4" }, + ]); + }); + + it("should not overwrite model on subsequent chunks", () => { + const { result } = renderHook(() => useChatHistory({ simplified: false })); + + act(() => { + result.current.updateTextUI("assistant", "Hello", "gpt-4"); + }); + act(() => { + result.current.updateTextUI("assistant", " world", "gpt-3.5"); + }); + + expect(result.current.chatHistory[0].model).toBe("gpt-4"); + }); + + it("should create a new message when role changes", () => { + const { result } = renderHook(() => useChatHistory({ simplified: false })); + + act(() => { + result.current.updateTextUI("user", "Hi"); + }); + act(() => { + result.current.updateTextUI("assistant", "Hello", "gpt-4"); + }); + + expect(result.current.chatHistory).toHaveLength(2); + expect(result.current.chatHistory[0].role).toBe("user"); + expect(result.current.chatHistory[1].role).toBe("assistant"); + }); + + it("should not append to image messages", () => { + const { result } = renderHook(() => useChatHistory({ simplified: false })); + + act(() => { + result.current.updateImageUI("http://img.png", "dall-e"); + }); + act(() => { + result.current.updateTextUI("assistant", "description", "gpt-4"); + }); + + expect(result.current.chatHistory).toHaveLength(2); + }); + + it("should not append to audio messages", () => { + const { result } = renderHook(() => useChatHistory({ simplified: false })); + + act(() => { + result.current.updateAudioUI("http://audio.mp3", "tts-1"); + }); + act(() => { + result.current.updateTextUI("assistant", "text", "gpt-4"); + }); + + expect(result.current.chatHistory).toHaveLength(2); + }); + }); + + describe("updateReasoningContent", () => { + it("should add reasoning content to existing assistant message", () => { + const { result } = renderHook(() => useChatHistory({ simplified: false })); + + act(() => { + result.current.updateTextUI("assistant", "Answer", "gpt-4"); + }); + act(() => { + result.current.updateReasoningContent("thinking..."); + }); + + expect(result.current.chatHistory[0].reasoningContent).toBe("thinking..."); + }); + + it("should append reasoning content across chunks", () => { + const { result } = renderHook(() => useChatHistory({ simplified: false })); + + act(() => { + result.current.updateTextUI("assistant", "", "gpt-4"); + }); + act(() => { + result.current.updateReasoningContent("step 1"); + }); + act(() => { + result.current.updateReasoningContent(" step 2"); + }); + + expect(result.current.chatHistory[0].reasoningContent).toBe("step 1 step 2"); + }); + + it("should create assistant message with reasoning when last message is user", () => { + const { result } = renderHook(() => useChatHistory({ simplified: false })); + + act(() => { + result.current.setChatHistory([{ role: "user", content: "question" }]); + }); + act(() => { + result.current.updateReasoningContent("thinking..."); + }); + + expect(result.current.chatHistory).toHaveLength(2); + expect(result.current.chatHistory[1]).toEqual({ + role: "assistant", + content: "", + reasoningContent: "thinking...", + }); + }); + + it("should not update when chat is empty", () => { + const { result } = renderHook(() => useChatHistory({ simplified: false })); + + act(() => { + result.current.updateReasoningContent("thinking..."); + }); + + expect(result.current.chatHistory).toHaveLength(0); + }); + }); + + describe("updateTimingData", () => { + it("should add timeToFirstToken to existing assistant message", () => { + const { result } = renderHook(() => useChatHistory({ simplified: false })); + + act(() => { + result.current.updateTextUI("assistant", "Hello", "gpt-4"); + }); + act(() => { + result.current.updateTimingData(150); + }); + + expect(result.current.chatHistory[0].timeToFirstToken).toBe(150); + }); + + it("should create assistant message when last is user", () => { + const { result } = renderHook(() => useChatHistory({ simplified: false })); + + act(() => { + result.current.setChatHistory([{ role: "user", content: "hi" }]); + }); + act(() => { + result.current.updateTimingData(200); + }); + + expect(result.current.chatHistory).toHaveLength(2); + expect(result.current.chatHistory[1].timeToFirstToken).toBe(200); + }); + }); + + describe("updateUsageData", () => { + it("should add usage data to assistant message", () => { + const { result } = renderHook(() => useChatHistory({ simplified: false })); + + act(() => { + result.current.updateTextUI("assistant", "Hello", "gpt-4"); + }); + + const usage = { completionTokens: 10, promptTokens: 5, totalTokens: 15 }; + act(() => { + result.current.updateUsageData(usage); + }); + + expect(result.current.chatHistory[0].usage).toEqual(usage); + }); + + it("should add toolName when provided", () => { + const { result } = renderHook(() => useChatHistory({ simplified: false })); + + act(() => { + result.current.updateTextUI("assistant", "Hello", "gpt-4"); + }); + + const usage = { completionTokens: 10, promptTokens: 5, totalTokens: 15 }; + act(() => { + result.current.updateUsageData(usage, "search_tool"); + }); + + expect(result.current.chatHistory[0].toolName).toBe("search_tool"); + }); + }); + + describe("updateTotalLatency", () => { + it("should add totalLatency to assistant message", () => { + const { result } = renderHook(() => useChatHistory({ simplified: false })); + + act(() => { + result.current.updateTextUI("assistant", "Hello", "gpt-4"); + }); + act(() => { + result.current.updateTotalLatency(500); + }); + + expect(result.current.chatHistory[0].totalLatency).toBe(500); + }); + }); + + describe("updateA2AMetadata", () => { + it("should add A2A metadata to assistant message", () => { + const { result } = renderHook(() => useChatHistory({ simplified: false })); + + act(() => { + result.current.updateTextUI("assistant", "Hello", "gpt-4"); + }); + + const metadata = { taskId: "task-1", contextId: "ctx-1" }; + act(() => { + result.current.updateA2AMetadata(metadata); + }); + + expect(result.current.chatHistory[0].a2aMetadata).toEqual(metadata); + }); + }); + + describe("updateSearchResults", () => { + it("should add search results to assistant message", () => { + const { result } = renderHook(() => useChatHistory({ simplified: false })); + + act(() => { + result.current.updateTextUI("assistant", "Hello", "gpt-4"); + }); + + const searchResults = [{ object: "search", search_query: "test", data: [] }]; + act(() => { + result.current.updateSearchResults(searchResults); + }); + + expect(result.current.chatHistory[0].searchResults).toEqual(searchResults); + }); + }); + + describe("updateImageUI", () => { + it("should add image message to history", () => { + const { result } = renderHook(() => useChatHistory({ simplified: false })); + + act(() => { + result.current.updateImageUI("http://img.png", "dall-e-3"); + }); + + expect(result.current.chatHistory).toEqual([ + { role: "assistant", content: "http://img.png", model: "dall-e-3", isImage: true }, + ]); + }); + }); + + describe("updateEmbeddingsUI", () => { + it("should add truncated embeddings message", () => { + const { result } = renderHook(() => useChatHistory({ simplified: false })); + + act(() => { + result.current.updateEmbeddingsUI("[0.1, 0.2, 0.3]", "text-embedding-ada"); + }); + + expect(result.current.chatHistory[0].isEmbeddings).toBe(true); + expect(result.current.chatHistory[0].model).toBe("text-embedding-ada"); + }); + }); + + describe("updateAudioUI", () => { + it("should add audio message to history", () => { + const { result } = renderHook(() => useChatHistory({ simplified: false })); + + act(() => { + result.current.updateAudioUI("http://audio.mp3", "tts-1"); + }); + + expect(result.current.chatHistory).toEqual([ + { role: "assistant", content: "http://audio.mp3", model: "tts-1", isAudio: true }, + ]); + }); + }); + + describe("updateChatImageUI", () => { + it("should add image to existing assistant message", () => { + const { result } = renderHook(() => useChatHistory({ simplified: false })); + + act(() => { + result.current.updateTextUI("assistant", "Here is the image", "gpt-4"); + }); + act(() => { + result.current.updateChatImageUI("http://img.png", "gpt-4"); + }); + + expect(result.current.chatHistory[0].image).toEqual({ + url: "http://img.png", + detail: "auto", + }); + }); + + it("should create new assistant message with image when no assistant message exists", () => { + const { result } = renderHook(() => useChatHistory({ simplified: false })); + + act(() => { + result.current.updateChatImageUI("http://img.png", "gpt-4"); + }); + + expect(result.current.chatHistory[0]).toEqual({ + role: "assistant", + content: "", + model: "gpt-4", + image: { url: "http://img.png", detail: "auto" }, + }); + }); + }); + + describe("handleMCPEvent", () => { + it("should add MCP event", () => { + const { result } = renderHook(() => useChatHistory({ simplified: false })); + + act(() => { + result.current.handleMCPEvent({ type: "tool_call", item_id: "1" }); + }); + + expect(result.current.mcpEvents).toHaveLength(1); + }); + + it("should deduplicate events by item_id and type", () => { + const { result } = renderHook(() => useChatHistory({ simplified: false })); + + const event = { type: "tool_call", item_id: "1" }; + act(() => { + result.current.handleMCPEvent(event); + }); + act(() => { + result.current.handleMCPEvent(event); + }); + + expect(result.current.mcpEvents).toHaveLength(1); + }); + + it("should allow events without item_id (no dedup)", () => { + const { result } = renderHook(() => useChatHistory({ simplified: false })); + + act(() => { + result.current.handleMCPEvent({ type: "tool_call" }); + }); + act(() => { + result.current.handleMCPEvent({ type: "tool_call" }); + }); + + expect(result.current.mcpEvents).toHaveLength(2); + }); + + it("should allow events with same item_id/type but different sequence_number", () => { + const { result } = renderHook(() => useChatHistory({ simplified: false })); + + act(() => { + result.current.handleMCPEvent({ type: "tool_call", item_id: "1", sequence_number: 1 }); + }); + act(() => { + result.current.handleMCPEvent({ type: "tool_call", item_id: "1", sequence_number: 2 }); + }); + + expect(result.current.mcpEvents).toHaveLength(2); + }); + }); + + describe("clearMCPEvents", () => { + it("should clear MCP events without affecting chat history", () => { + const { result } = renderHook(() => useChatHistory({ simplified: false })); + + act(() => { + result.current.updateTextUI("assistant", "Hello", "gpt-4"); + result.current.handleMCPEvent({ type: "tool_call", item_id: "1" }); + }); + act(() => { + result.current.clearMCPEvents(); + }); + + expect(result.current.mcpEvents).toEqual([]); + expect(result.current.chatHistory).toHaveLength(1); + }); + }); + + describe("clearChatHistory", () => { + it("should clear all state", () => { + const { result } = renderHook(() => useChatHistory({ simplified: false })); + + act(() => { + result.current.updateTextUI("assistant", "Hello", "gpt-4"); + result.current.handleMCPEvent({ type: "tool_call", item_id: "1" }); + }); + act(() => { + result.current.clearChatHistory(); + }); + + expect(result.current.chatHistory).toEqual([]); + expect(result.current.mcpEvents).toEqual([]); + expect(result.current.messageTraceId).toBeNull(); + expect(result.current.responsesSessionId).toBeNull(); + }); + + it("should revoke audio object URLs when clearing", () => { + const revokeSpy = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {}); + const { result } = renderHook(() => useChatHistory({ simplified: false })); + + act(() => { + result.current.updateAudioUI("blob:http://localhost/audio-1", "tts-1"); + }); + act(() => { + result.current.clearChatHistory(); + }); + + expect(revokeSpy).toHaveBeenCalledWith("blob:http://localhost/audio-1"); + revokeSpy.mockRestore(); + }); + + it("should clear sessionStorage when not simplified", () => { + vi.useFakeTimers(); + const { result } = renderHook(() => useChatHistory({ simplified: false })); + + sessionStorage.setItem("chatHistory", "[]"); + sessionStorage.setItem("messageTraceId", "trace-1"); + sessionStorage.setItem("responsesSessionId", "resp-1"); + + act(() => { + result.current.clearChatHistory(); + }); + + // Advance past the 500ms debounce to verify it does not re-write the key + act(() => { + vi.advanceTimersByTime(600); + }); + + expect(sessionStorage.getItem("chatHistory")).toBeNull(); + expect(sessionStorage.getItem("messageTraceId")).toBeNull(); + expect(sessionStorage.getItem("responsesSessionId")).toBeNull(); + + vi.useRealTimers(); + }); + + it("should NOT clear sessionStorage when simplified", () => { + sessionStorage.setItem("chatHistory", '[{"role":"user","content":"hi"}]'); + + const { result } = renderHook(() => useChatHistory({ simplified: true })); + + act(() => { + result.current.clearChatHistory(); + }); + + // simplified mode should not touch sessionStorage + expect(sessionStorage.getItem("chatHistory")).toBe('[{"role":"user","content":"hi"}]'); + }); + + it("should not re-write chatHistory to sessionStorage after clear via debounce", () => { + vi.useFakeTimers(); + const { result } = renderHook(() => useChatHistory({ simplified: false })); + + // Add a message so the debounce has something to persist + act(() => { + result.current.updateTextUI("assistant", "Hello", "gpt-4"); + }); + + // Let the debounce fire so the message is persisted + act(() => { + vi.advanceTimersByTime(600); + }); + expect(sessionStorage.getItem("chatHistory")).not.toBeNull(); + + // Now clear + act(() => { + result.current.clearChatHistory(); + }); + + // Advance past the debounce — the key should stay removed + act(() => { + vi.advanceTimersByTime(600); + }); + + expect(sessionStorage.getItem("chatHistory")).toBeNull(); + + vi.useRealTimers(); + }); + }); + + describe("simplified mode session isolation", () => { + it("should not hydrate messageTraceId from sessionStorage in simplified mode", () => { + sessionStorage.setItem("messageTraceId", "trace-from-playground"); + + const { result } = renderHook(() => useChatHistory({ simplified: true })); + + expect(result.current.messageTraceId).toBeNull(); + }); + + it("should not hydrate responsesSessionId from sessionStorage in simplified mode", () => { + sessionStorage.setItem("responsesSessionId", "resp-from-playground"); + + const { result } = renderHook(() => useChatHistory({ simplified: true })); + + expect(result.current.responsesSessionId).toBeNull(); + }); + + it("should not hydrate useApiSessionManagement from sessionStorage in simplified mode", () => { + sessionStorage.setItem("useApiSessionManagement", "false"); + + const { result } = renderHook(() => useChatHistory({ simplified: true })); + + // Should get the default (true), not the stored value + expect(result.current.useApiSessionManagement).toBe(true); + }); + + it("should not persist session state to sessionStorage in simplified mode", () => { + vi.useFakeTimers(); + const { result } = renderHook(() => useChatHistory({ simplified: true })); + + act(() => { + result.current.setMessageTraceId("trace-embedded"); + result.current.setResponsesSessionId("resp-embedded"); + }); + + // Flush effects + act(() => { + vi.advanceTimersByTime(0); + }); + + expect(sessionStorage.getItem("messageTraceId")).toBeNull(); + expect(sessionStorage.getItem("responsesSessionId")).toBeNull(); + + vi.useRealTimers(); + }); + }); + + describe("session management", () => { + it("handleResponseId should set responsesSessionId when useApiSessionManagement is true", () => { + const { result } = renderHook(() => useChatHistory({ simplified: false })); + + act(() => { + result.current.handleResponseId("resp-123"); + }); + + expect(result.current.responsesSessionId).toBe("resp-123"); + }); + + it("handleResponseId should NOT set responsesSessionId when useApiSessionManagement is false", () => { + const { result } = renderHook(() => useChatHistory({ simplified: false })); + + act(() => { + result.current.handleToggleSessionManagement(false); + }); + act(() => { + result.current.handleResponseId("resp-123"); + }); + + expect(result.current.responsesSessionId).toBeNull(); + }); + + it("handleToggleSessionManagement should clear session when switching to UI mode", () => { + const { result } = renderHook(() => useChatHistory({ simplified: false })); + + act(() => { + result.current.handleResponseId("resp-123"); + }); + act(() => { + result.current.handleToggleSessionManagement(false); + }); + + expect(result.current.useApiSessionManagement).toBe(false); + expect(result.current.responsesSessionId).toBeNull(); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/playground/chat_ui/useChatHistory.ts b/ui/litellm-dashboard/src/components/playground/chat_ui/useChatHistory.ts new file mode 100644 index 0000000000..8e38191259 --- /dev/null +++ b/ui/litellm-dashboard/src/components/playground/chat_ui/useChatHistory.ts @@ -0,0 +1,392 @@ +import React, { useState, useEffect } from "react"; +import { MessageType, A2ATaskMetadata } from "./types"; +import { TokenUsage } from "./ResponseMetrics"; +import { MCPEvent } from "../../mcp_tools/types"; +import { truncateString } from "../../../utils/textUtils"; + +export interface UseChatHistoryReturn { + // State + chatHistory: MessageType[]; + setChatHistory: React.Dispatch>; + mcpEvents: MCPEvent[]; + setMCPEvents: React.Dispatch>; + messageTraceId: string | null; + setMessageTraceId: React.Dispatch>; + responsesSessionId: string | null; + setResponsesSessionId: React.Dispatch>; + useApiSessionManagement: boolean; + setUseApiSessionManagement: React.Dispatch>; + + // Actions + updateTextUI: (role: string, chunk: string, model?: string) => void; + updateReasoningContent: (chunk: string) => void; + updateTimingData: (timeToFirstToken: number) => void; + updateUsageData: (usage: TokenUsage, toolName?: string) => void; + updateA2AMetadata: (a2aMetadata: A2ATaskMetadata) => void; + updateTotalLatency: (totalLatency: number) => void; + updateSearchResults: (searchResults: any[]) => void; + handleResponseId: (responseId: string) => void; + handleToggleSessionManagement: (useApi: boolean) => void; + handleMCPEvent: (event: MCPEvent) => void; + updateImageUI: (imageUrl: string, model: string) => void; + updateEmbeddingsUI: (embeddings: string, model?: string) => void; + updateAudioUI: (audioUrl: string, model: string) => void; + updateChatImageUI: (imageUrl: string, model?: string) => void; + clearChatHistory: () => void; + clearMCPEvents: () => void; +} + +export function useChatHistory({ simplified }: { simplified: boolean }): UseChatHistoryReturn { + const [chatHistory, setChatHistory] = useState(() => { + if (simplified) return []; + try { + const saved = sessionStorage.getItem("chatHistory"); + return saved ? JSON.parse(saved) : []; + } catch (error) { + console.error("Error parsing chatHistory from sessionStorage", error); + return []; + } + }); + + const [mcpEvents, setMCPEvents] = useState([]); + + const [messageTraceId, setMessageTraceId] = useState( + () => (simplified ? null : sessionStorage.getItem("messageTraceId") || null), + ); + + const [responsesSessionId, setResponsesSessionId] = useState( + () => (simplified ? null : sessionStorage.getItem("responsesSessionId") || null), + ); + + const [useApiSessionManagement, setUseApiSessionManagement] = useState(() => { + if (simplified) return true; + const saved = sessionStorage.getItem("useApiSessionManagement"); + return saved ? JSON.parse(saved) : true; // Default to API session management + }); + + // Debounced chatHistory persistence + useEffect(() => { + if (simplified) return; // Do not persist chat history in simplified (embedded) mode + // When chatHistory is empty (e.g. after clearChatHistory removed the key), + // don't re-write an empty array back into sessionStorage. + if (chatHistory.length === 0) return; + const handler = setTimeout(() => { + sessionStorage.setItem("chatHistory", JSON.stringify(chatHistory)); + }, 500); // Debounce by 500ms + + return () => { + clearTimeout(handler); + }; + }, [chatHistory, simplified]); + + // messageTraceId/responsesSessionId/useApiSessionManagement persistence + useEffect(() => { + if (simplified) return; + if (messageTraceId) { + sessionStorage.setItem("messageTraceId", messageTraceId); + } else { + sessionStorage.removeItem("messageTraceId"); + } + if (responsesSessionId) { + sessionStorage.setItem("responsesSessionId", responsesSessionId); + } else { + sessionStorage.removeItem("responsesSessionId"); + } + sessionStorage.setItem("useApiSessionManagement", JSON.stringify(useApiSessionManagement)); + }, [messageTraceId, responsesSessionId, useApiSessionManagement, simplified]); + + const updateTextUI = (role: string, chunk: string, model?: string) => { + setChatHistory((prev) => { + const last = prev[prev.length - 1]; + // if the last message is already from this same role, append + if (last && last.role === role && !last.isImage && !last.isAudio) { + // build a new object, but only set `model` if it wasn't there already + const updated: MessageType = { + ...last, + content: last.content + chunk, + model: last.model ?? model, // ← only use the passed‐in model on the first chunk + }; + return [...prev.slice(0, -1), updated]; + } else { + // otherwise start a brand new assistant bubble + return [ + ...prev, + { + role, + content: chunk, + model, // model set exactly once here + }, + ]; + } + }); + }; + + const updateReasoningContent = (chunk: string) => { + setChatHistory((prevHistory) => { + const lastMessage = prevHistory[prevHistory.length - 1]; + + if (lastMessage && lastMessage.role === "assistant" && !lastMessage.isImage && !lastMessage.isAudio) { + return [ + ...prevHistory.slice(0, prevHistory.length - 1), + { + ...lastMessage, + reasoningContent: (lastMessage.reasoningContent || "") + chunk, + }, + ]; + } else { + // If there's no assistant message yet, we'll create one with empty content + // but with reasoning content + if (prevHistory.length > 0 && prevHistory[prevHistory.length - 1].role === "user") { + return [ + ...prevHistory, + { + role: "assistant", + content: "", + reasoningContent: chunk, + }, + ]; + } + + return prevHistory; + } + }); + }; + + const updateTimingData = (timeToFirstToken: number) => { + setChatHistory((prevHistory) => { + const lastMessage = prevHistory[prevHistory.length - 1]; + + if (lastMessage && lastMessage.role === "assistant") { + return [ + ...prevHistory.slice(0, prevHistory.length - 1), + { + ...lastMessage, + timeToFirstToken, + }, + ]; + } + // If the last message is a user message and no assistant message exists yet, + // create a new assistant message with empty content + else if (lastMessage && lastMessage.role === "user") { + return [ + ...prevHistory, + { + role: "assistant", + content: "", + timeToFirstToken, + }, + ]; + } + + return prevHistory; + }); + }; + + const updateUsageData = (usage: TokenUsage, toolName?: string) => { + setChatHistory((prevHistory) => { + const lastMessage = prevHistory[prevHistory.length - 1]; + + if (lastMessage && lastMessage.role === "assistant") { + const updatedMessage = { + ...lastMessage, + usage, + toolName, + }; + + return [...prevHistory.slice(0, prevHistory.length - 1), updatedMessage]; + } + + return prevHistory; + }); + }; + + const updateA2AMetadata = (a2aMetadata: A2ATaskMetadata) => { + setChatHistory((prevHistory) => { + const lastMessage = prevHistory[prevHistory.length - 1]; + + if (lastMessage && lastMessage.role === "assistant") { + const updatedMessage = { + ...lastMessage, + a2aMetadata, + }; + return [...prevHistory.slice(0, prevHistory.length - 1), updatedMessage]; + } + + return prevHistory; + }); + }; + + const updateTotalLatency = (totalLatency: number) => { + setChatHistory((prevHistory) => { + const lastMessage = prevHistory[prevHistory.length - 1]; + + if (lastMessage && lastMessage.role === "assistant") { + return [ + ...prevHistory.slice(0, prevHistory.length - 1), + { + ...lastMessage, + totalLatency, + }, + ]; + } + + return prevHistory; + }); + }; + + const updateSearchResults = (searchResults: any[]) => { + setChatHistory((prevHistory) => { + const lastMessage = prevHistory[prevHistory.length - 1]; + + if (lastMessage && lastMessage.role === "assistant") { + const updatedMessage = { + ...lastMessage, + searchResults, + }; + + return [...prevHistory.slice(0, prevHistory.length - 1), updatedMessage]; + } + + return prevHistory; + }); + }; + + const handleResponseId = (responseId: string) => { + if (useApiSessionManagement) { + setResponsesSessionId(responseId); + } + }; + + const handleToggleSessionManagement = (useApi: boolean) => { + setUseApiSessionManagement(useApi); + if (!useApi) { + // Clear API session when switching to UI mode + setResponsesSessionId(null); + } + }; + + const handleMCPEvent = (event: MCPEvent) => { + setMCPEvents((prev) => { + // Check if this is a duplicate event (same item_id and type) + // Only check for duplicates if item_id is defined (for mcp_list_tools, item_id is "mcp_list_tools") + const isDuplicate = event.item_id + ? prev.some( + (existingEvent) => + existingEvent.item_id === event.item_id && + existingEvent.type === event.type && + (existingEvent.sequence_number === event.sequence_number || + (existingEvent.sequence_number === undefined && event.sequence_number === undefined)), + ) + : false; + + if (isDuplicate) { + return prev; + } + + return [...prev, event]; + }); + }; + + const updateImageUI = (imageUrl: string, model: string) => { + setChatHistory((prevHistory) => [...prevHistory, { role: "assistant", content: imageUrl, model, isImage: true }]); + }; + + const updateEmbeddingsUI = (embeddings: string, model?: string) => { + setChatHistory((prevHistory) => [ + ...prevHistory, + { role: "assistant", content: truncateString(embeddings, 100), model, isEmbeddings: true }, + ]); + }; + + const updateAudioUI = (audioUrl: string, model: string) => { + setChatHistory((prevHistory) => [...prevHistory, { role: "assistant", content: audioUrl, model, isAudio: true }]); + }; + + const updateChatImageUI = (imageUrl: string, model?: string) => { + setChatHistory((prev) => { + const last = prev[prev.length - 1]; + // If the last message is from assistant and has content, add image to it + if (last && last.role === "assistant" && !last.isImage && !last.isAudio) { + const updated = { + ...last, + image: { + url: imageUrl, + detail: "auto", + }, + model: last.model ?? model, + }; + return [...prev.slice(0, -1), updated]; + } else { + // Otherwise create a new assistant message with just the image + return [ + ...prev, + { + role: "assistant", + content: "", + model, + image: { + url: imageUrl, + detail: "auto", + }, + }, + ]; + } + }); + }; + + const clearChatHistory = () => { + // Use functional updater to get the latest snapshot — avoids stale-closure + // bugs where audio messages added between the last render and the click + // would leak their blob URLs. + setChatHistory((prev) => { + prev.forEach((message) => { + if (message.isAudio && typeof message.content === "string") { + URL.revokeObjectURL(message.content); + } + }); + return []; + }); + + setMessageTraceId(null); + setResponsesSessionId(null); // Clear responses session ID + setMCPEvents([]); // Clear MCP events + if (!simplified) { + sessionStorage.removeItem("chatHistory"); + sessionStorage.removeItem("messageTraceId"); + sessionStorage.removeItem("responsesSessionId"); + } + }; + + const clearMCPEvents = () => { + setMCPEvents([]); + }; + + return { + chatHistory, + setChatHistory, + mcpEvents, + setMCPEvents, + messageTraceId, + setMessageTraceId, + responsesSessionId, + setResponsesSessionId, + useApiSessionManagement, + setUseApiSessionManagement, + updateTextUI, + updateReasoningContent, + updateTimingData, + updateUsageData, + updateA2AMetadata, + updateTotalLatency, + updateSearchResults, + handleResponseId, + handleToggleSessionManagement, + handleMCPEvent, + updateImageUI, + updateEmbeddingsUI, + updateAudioUI, + updateChatImageUI, + clearChatHistory, + clearMCPEvents, + }; +} diff --git a/ui/litellm-dashboard/src/components/policies/index.tsx b/ui/litellm-dashboard/src/components/policies/index.tsx index bd77f3ab9b..f47b8d78b9 100644 --- a/ui/litellm-dashboard/src/components/policies/index.tsx +++ b/ui/litellm-dashboard/src/components/policies/index.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect, useCallback } from "react"; import { Button, TabGroup, TabList, Tab, TabPanels, TabPanel } from "@tremor/react"; -import { Modal, message, Alert } from "antd"; +import { Modal, Alert } from "antd"; +import MessageManager from "@/components/molecules/message_manager"; import { ExclamationCircleOutlined, InfoCircleOutlined } from "@ant-design/icons"; import { isAdminRole } from "@/utils/roles"; import PolicyTable from "./policy_table"; @@ -80,7 +81,7 @@ const PoliciesPanel: React.FC = ({ setPoliciesList(response.policies || []); } catch (error) { console.error("Error fetching policies:", error); - message.error("Failed to fetch policies"); + MessageManager.error("Failed to fetch policies"); } finally { setIsLoading(false); } @@ -95,7 +96,7 @@ const PoliciesPanel: React.FC = ({ setAttachmentsList(response.attachments || []); } catch (error) { console.error("Error fetching attachments:", error); - message.error("Failed to fetch attachments"); + MessageManager.error("Failed to fetch attachments"); } finally { setIsAttachmentsLoading(false); } @@ -148,11 +149,11 @@ const PoliciesPanel: React.FC = ({ setIsDeleting(true); try { await deletePolicyCall(accessToken, policyToDelete.policy_id); - message.success(`Policy "${policyToDelete.policy_name}" deleted successfully`); + MessageManager.success(`Policy "${policyToDelete.policy_name}" deleted successfully`); await fetchPolicies(); } catch (error) { console.error("Error deleting policy:", error); - message.error("Failed to delete policy"); + MessageManager.error("Failed to delete policy"); } finally { setIsDeleting(false); setIsDeleteModalOpen(false); @@ -177,11 +178,11 @@ const PoliciesPanel: React.FC = ({ if (!accessToken) return; try { await deletePolicyAttachmentCall(accessToken, attachmentId); - message.success("Attachment deleted successfully"); + MessageManager.success("Attachment deleted successfully"); fetchAttachments(); } catch (error) { console.error("Error deleting attachment:", error); - message.error("Failed to delete attachment"); + MessageManager.error("Failed to delete attachment"); } }, }); @@ -193,7 +194,7 @@ const PoliciesPanel: React.FC = ({ const handleUseTemplate = async (template: any) => { if (!accessToken) { - message.error("Authentication required"); + MessageManager.error("Authentication required"); return; } @@ -221,7 +222,7 @@ const PoliciesPanel: React.FC = ({ setIsGuardrailSelectionModalOpen(true); } catch (error) { console.error("Error fetching guardrails:", error); - message.error("Failed to load guardrails. Please try again."); + MessageManager.error("Failed to load guardrails. Please try again."); } }; @@ -271,7 +272,7 @@ const PoliciesPanel: React.FC = ({ await proceedWithTemplate(enrichedTemplate); } catch (error) { console.error("Error enriching template:", error); - message.error("Failed to configure template. Please try again."); + MessageManager.error("Failed to configure template. Please try again."); setIsEnrichingTemplate(false); } }; @@ -318,15 +319,15 @@ const PoliciesPanel: React.FC = ({ // Show success message if (createdGuardrails.length > 0) { - message.success( + MessageManager.success( `Created ${createdGuardrails.length} guardrail${createdGuardrails.length > 1 ? "s" : ""}! Complete the policy form to save.` ); } else { - message.success("Template ready! Complete the policy form to save."); + MessageManager.success("Template ready! Complete the policy form to save."); } if (failedGuardrails.length > 0) { - message.warning( + MessageManager.warning( `Failed to create ${failedGuardrails.length} guardrail(s): ${failedGuardrails.join(", ")}. You may need to create them manually.` ); } @@ -348,7 +349,7 @@ const PoliciesPanel: React.FC = ({ setTemplateQueue([]); setTemplateQueueProgress(null); console.error("Error creating guardrails:", error); - message.error("Failed to create guardrails. Please try again."); + MessageManager.error("Failed to create guardrails. Please try again."); } }; diff --git a/ui/litellm-dashboard/src/components/policies/pipeline_flow_builder.tsx b/ui/litellm-dashboard/src/components/policies/pipeline_flow_builder.tsx index 89665774ee..b1768d5b81 100644 --- a/ui/litellm-dashboard/src/components/policies/pipeline_flow_builder.tsx +++ b/ui/litellm-dashboard/src/components/policies/pipeline_flow_builder.tsx @@ -1,5 +1,6 @@ import React, { useState } from "react"; -import { Select, Typography, message, Spin } from "antd"; +import { Select, Typography, Spin } from "antd"; +import MessageManager from "@/components/molecules/message_manager"; import { Button, TextInput } from "@tremor/react"; import { ArrowLeftIcon, PlusIcon } from "@heroicons/react/outline"; import { DotsVerticalIcon } from "@heroicons/react/solid"; @@ -1385,17 +1386,17 @@ export const FlowBuilderPage: React.FC = ({ const handleSave = async () => { if (!policyName.trim()) { - message.error("Please enter a policy name"); + MessageManager.error("Please enter a policy name"); return; } if (!accessToken) { - message.error("No access token available"); + MessageManager.error("No access token available"); return; } const emptySteps = pipeline.steps.filter((s) => !s.guardrail); if (emptySteps.length > 0) { - message.error("Please select a guardrail for all steps"); + MessageManager.error("Please select a guardrail for all steps"); return; } diff --git a/ui/litellm-dashboard/src/components/policies/policy_templates.tsx b/ui/litellm-dashboard/src/components/policies/policy_templates.tsx index 98ba0acb6a..c4ea22651c 100644 --- a/ui/litellm-dashboard/src/components/policies/policy_templates.tsx +++ b/ui/litellm-dashboard/src/components/policies/policy_templates.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect, useMemo } from "react"; -import { Card, Button, Spin, message, Checkbox } from "antd"; +import { Card, Button, Spin, Checkbox } from "antd"; +import MessageManager from "@/components/molecules/message_manager"; import { ShieldCheckIcon, ShieldExclamationIcon, @@ -184,7 +185,7 @@ const PolicyTemplates: React.FC = ({ onUseTemplate, onOpen onTemplatesLoaded?.(data); } catch (error) { console.error("Error fetching policy templates:", error); - message.error("Failed to fetch policy templates"); + MessageManager.error("Failed to fetch policy templates"); } finally { setIsLoading(false); } diff --git a/ui/litellm-dashboard/src/components/shared/CreatedKeyDisplay.test.tsx b/ui/litellm-dashboard/src/components/shared/CreatedKeyDisplay.test.tsx index 013e222c6b..eb6fe5f50c 100644 --- a/ui/litellm-dashboard/src/components/shared/CreatedKeyDisplay.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/CreatedKeyDisplay.test.tsx @@ -3,15 +3,11 @@ import { render, screen, act } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import CreatedKeyDisplay from "./CreatedKeyDisplay"; -vi.mock("antd", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - message: { success: vi.fn() }, - }; -}); +vi.mock("@/components/molecules/message_manager", () => ({ + default: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn(), loading: vi.fn(), destroy: vi.fn() }, +})); -import { message } from "antd"; +import MessageManager from "@/components/molecules/message_manager"; describe("CreatedKeyDisplay", () => { beforeEach(() => { @@ -52,7 +48,7 @@ describe("CreatedKeyDisplay", () => { await user.click(screen.getByRole("button", { name: /copy virtual key/i })); - expect(message.success).toHaveBeenCalledWith("Key copied to clipboard"); + expect(MessageManager.success).toHaveBeenCalledWith("Key copied to clipboard"); }); it("should revert button text back after 2 seconds", async () => { diff --git a/ui/litellm-dashboard/src/components/shared/CreatedKeyDisplay.tsx b/ui/litellm-dashboard/src/components/shared/CreatedKeyDisplay.tsx index d75c63c646..00ffb8c6e2 100644 --- a/ui/litellm-dashboard/src/components/shared/CreatedKeyDisplay.tsx +++ b/ui/litellm-dashboard/src/components/shared/CreatedKeyDisplay.tsx @@ -1,6 +1,7 @@ import React, { useState } from "react"; import { CopyToClipboard } from "react-copy-to-clipboard"; -import { Button, message } from "antd"; +import { Button } from "antd"; +import MessageManager from "@/components/molecules/message_manager"; interface CreatedKeyDisplayProps { apiKey: string; @@ -15,7 +16,7 @@ const CreatedKeyDisplay: React.FC = ({ apiKey }) => { const handleCopy = () => { setCopied(true); - message.success("Key copied to clipboard"); + MessageManager.success("Key copied to clipboard"); setTimeout(() => setCopied(false), 2000); }; diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index d2ce79580d..a4c7ae2bbb 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -20,7 +20,8 @@ import { isProxyAdminRole } from "@/utils/roles"; import { EditOutlined, InfoCircleOutlined, SaveOutlined } from "@ant-design/icons"; import { ArrowLeftIcon } from "@heroicons/react/outline"; import { Badge, Card, Grid, Text, TextInput, Title } from "@tremor/react"; -import { Button, Form, Input, message, Select, Switch, Tabs, Tooltip } from "antd"; +import { Button, Form, Input, Select, Switch, Tabs, Tooltip } from "antd"; +import MessageManager from "@/components/molecules/message_manager"; import { CheckIcon, CopyIcon } from "lucide-react"; import React, { useEffect, useMemo, useState } from "react"; import { copyToClipboard as utilCopyToClipboard } from "../../utils/dataUtils"; @@ -366,7 +367,7 @@ const TeamInfoView: React.FC = ({ tpm_limit: values.tpm_limit, rpm_limit: values.rpm_limit, }; - message.destroy(); // Remove all existing toasts + MessageManager.destroy(); // Remove all existing toasts await teamMemberUpdateCall(accessToken, teamId, member); @@ -388,7 +389,7 @@ const TeamInfoView: React.FC = ({ } setIsEditMemberModalVisible(false); - message.destroy(); // Remove all existing toasts + MessageManager.destroy(); // Remove all existing toasts NotificationsManager.fromBackend(errMsg); console.error("Error updating team member:", error); diff --git a/ui/litellm-dashboard/src/components/ui/AntDLoadingSpinner.tsx b/ui/litellm-dashboard/src/components/ui/AntDLoadingSpinner.tsx new file mode 100644 index 0000000000..9e90f77584 --- /dev/null +++ b/ui/litellm-dashboard/src/components/ui/AntDLoadingSpinner.tsx @@ -0,0 +1,12 @@ +import { Spin } from "antd"; +import { LoadingOutlined } from "@ant-design/icons"; + +interface AntDLoadingSpinnerProps { + size?: "small" | "default" | "large"; + fontSize?: number; +} + +export function AntDLoadingSpinner({ size, fontSize }: AntDLoadingSpinnerProps) { + const indicator = ; + return ; +} diff --git a/ui/litellm-dashboard/src/components/vector_store_management/CreateVectorStore.tsx b/ui/litellm-dashboard/src/components/vector_store_management/CreateVectorStore.tsx index 97162aa132..b189168e1b 100644 --- a/ui/litellm-dashboard/src/components/vector_store_management/CreateVectorStore.tsx +++ b/ui/litellm-dashboard/src/components/vector_store_management/CreateVectorStore.tsx @@ -1,6 +1,7 @@ import React, { useState } from "react"; import { Card, Title, Text } from "@tremor/react"; -import { Upload, Button, Select, Form, message, Alert, Tooltip, Input } from "antd"; +import { Upload, Button, Select, Form, Alert, Tooltip, Input } from "antd"; +import MessageManager from "@/components/molecules/message_manager"; import { InboxOutlined, InfoCircleOutlined } from "@ant-design/icons"; import type { UploadProps } from "antd"; import { ragIngestCall } from "../networking"; @@ -47,13 +48,13 @@ const CreateVectorStore: React.FC = ({ accessToken, onSu ].includes(file.type); if (!isValidType) { - message.error(`${file.name} is not a supported file type. Please upload PDF, TXT, DOCX, or MD files.`); + MessageManager.error(`${file.name} is not a supported file type. Please upload PDF, TXT, DOCX, or MD files.`); return Upload.LIST_IGNORE; } const isLt50M = file.size / 1024 / 1024 < 50; if (!isLt50M) { - message.error(`${file.name} must be smaller than 50MB!`); + MessageManager.error(`${file.name} must be smaller than 50MB!`); return Upload.LIST_IGNORE; } @@ -87,12 +88,12 @@ const CreateVectorStore: React.FC = ({ accessToken, onSu const handleCreateVectorStore = async () => { if (documents.length === 0) { - message.warning("Please upload at least one document"); + MessageManager.warning("Please upload at least one document"); return; } if (!selectedProvider) { - message.warning("Please select a provider"); + MessageManager.warning("Please select a provider"); return; } @@ -100,7 +101,7 @@ const CreateVectorStore: React.FC = ({ accessToken, onSu const requiredFields = getProviderSpecificFields(selectedProvider).filter((field) => field.required); for (const field of requiredFields) { if (!providerParams[field.name]) { - message.warning(`Please provide ${field.label}`); + MessageManager.warning(`Please provide ${field.label}`); return; } } @@ -108,17 +109,17 @@ const CreateVectorStore: React.FC = ({ accessToken, onSu // S3 Vectors specific validation if (selectedProvider === "s3_vectors") { if (providerParams.vector_bucket_name && providerParams.vector_bucket_name.length < 3) { - message.warning("Vector bucket name must be at least 3 characters"); + MessageManager.warning("Vector bucket name must be at least 3 characters"); return; } if (providerParams.index_name && providerParams.index_name.length > 0 && providerParams.index_name.length < 3) { - message.warning("Index name must be at least 3 characters if provided"); + MessageManager.warning("Index name must be at least 3 characters if provided"); return; } } if (!accessToken) { - message.error("No access token available"); + MessageManager.error("No access token available"); return; } diff --git a/ui/litellm-dashboard/src/components/vector_store_management/DocumentsTable.tsx b/ui/litellm-dashboard/src/components/vector_store_management/DocumentsTable.tsx index aeb4240d36..c1ce57ad33 100644 --- a/ui/litellm-dashboard/src/components/vector_store_management/DocumentsTable.tsx +++ b/ui/litellm-dashboard/src/components/vector_store_management/DocumentsTable.tsx @@ -1,5 +1,6 @@ import React from "react"; -import { Table, Badge, Tooltip, message } from "antd"; +import { Table, Badge, Tooltip } from "antd"; +import MessageManager from "@/components/molecules/message_manager"; import { EyeOutlined, CopyOutlined, DeleteOutlined } from "@ant-design/icons"; import { DocumentUpload } from "./types"; @@ -11,7 +12,7 @@ interface DocumentsTableProps { const DocumentsTable: React.FC = ({ documents, onRemove }) => { const handleCopyId = (uid: string) => { navigator.clipboard.writeText(uid); - message.success("Document ID copied to clipboard"); + MessageManager.success("Document ID copied to clipboard"); }; const getStatusBadge = (status: DocumentUpload["status"]) => { diff --git a/ui/litellm-dashboard/src/components/vector_store_management/VectorStoreTester.tsx b/ui/litellm-dashboard/src/components/vector_store_management/VectorStoreTester.tsx index f8b5ada0c3..5c59334fa4 100644 --- a/ui/litellm-dashboard/src/components/vector_store_management/VectorStoreTester.tsx +++ b/ui/litellm-dashboard/src/components/vector_store_management/VectorStoreTester.tsx @@ -1,5 +1,6 @@ import React, { useState } from "react"; -import { Button, Input, Card, Typography, Spin, message, Divider } from "antd"; +import { Button, Input, Card, Typography, Spin, Divider } from "antd"; +import MessageManager from "@/components/molecules/message_manager"; import { SendOutlined, DatabaseOutlined, LoadingOutlined, DownOutlined, RightOutlined } from "@ant-design/icons"; import { vectorStoreSearchCall } from "../networking"; import NotificationsManager from "../molecules/notifications_manager"; @@ -46,7 +47,7 @@ export const VectorStoreTester: React.FC = ({ vectorStor const handleSearch = async () => { if (!query.trim()) { - message.warning("Please enter a search query"); + MessageManager.warning("Please enter a search query"); return; } diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/CollapsibleMessage.test.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/CollapsibleMessage.test.tsx new file mode 100644 index 0000000000..f07d186c04 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/CollapsibleMessage.test.tsx @@ -0,0 +1,54 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect } from "vitest"; +import { CollapsibleMessage } from "./CollapsibleMessage"; + +describe("CollapsibleMessage", () => { + it("should return null when content is empty", () => { + const { container } = render( + + ); + expect(container.innerHTML).toBe(""); + }); + + it("should return null when content is undefined", () => { + const { container } = render(); + expect(container.innerHTML).toBe(""); + }); + + it("should render the label and char count", () => { + render(); + expect(screen.getByText("SYSTEM")).toBeInTheDocument(); + expect(screen.getByText("(5 chars)")).toBeInTheDocument(); + }); + + it("should show content when defaultExpanded is true", () => { + render( + + ); + expect(screen.getByText("Visible text")).toBeInTheDocument(); + }); + + it("should toggle expanded state when header is clicked", async () => { + const user = userEvent.setup(); + render( + + ); + + // Content is rendered in DOM but collapsed by default + expect(screen.getByText("Toggle me")).toBeInTheDocument(); + + // Click the header to expand - should still show content + await user.click(screen.getByText("SYSTEM")); + expect(screen.getByText("Toggle me")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/HistoryTree.test.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/HistoryTree.test.tsx new file mode 100644 index 0000000000..f5eb1fcf8d --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/HistoryTree.test.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect } from "vitest"; +import { HistoryTree } from "./HistoryTree"; +import { ParsedMessage } from "./prettyMessagesTypes"; + +describe("HistoryTree", () => { + it("should return null when messages array is empty", () => { + const { container } = render(); + expect(container.innerHTML).toBe(""); + }); + + it('should render message count with plural "messages" for multiple messages', () => { + const messages: ParsedMessage[] = [ + { role: "user", content: "Hello" }, + { role: "assistant", content: "Hi there" }, + { role: "user", content: "How are you?" }, + ]; + render(); + expect( + screen.getByText("HISTORY (3 messages)") + ).toBeInTheDocument(); + }); + + it('should render message count with singular "message" for one message', () => { + const messages: ParsedMessage[] = [ + { role: "user", content: "Hello" }, + ]; + render(); + expect( + screen.getByText("HISTORY (1 message)") + ).toBeInTheDocument(); + }); + + it("should expand and show messages when header is clicked", async () => { + const user = userEvent.setup(); + const messages: ParsedMessage[] = [ + { role: "user", content: "Hello" }, + { role: "assistant", content: "Hi there" }, + ]; + render(); + + // Click to expand + await user.click(screen.getByText("HISTORY (2 messages)")); + + expect(screen.getByText("Hello")).toBeInTheDocument(); + expect(screen.getByText("Hi there")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/InputCard.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/InputCard.tsx index 1299f9c168..8fb47274a8 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/InputCard.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/InputCard.tsx @@ -4,7 +4,7 @@ */ import { useState } from 'react'; -import { message } from 'antd'; +import MessageManager from "@/components/molecules/message_manager"; import { ParsedMessage } from './prettyMessagesTypes'; import { SectionHeader } from './SectionHeader'; import { CollapsibleMessage } from './CollapsibleMessage'; @@ -33,7 +33,7 @@ export function InputCard({ messages, promptTokens, inputCost }: InputCardProps) const handleCopy = () => { const content = lastMessage?.content || ''; navigator.clipboard.writeText(content); - message.success('Input copied'); + MessageManager.success('Input copied'); }; return ( diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/OutputCard.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/OutputCard.tsx index eff8d83cbd..22f1708ee4 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/OutputCard.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/OutputCard.tsx @@ -4,7 +4,8 @@ */ import { useState } from 'react'; -import { Typography, message as antdMessage } from 'antd'; +import { Typography } from 'antd'; +import MessageManager from "@/components/molecules/message_manager"; import { ParsedMessage } from './prettyMessagesTypes'; import { SectionHeader } from './SectionHeader'; import { SimpleMessageBlock } from './SimpleMessageBlock'; @@ -25,7 +26,7 @@ export function OutputCard({ message, completionTokens, outputCost }: OutputCard const content = message.content || ''; navigator.clipboard.writeText(content); - antdMessage.success('Output copied'); + MessageManager.success('Output copied'); }; if (!message) { diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/SimpleMessageBlock.test.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/SimpleMessageBlock.test.tsx new file mode 100644 index 0000000000..6483507938 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/SimpleMessageBlock.test.tsx @@ -0,0 +1,55 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { SimpleMessageBlock } from "./SimpleMessageBlock"; + +describe("SimpleMessageBlock", () => { + it("should render the label and content", () => { + render(); + expect(screen.getByText("USER")).toBeInTheDocument(); + expect(screen.getByText("Hello world")).toBeInTheDocument(); + }); + + it("should return null when content is empty and no tool calls", () => { + const { container } = render( + + ); + expect(container.innerHTML).toBe(""); + }); + + it('should return null when content is "null" string and no tool calls', () => { + const { container } = render( + + ); + expect(container.innerHTML).toBe(""); + }); + + it("should render tool calls when present", () => { + render( + + ); + expect(screen.getByText("ASSISTANT")).toBeInTheDocument(); + expect(screen.getByText("get_weather")).toBeInTheDocument(); + }); + + it("should render content and tool calls together", () => { + render( + + ); + expect( + screen.getByText("Let me check the weather.") + ).toBeInTheDocument(); + expect(screen.getByText("get_weather")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/SimpleToolCallBlock.test.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/SimpleToolCallBlock.test.tsx new file mode 100644 index 0000000000..33ff403090 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/SimpleToolCallBlock.test.tsx @@ -0,0 +1,51 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { SimpleToolCallBlock } from "./SimpleToolCallBlock"; + +describe("SimpleToolCallBlock", () => { + it("should render the tool name", () => { + render( + + ); + expect(screen.getByText("get_weather")).toBeInTheDocument(); + }); + + it('should display "function" badge', () => { + render( + + ); + expect(screen.getByText("function")).toBeInTheDocument(); + }); + + it("should render arguments when present", () => { + render( + + ); + expect(screen.getByText("city:")).toBeInTheDocument(); + expect(screen.getByText('"London"')).toBeInTheDocument(); + expect(screen.getByText("units:")).toBeInTheDocument(); + expect(screen.getByText('"metric"')).toBeInTheDocument(); + }); + + it("should not render arguments section when arguments are empty", () => { + const { container } = render( + + ); + // The tool name and "function" badge should be there, but no key: value pairs + expect(screen.getByText("get_weather")).toBeInTheDocument(); + expect(screen.queryByText(/:$/)).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/contexts/AntdGlobalProvider.tsx b/ui/litellm-dashboard/src/contexts/AntdGlobalProvider.tsx index 5b5c1036fa..cc182ee36d 100644 --- a/ui/litellm-dashboard/src/contexts/AntdGlobalProvider.tsx +++ b/ui/litellm-dashboard/src/contexts/AntdGlobalProvider.tsx @@ -1,23 +1,27 @@ "use client"; import React, { useEffect, useRef } from "react"; -import { notification } from "antd"; +import { notification, message } from "antd"; import { setNotificationInstance } from "@/components/molecules/notifications_manager"; +import { setMessageInstance } from "@/components/molecules/message_manager"; export default function AntdGlobalProvider({ children }: { children: React.ReactNode }) { - const [api, contextHolder] = notification.useNotification(); + const [notificationApi, notificationContextHolder] = notification.useNotification(); + const [messageApi, messageContextHolder] = message.useMessage(); const initialized = useRef(false); useEffect(() => { if (!initialized.current) { - setNotificationInstance(api); + setNotificationInstance(notificationApi); + setMessageInstance(messageApi); initialized.current = true; } - }, [api]); + }, [notificationApi, messageApi]); return ( <> - {contextHolder} + {notificationContextHolder} + {messageContextHolder} {children} ); diff --git a/ui/litellm-dashboard/tests/setupTests.ts b/ui/litellm-dashboard/tests/setupTests.ts index e8de9f7248..5f85a80e58 100644 --- a/ui/litellm-dashboard/tests/setupTests.ts +++ b/ui/litellm-dashboard/tests/setupTests.ts @@ -100,6 +100,11 @@ if (!document.getAnimations) { document.getAnimations = () => []; } +// Stub URL.revokeObjectURL so vi.spyOn can intercept it in tests +if (!URL.revokeObjectURL) { + URL.revokeObjectURL = () => {}; +} + // Mock ResizeObserver for components that use it (e.g., Tremor UI components) // This prevents "ResizeObserver is not defined" errors in JSDOM global.ResizeObserver = class ResizeObserver {