diff --git a/AGENTS.md b/AGENTS.md index 61afbd035fe..5a48049ef45 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,12 +51,14 @@ LiteLLM is a unified interface for 100+ LLMs that: ### MAKING CODE CHANGES FOR THE UI (IGNORE FOR BACKEND) -1. **Use Common Components as much as possible**: +1. **Tremor is DEPRECATED, do not use Tremor components in new features/changes** + - The only exception is the Tremor Table component and its required Tremor Table sub components. + +2. **Use Common Components as much as possible**: - These are usually defined in the `common_components` directory - Use these components as much as possible and avoid building new components unless needed - - Tremor components are deprecated; prefer using Ant Design (AntD) as much as possible -2. **Testing**: +3. **Testing**: - The codebase uses **Vitest** and **React Testing Library** - **Query Priority Order**: Use query methods in this order: `getByRole`, `getByLabelText`, `getByPlaceholderText`, `getByText`, `getByTestId` - **Always use `screen`** instead of destructuring from `render()` (e.g., use `screen.getByText()` not `getByText`) diff --git a/README.md b/README.md index 914fda384b0..77adddf8978 100644 --- a/README.md +++ b/README.md @@ -267,6 +267,7 @@ Support for more providers. Missing a provider or LLM Platform, raise a [feature Greptile OpenHands

Netflix

+ OpenAI Agents SDK diff --git a/litellm/proxy/rag_endpoints/endpoints.py b/litellm/proxy/rag_endpoints/endpoints.py index 79b4fd6873d..79f182817f6 100644 --- a/litellm/proxy/rag_endpoints/endpoints.py +++ b/litellm/proxy/rag_endpoints/endpoints.py @@ -26,6 +26,184 @@ from litellm.proxy.common_utils.http_parsing_utils import ( router = APIRouter() +def _build_file_metadata_entry( + response: Any, + file_data: Optional[Tuple[str, bytes, str]] = None, + file_url: Optional[str] = None, +) -> Dict[str, Any]: + """ + Build a file metadata entry for storing in vector_store_metadata. + + Args: + response: The response from litellm.aingest containing file_id + file_data: Optional tuple of (filename, content, content_type) + file_url: Optional URL if file was ingested from URL + + Returns: + Dictionary with file metadata (file_id, filename, file_url, ingested_at, etc.) + """ + from datetime import datetime, timezone + + # Extract file_id from response + file_id = None + if hasattr(response, "get"): + file_id = response.get("file_id") + elif hasattr(response, "file_id"): + file_id = response.file_id + + # Extract file information from file_data tuple + filename = None + file_size = None + content_type = None + + if file_data: + filename = file_data[0] + file_size = len(file_data[1]) if len(file_data) > 1 else None + content_type = file_data[2] if len(file_data) > 2 else None + + # Build file metadata entry + file_entry = { + "file_id": file_id, + "filename": filename, + "file_url": file_url, + "ingested_at": datetime.now(timezone.utc).isoformat(), + } + + # Add optional fields if available + if file_size is not None: + file_entry["file_size"] = file_size + if content_type is not None: + file_entry["content_type"] = content_type + + return file_entry + + +async def _save_vector_store_to_db_from_rag_ingest( + response: Any, + ingest_options: Dict[str, Any], + prisma_client, + user_api_key_dict: UserAPIKeyAuth, + file_data: Optional[Tuple[str, bytes, str]] = None, + file_url: Optional[str] = None, +) -> None: + """ + Helper function to save a newly created vector store from RAG ingest to the database. + + This function: + - Extracts vector store ID and config from the ingest response + - Checks if the vector store already exists in the database + - Creates a new database entry if it doesn't exist + - Adds the vector store to the registry + + Args: + response: The response from litellm.aingest() + ingest_options: The ingest options containing vector store config + prisma_client: The Prisma database client + user_api_key_dict: User API key authentication info + """ + from litellm.proxy.vector_store_endpoints.management_endpoints import ( + create_vector_store_in_db, + ) + + # Handle both dict and object responses + if hasattr(response, "get"): + vector_store_id = response.get("vector_store_id") + elif hasattr(response, "vector_store_id"): + vector_store_id = response.vector_store_id + else: + verbose_proxy_logger.warning( + f"Unable to extract vector_store_id from response type: {type(response)}" + ) + return + + if vector_store_id is None or not isinstance(vector_store_id, str): + verbose_proxy_logger.warning( + "Vector store ID is None or not a string, skipping database save" + ) + return + + vector_store_config = ingest_options.get("vector_store", {}) + custom_llm_provider = vector_store_config.get("custom_llm_provider") + + # Extract litellm_vector_store_params for custom name and description + litellm_vector_store_params = ingest_options.get("litellm_vector_store_params", {}) + custom_vector_store_name = litellm_vector_store_params.get("vector_store_name") + custom_vector_store_description = litellm_vector_store_params.get("vector_store_description") + + # Build file metadata entry using helper + file_entry = _build_file_metadata_entry( + response=response, + file_data=file_data, + file_url=file_url, + ) + + try: + # Check if vector store already exists in database + existing_vector_store = ( + await prisma_client.db.litellm_managedvectorstorestable.find_unique( + where={"vector_store_id": vector_store_id} + ) + ) + + # Only create if it doesn't exist + if existing_vector_store is None: + verbose_proxy_logger.info( + f"Saving newly created vector store {vector_store_id} to database" + ) + + # Initialize metadata with first file + initial_metadata = { + "ingested_files": [file_entry] + } + + # Use custom name if provided, otherwise default + vector_store_name = custom_vector_store_name or f"RAG Vector Store - {vector_store_id[:8]}" + vector_store_description = custom_vector_store_description or "Created via RAG ingest endpoint" + + await create_vector_store_in_db( + vector_store_id=vector_store_id, + custom_llm_provider=custom_llm_provider or "openai", + prisma_client=prisma_client, + vector_store_name=vector_store_name, + vector_store_description=vector_store_description, + vector_store_metadata=initial_metadata, + ) + + verbose_proxy_logger.info( + f"Vector store {vector_store_id} saved to database successfully" + ) + else: + verbose_proxy_logger.info( + f"Vector store {vector_store_id} already exists, appending file to metadata" + ) + + # Update existing vector store with new file + existing_metadata = existing_vector_store.vector_store_metadata or {} + if isinstance(existing_metadata, str): + import json + existing_metadata = json.loads(existing_metadata) + + ingested_files = existing_metadata.get("ingested_files", []) + ingested_files.append(file_entry) + existing_metadata["ingested_files"] = ingested_files + + # Update the vector store + from litellm.proxy.utils import safe_dumps + await prisma_client.db.litellm_managedvectorstorestable.update( + where={"vector_store_id": vector_store_id}, + data={"vector_store_metadata": safe_dumps(existing_metadata)} + ) + + verbose_proxy_logger.info( + f"Added file {file_entry.get('filename') or file_entry.get('file_url', 'Unknown')} to vector store {vector_store_id} metadata" + ) + except Exception as db_error: + # Log the error but don't fail the request since ingestion succeeded + verbose_proxy_logger.exception( + f"Failed to save vector store {vector_store_id} to database: {db_error}" + ) + + async def parse_rag_ingest_request( request: Request, ) -> Tuple[Dict[str, Any], Optional[Tuple[str, bytes, str]], Optional[str], Optional[str]]: @@ -158,6 +336,7 @@ async def rag_ingest( add_litellm_data_to_request, general_settings, llm_router, + prisma_client, proxy_config, version, ) @@ -189,6 +368,25 @@ async def rag_ingest( **request_data, ) + # Save vector store to database if it was newly created and prisma_client is available + verbose_proxy_logger.debug( + f"RAG Ingest - Checking database save conditions: prisma_client={prisma_client is not None}, response={response is not None}, response_type={type(response)}" + ) + + if prisma_client is not None and response is not None: + await _save_vector_store_to_db_from_rag_ingest( + response=response, + ingest_options=ingest_options, + prisma_client=prisma_client, + user_api_key_dict=user_api_key_dict, + file_data=file_data, + file_url=file_url, + ) + else: + verbose_proxy_logger.warning( + f"Skipping database save: prisma_client={prisma_client is not None}, response={response is not None}" + ) + return response except HTTPException: diff --git a/litellm/proxy/vector_store_endpoints/management_endpoints.py b/litellm/proxy/vector_store_endpoints/management_endpoints.py index bc61a60fe5a..f3787e62f4d 100644 --- a/litellm/proxy/vector_store_endpoints/management_endpoints.py +++ b/litellm/proxy/vector_store_endpoints/management_endpoints.py @@ -133,6 +133,112 @@ async def _resolve_embedding_config_from_db( return None +######################################################## +# Helper Functions +######################################################## +async def create_vector_store_in_db( + vector_store_id: str, + custom_llm_provider: str, + prisma_client, + vector_store_name: Optional[str] = None, + vector_store_description: Optional[str] = None, + vector_store_metadata: Optional[Dict] = None, + litellm_params: Optional[Dict] = None, + litellm_credential_name: Optional[str] = None, +) -> LiteLLM_ManagedVectorStore: + """ + Helper function to create a vector store in the database. + + This function handles: + - Checking if vector store already exists + - Creating the vector store in the database + - Adding it to the vector store registry + + Returns: + LiteLLM_ManagedVectorStore: The created vector store object + + Raises: + HTTPException: If vector store already exists or database error occurs + """ + from litellm.types.router import GenericLiteLLMParams + + if prisma_client is None: + raise HTTPException(status_code=500, detail="Database not connected") + + # Check if vector store already exists + existing_vector_store = ( + await prisma_client.db.litellm_managedvectorstorestable.find_unique( + where={"vector_store_id": vector_store_id} + ) + ) + if existing_vector_store is not None: + raise HTTPException( + status_code=400, + detail=f"Vector store with ID {vector_store_id} already exists", + ) + + # Prepare data for database + data_to_create: Dict[str, Any] = { + "vector_store_id": vector_store_id, + "custom_llm_provider": custom_llm_provider, + } + + if vector_store_name is not None: + data_to_create["vector_store_name"] = vector_store_name + if vector_store_description is not None: + data_to_create["vector_store_description"] = vector_store_description + if vector_store_metadata is not None: + data_to_create["vector_store_metadata"] = safe_dumps(vector_store_metadata) + if litellm_credential_name is not None: + data_to_create["litellm_credential_name"] = litellm_credential_name + + # Handle litellm_params - always provide at least an empty dict + if litellm_params: + # Auto-resolve embedding config if embedding model is provided but config is not + embedding_model = litellm_params.get("litellm_embedding_model") + if embedding_model and not litellm_params.get("litellm_embedding_config"): + resolved_config = await _resolve_embedding_config_from_db( + embedding_model=embedding_model, + prisma_client=prisma_client + ) + if resolved_config: + litellm_params["litellm_embedding_config"] = resolved_config + verbose_proxy_logger.info( + f"Auto-resolved embedding config for model {embedding_model}" + ) + + litellm_params_dict = GenericLiteLLMParams( + **litellm_params + ).model_dump(exclude_none=True) + data_to_create["litellm_params"] = safe_dumps(litellm_params_dict) + else: + # Provide empty dict if no litellm_params provided + data_to_create["litellm_params"] = safe_dumps({}) + + # Create in database + _new_vector_store = ( + await prisma_client.db.litellm_managedvectorstorestable.create( + data=data_to_create + ) + ) + + new_vector_store: LiteLLM_ManagedVectorStore = LiteLLM_ManagedVectorStore( + **_new_vector_store.model_dump() + ) + + # Add vector store to registry + if litellm.vector_store_registry is not None: + litellm.vector_store_registry.add_vector_store_to_registry( + vector_store=new_vector_store + ) + + verbose_proxy_logger.info( + f"Vector store {vector_store_id} created in database successfully" + ) + + return new_vector_store + + ######################################################## # Management Endpoints ######################################################## @@ -156,71 +262,34 @@ async def new_vector_store( - vector_store_metadata: Optional[Dict] - Additional metadata for the vector store """ from litellm.proxy.proxy_server import prisma_client - from litellm.types.router import GenericLiteLLMParams - - if prisma_client is None: - raise HTTPException(status_code=500, detail="Database not connected") try: - # Check if vector store already exists - existing_vector_store = ( - await prisma_client.db.litellm_managedvectorstorestable.find_unique( - where={"vector_store_id": vector_store.get("vector_store_id")} - ) - ) - if existing_vector_store is not None: + vector_store_id = vector_store.get("vector_store_id") + custom_llm_provider = vector_store.get("custom_llm_provider") + + if not vector_store_id or not custom_llm_provider: raise HTTPException( status_code=400, - detail=f"Vector store with ID {vector_store.get('vector_store_id')} already exists", - ) - - if vector_store.get("vector_store_metadata") is not None: - vector_store["vector_store_metadata"] = safe_dumps( - vector_store.get("vector_store_metadata") - ) - - # Safely handle JSON serialization of litellm_params - litellm_params_json: Optional[str] = None - _input_litellm_params: dict = vector_store.get("litellm_params", {}) or {} - if _input_litellm_params is not None: - # Auto-resolve embedding config if embedding model is provided but config is not - embedding_model = _input_litellm_params.get("litellm_embedding_model") - if embedding_model and not _input_litellm_params.get("litellm_embedding_config"): - resolved_config = await _resolve_embedding_config_from_db( - embedding_model=embedding_model, - prisma_client=prisma_client - ) - if resolved_config: - _input_litellm_params["litellm_embedding_config"] = resolved_config - verbose_proxy_logger.info( - f"Auto-resolved embedding config for model {embedding_model}" - ) - - litellm_params_dict = GenericLiteLLMParams( - **_input_litellm_params - ).model_dump(exclude_none=True) - litellm_params_json = safe_dumps(litellm_params_dict) - del vector_store["litellm_params"] - - _new_vector_store = ( - await prisma_client.db.litellm_managedvectorstorestable.create( - data={ - **vector_store, - "litellm_params": litellm_params_json, - } + detail="vector_store_id and custom_llm_provider are required" ) + + # Extract and validate metadata + metadata = vector_store.get("vector_store_metadata") + validated_metadata: Optional[Dict] = None + if metadata is not None and isinstance(metadata, dict): + validated_metadata = metadata + + new_vector_store = await create_vector_store_in_db( + vector_store_id=vector_store_id, + custom_llm_provider=custom_llm_provider, + prisma_client=prisma_client, + vector_store_name=vector_store.get("vector_store_name"), + vector_store_description=vector_store.get("vector_store_description"), + vector_store_metadata=validated_metadata, + litellm_params=vector_store.get("litellm_params"), + litellm_credential_name=vector_store.get("litellm_credential_name"), ) - new_vector_store: LiteLLM_ManagedVectorStore = LiteLLM_ManagedVectorStore( - **_new_vector_store.model_dump() - ) - - # Add vector store to registry - if litellm.vector_store_registry is not None: - litellm.vector_store_registry.add_vector_store_to_registry( - vector_store=new_vector_store - ) - return { "status": "success", "message": f"Vector store {vector_store.get('vector_store_id')} created successfully", diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableShowPrompts.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableShowPrompts.ts new file mode 100644 index 00000000000..801fbdbb99d --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableShowPrompts.ts @@ -0,0 +1,35 @@ +// hooks/useDisableShowPrompts.ts +import { useSyncExternalStore } from "react"; +import { getLocalStorageItem } from "@/utils/localStorageUtils"; +import { LOCAL_STORAGE_EVENT } from "@/utils/localStorageUtils"; + +function subscribe(callback: () => void) { + const onStorage = (e: StorageEvent) => { + if (e.key === "disableShowPrompts") { + callback(); + } + }; + + const onCustom = (e: Event) => { + const { key } = (e as CustomEvent).detail; + if (key === "disableShowPrompts") { + callback(); + } + }; + + window.addEventListener("storage", onStorage); + window.addEventListener(LOCAL_STORAGE_EVENT, onCustom); + + return () => { + window.removeEventListener("storage", onStorage); + window.removeEventListener(LOCAL_STORAGE_EVENT, onCustom); + }; +} + +function getSnapshot() { + return getLocalStorageItem("disableShowPrompts") === "true"; +} + +export function useDisableShowPrompts() { + return useSyncExternalStore(subscribe, getSnapshot); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx index 97837ff8e0a..97e4c799e72 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx @@ -56,8 +56,10 @@ export default function Layout({ children }: { children: React.ReactNode }) { userRole={userRole} premiumUser={premiumUser} proxySettings={undefined} - setProxySettings={() => {}} + setProxySettings={() => { }} accessToken={accessToken} + isDarkMode={false} + toggleDarkMode={() => { }} />
diff --git a/ui/litellm-dashboard/src/app/page.tsx b/ui/litellm-dashboard/src/app/page.tsx index 8ac1f756f96..23c80acf973 100644 --- a/ui/litellm-dashboard/src/app/page.tsx +++ b/ui/litellm-dashboard/src/app/page.tsx @@ -45,6 +45,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { jwtDecode } from "jwt-decode"; import { useSearchParams } from "next/navigation"; import { Suspense, useEffect, useState } from "react"; +import { ConfigProvider, theme } from "antd"; function getCookie(name: string) { // Safer cookie read + decoding; handles '=' inside values @@ -131,6 +132,12 @@ export default function CreateKeyPage() { const [showClaudeCodePrompt, setShowClaudeCodePrompt] = useState(false); const [showClaudeCodeModal, setShowClaudeCodeModal] = useState(false); + // Dark mode state + const [isDarkMode, setIsDarkMode] = useState(false); + const toggleDarkMode = () => { + setIsDarkMode(!isDarkMode); + }; + const invitation_id = searchParams.get("invitation_id"); // Get page from URL, default to 'api-keys' if not present @@ -282,7 +289,7 @@ export default function CreateKeyPage() { const nudgesConfig = await getInProductNudgesCall(accessToken); const isUsingClaudeCode = nudgesConfig?.is_claude_code_enabled || false; setIsClaudeCode(isUsingClaudeCode); - + // Show Claude Code prompt on login if enabled if (isUsingClaudeCode) { setShowClaudeCodePrompt(true); @@ -362,225 +369,231 @@ export default function CreateKeyPage() { return ( }> - - {invitation_id ? ( - - ) : ( -
- + + {invitation_id ? ( + -
-
- -
+ ) : ( +
+ +
+
+ +
- {page == "api-keys" ? ( - - ) : page == "models" ? ( - - ) : page == "llm-playground" ? ( - - ) : page == "users" ? ( - - ) : page == "teams" ? ( - - ) : page == "organizations" ? ( - - ) : page == "admin-panel" ? ( - - ) : page == "api_ref" ? ( - - ) : page == "logging-and-alerts" ? ( - - ) : page == "budgets" ? ( - - ) : page == "guardrails" ? ( - - ) : page == "policies" ? ( - - ) : page == "agents" ? ( - - ) : page == "prompts" ? ( - - ) : page == "transform-request" ? ( - - ) : page == "router-settings" ? ( - - ) : page == "ui-theme" ? ( - - ) : page == "cost-tracking" ? ( - - ) : page == "model-hub-table" ? ( - isAdminRole(userRole) ? ( - + ) : page == "models" ? ( + + ) : page == "llm-playground" ? ( + + ) : page == "users" ? ( + + ) : page == "teams" ? ( + + ) : page == "organizations" ? ( + + ) : page == "admin-panel" ? ( + + ) : page == "api_ref" ? ( + + ) : page == "logging-and-alerts" ? ( + + ) : page == "budgets" ? ( + + ) : page == "guardrails" ? ( + + ) : page == "policies" ? ( + + ) : page == "agents" ? ( + + ) : page == "prompts" ? ( + + ) : page == "transform-request" ? ( + + ) : page == "router-settings" ? ( + + ) : page == "ui-theme" ? ( + + ) : page == "cost-tracking" ? ( + + ) : page == "model-hub-table" ? ( + isAdminRole(userRole) ? ( + + ) : ( + + ) + ) : page == "caching" ? ( + + ) : page == "pass-through-settings" ? ( + + ) : page == "logs" ? ( + + ) : page == "mcp-servers" ? ( + + ) : page == "search-tools" ? ( + + ) : page == "tag-management" ? ( + + ) : page == "claude-code-plugins" ? ( + + ) : page == "vector-stores" ? ( + + ) : page == "new_usage" ? ( + ) : ( - - ) - ) : page == "caching" ? ( - - ) : page == "pass-through-settings" ? ( - - ) : page == "logs" ? ( - - ) : page == "mcp-servers" ? ( - - ) : page == "search-tools" ? ( - - ) : page == "tag-management" ? ( - - ) : page == "claude-code-plugins" ? ( - - ) : page == "vector-stores" ? ( - - ) : page == "new_usage" ? ( - - ) : ( - - )} + + )} +
+ + {/* Survey Components */} + + + + {/* Claude Code Components */} + +
- - {/* Survey Components */} - - - - {/* Claude Code Components */} - - -
- )} -
+ )} + + ); diff --git a/ui/litellm-dashboard/src/components/BulkEditUsers.test.tsx b/ui/litellm-dashboard/src/components/BulkEditUsers.test.tsx new file mode 100644 index 00000000000..4185625e746 --- /dev/null +++ b/ui/litellm-dashboard/src/components/BulkEditUsers.test.tsx @@ -0,0 +1,343 @@ +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { renderWithProviders, screen, waitFor } from "../../tests/test-utils"; +import BulkEditUserModal from "./BulkEditUsers"; +import { userBulkUpdateUserCall, teamBulkMemberAddCall } from "./networking"; +import NotificationsManager from "./molecules/notifications_manager"; + +vi.mock("./networking", () => ({ + userBulkUpdateUserCall: vi.fn(), + teamBulkMemberAddCall: vi.fn(), +})); + +vi.mock("./user_edit_view", () => ({ + UserEditView: ({ onSubmit, onCancel }: { onSubmit: (values: any) => void; onCancel: () => void }) => ( +
+ + +
+ ), +})); + +const mockUserBulkUpdateUserCall = vi.mocked(userBulkUpdateUserCall); +const mockTeamBulkMemberAddCall = vi.mocked(teamBulkMemberAddCall); + +const defaultProps = { + open: true, + onCancel: vi.fn(), + selectedUsers: [ + { user_id: "user1", user_email: "user1@example.com", user_role: "user", max_budget: 50 }, + { user_id: "user2", user_email: "user2@example.com", user_role: "admin", max_budget: null }, + ], + possibleUIRoles: { + admin: { ui_label: "Admin", description: "Administrator role" }, + user: { ui_label: "User", description: "Regular user role" }, + }, + accessToken: "test-token", + onSuccess: vi.fn(), + teams: [ + { team_id: "team1", team_alias: "Team 1" }, + { team_id: "team2", team_alias: "Team 2" }, + ], + userRole: "Admin", + userModels: ["gpt-4", "gpt-3.5-turbo"], + allowAllUsers: false, +}; + +describe("BulkEditUserModal", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUserBulkUpdateUserCall.mockResolvedValue({ + results: [], + total_requested: 2, + successful_updates: 2, + failed_updates: 0, + }); + mockTeamBulkMemberAddCall.mockResolvedValue({ + successful_additions: 2, + failed_additions: 0, + }); + }); + + it("should render without crashing", () => { + renderWithProviders(); + + expect(screen.getByText(`Bulk Edit ${defaultProps.selectedUsers.length} User(s)`)).toBeInTheDocument(); + }); + + it("should display modal title with correct user count", () => { + renderWithProviders(); + + expect(screen.getByText("Bulk Edit 2 User(s)")).toBeInTheDocument(); + }); + + it("should display selected users table when modal is open", () => { + renderWithProviders(); + + expect(screen.getByText("Selected Users (2):")).toBeInTheDocument(); + expect(screen.getByText("user1")).toBeInTheDocument(); + expect(screen.getByText("user2")).toBeInTheDocument(); + expect(screen.getByText("user1@example.com")).toBeInTheDocument(); + expect(screen.getByText("user2@example.com")).toBeInTheDocument(); + }); + + it("should display user roles in table", () => { + renderWithProviders(); + + expect(screen.getByText("User")).toBeInTheDocument(); + expect(screen.getByText("Admin")).toBeInTheDocument(); + }); + + it("should display budget information in table", () => { + renderWithProviders(); + + expect(screen.getByText("$50")).toBeInTheDocument(); + expect(screen.getByText("Unlimited")).toBeInTheDocument(); + }); + + it("should call onCancel when cancel button is clicked", async () => { + const user = userEvent.setup(); + const onCancel = vi.fn(); + renderWithProviders(); + + const cancelButton = screen.getByRole("button", { name: "Cancel" }); + await user.click(cancelButton); + + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + + it("should show update all users checkbox when allowAllUsers is true", () => { + renderWithProviders(); + + expect(screen.getByRole("checkbox", { name: /update all users/i })).toBeInTheDocument(); + }); + + it("should not show update all users checkbox when allowAllUsers is false", () => { + renderWithProviders(); + + expect(screen.queryByRole("checkbox", { name: /update all users/i })).not.toBeInTheDocument(); + }); + + it("should toggle update all users mode", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const checkbox = screen.getByRole("checkbox", { name: /update all users/i }); + expect(checkbox).not.toBeChecked(); + + await user.click(checkbox); + + expect(checkbox).toBeChecked(); + expect(screen.getByText("Bulk Edit All Users")).toBeInTheDocument(); + }); + + it("should show warning message when update all users is enabled", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const checkbox = screen.getByRole("checkbox", { name: /update all users/i }); + await user.click(checkbox); + + expect(screen.getByText(/this will apply changes to all users/i)).toBeInTheDocument(); + }); + + it("should hide selected users table when update all users is enabled", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + expect(screen.getByText("Selected Users (2):")).toBeInTheDocument(); + + const checkbox = screen.getByRole("checkbox", { name: /update all users/i }); + await user.click(checkbox); + + expect(screen.queryByText("Selected Users (2):")).not.toBeInTheDocument(); + }); + + it("should display team management section", () => { + renderWithProviders(); + + expect(screen.getByText("Team Management")).toBeInTheDocument(); + expect(screen.getByRole("checkbox", { name: /add selected users to teams/i })).toBeInTheDocument(); + }); + + it("should show team budget input when add to teams is checked", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const addToTeamsCheckbox = screen.getByRole("checkbox", { name: /add selected users to teams/i }); + await user.click(addToTeamsCheckbox); + + expect(screen.getByText("Team Budget (Optional):")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Max budget per user in team")).toBeInTheDocument(); + }); + + it("should render UserEditView component", () => { + renderWithProviders(); + + expect(screen.getByTestId("user-edit-view")).toBeInTheDocument(); + }); + + it("should show error when access token is missing", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const submitButton = screen.getByRole("button", { name: "Submit" }); + await user.click(submitButton); + + await waitFor(() => { + expect(NotificationsManager.fromBackend).toHaveBeenCalledWith("Access token not found"); + }); + }); + + it("should call userBulkUpdateUserCall with correct payload for selected users", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const submitButton = screen.getByRole("button", { name: "Submit" }); + await user.click(submitButton); + + await waitFor(() => { + expect(mockUserBulkUpdateUserCall).toHaveBeenCalledWith( + "test-token", + { user_role: "admin", max_budget: 100 }, + ["user1", "user2"], + ); + }); + }); + + it("should call userBulkUpdateUserCall with allUsers flag when update all users is enabled", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const updateAllCheckbox = screen.getByRole("checkbox", { name: /update all users/i }); + await user.click(updateAllCheckbox); + + const submitButton = screen.getByRole("button", { name: "Submit" }); + await user.click(submitButton); + + await waitFor(() => { + expect(mockUserBulkUpdateUserCall).toHaveBeenCalledWith( + "test-token", + expect.objectContaining({ user_role: "admin", max_budget: 100 }), + undefined, + true, + ); + }); + }); + + + it("should show success message after successful user update", async () => { + const user = userEvent.setup(); + mockUserBulkUpdateUserCall.mockResolvedValue({ + results: [], + total_requested: 2, + successful_updates: 2, + failed_updates: 0, + }); + + renderWithProviders(); + + const submitButton = screen.getByRole("button", { name: "Submit" }); + await user.click(submitButton); + + await waitFor(() => { + expect(NotificationsManager.success).toHaveBeenCalledWith("Updated 2 user(s)"); + }); + }); + + it("should show success message for all users update", async () => { + const user = userEvent.setup(); + mockUserBulkUpdateUserCall.mockResolvedValue({ + results: [], + total_requested: 100, + successful_updates: 100, + failed_updates: 0, + }); + + renderWithProviders(); + + const updateAllCheckbox = screen.getByRole("checkbox", { name: /update all users/i }); + await user.click(updateAllCheckbox); + + const submitButton = screen.getByRole("button", { name: "Submit" }); + await user.click(submitButton); + + await waitFor(() => { + expect(NotificationsManager.success).toHaveBeenCalledWith("Updated all users (100 total)"); + }); + }); + + + it("should show error message when bulk update fails", async () => { + const user = userEvent.setup(); + mockUserBulkUpdateUserCall.mockRejectedValueOnce(new Error("Update failed")); + + renderWithProviders(); + + const submitButton = screen.getByRole("button", { name: "Submit" }); + await user.click(submitButton); + + await waitFor(() => { + expect(NotificationsManager.fromBackend).toHaveBeenCalledWith("Failed to perform bulk operations"); + }); + }); + + it("should call onSuccess and onCancel after successful update", async () => { + const user = userEvent.setup(); + const onSuccess = vi.fn(); + const onCancel = vi.fn(); + + renderWithProviders(); + + const submitButton = screen.getByRole("button", { name: "Submit" }); + await user.click(submitButton); + + await waitFor(() => { + expect(onSuccess).toHaveBeenCalledTimes(1); + expect(onCancel).toHaveBeenCalledTimes(1); + }); + }); + + it("should truncate long user IDs in table", () => { + const longUserId = "a".repeat(30); + const propsWithLongId = { + ...defaultProps, + selectedUsers: [{ user_id: longUserId, user_email: "test@example.com", user_role: "user", max_budget: null }], + }; + + renderWithProviders(); + + expect(screen.getByText(new RegExp(`${longUserId.slice(0, 20)}...`))).toBeInTheDocument(); + }); + + it("should display no email text when user email is missing", () => { + const propsWithoutEmail = { + ...defaultProps, + selectedUsers: [{ user_id: "user1", user_email: null, user_role: "user", max_budget: null }], + }; + + renderWithProviders(); + + expect(screen.getByText("No email")).toBeInTheDocument(); + }); + + it("should display role label from possibleUIRoles when available", () => { + renderWithProviders(); + + expect(screen.getByText("Admin")).toBeInTheDocument(); + expect(screen.getByText("User")).toBeInTheDocument(); + }); + + it("should display role key when ui_label is not available", () => { + const propsWithoutUIRoles = { + ...defaultProps, + possibleUIRoles: null, + }; + + renderWithProviders(); + + expect(screen.getByText("user")).toBeInTheDocument(); + expect(screen.getByText("admin")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/bulk_edit_user.tsx b/ui/litellm-dashboard/src/components/BulkEditUsers.tsx similarity index 98% rename from ui/litellm-dashboard/src/components/bulk_edit_user.tsx rename to ui/litellm-dashboard/src/components/BulkEditUsers.tsx index b3847911cc0..2f3e57ff2a8 100644 --- a/ui/litellm-dashboard/src/components/bulk_edit_user.tsx +++ b/ui/litellm-dashboard/src/components/BulkEditUsers.tsx @@ -18,7 +18,7 @@ import NotificationsManager from "./molecules/notifications_manager"; const { Text, Title } = Typography; interface BulkEditUserModalProps { - visible: boolean; + open: boolean; onCancel: () => void; selectedUsers: any[]; possibleUIRoles: Record> | null; @@ -31,7 +31,7 @@ interface BulkEditUserModalProps { } const BulkEditUserModal: React.FC = ({ - visible, + open, onCancel, selectedUsers, possibleUIRoles, @@ -75,7 +75,7 @@ const BulkEditUserModal: React.FC = ({ keys: [], teams: teams || [], }), - [teams, visible], + [teams, open], ); const handleSubmit = async (formValues: any) => { @@ -145,7 +145,7 @@ const BulkEditUserModal: React.FC = ({ if (updateAllUsers) { members = null; } else { - const members = selectedUsers.map((user) => ({ + members = selectedUsers.map((user) => ({ user_id: user.user_id, role: "user" as const, // Default role for bulk add user_email: user.user_email || null, @@ -214,7 +214,7 @@ const BulkEditUserModal: React.FC = ({ return ( { + const Option = ({ children, value }: any) => ( + + ); + const Select = ({ children, value, onChange, placeholder }: any) => ( + + ); + Select.Option = Option; + return { + Select, + Tooltip: ({ children, title }: any) => ( +
+ {children} +
+ ), + Switch: ({ checked, onChange }: any) => ( + onChange(e.target.checked)} + /> + ), + Divider: () =>
, + }; +}); + +vi.mock("@ant-design/icons", () => ({ + InfoCircleOutlined: () => , +})); + +vi.mock("@tremor/react", () => ({ + TextInput: ({ value, onValueChange, onChange, placeholder, name, className }: any) => { + const handleChange = (e: React.ChangeEvent) => { + if (onChange) { + onChange(e); + } + if (onValueChange) { + onValueChange(e.target.value); + } + }; + return ( + + ); + }, +})); + +describe("KeyLifecycleSettings", () => { + const mockForm = { + getFieldValue: vi.fn(), + setFieldValue: vi.fn(), + setFieldsValue: vi.fn(), + }; + + const defaultProps = { + form: mockForm, + autoRotationEnabled: false, + onAutoRotationChange: vi.fn(), + rotationInterval: "", + onRotationIntervalChange: vi.fn(), + isCreateMode: false, + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockForm.getFieldValue.mockReturnValue(""); + }); + + it("should render without crashing", () => { + renderWithProviders(); + + expect(screen.getByText("Key Expiry Settings")).toBeInTheDocument(); + expect(screen.getByText("Auto-Rotation Settings")).toBeInTheDocument(); + }); + + describe("Key Expiry Settings", () => { + it("should render expiry input field", () => { + renderWithProviders(); + + expect(screen.getByText("Expire Key")).toBeInTheDocument(); + expect(screen.getByTestId("duration-input")).toBeInTheDocument(); + }); + + it("should show correct placeholder in create mode", () => { + renderWithProviders(); + + const input = screen.getByTestId("duration-input"); + expect(input).toHaveAttribute( + "placeholder", + "e.g., 30d or leave empty to never expire" + ); + }); + + it("should show correct placeholder in edit mode", () => { + renderWithProviders(); + + const input = screen.getByTestId("duration-input"); + expect(input).toHaveAttribute("placeholder", "e.g., 30d or -1 to never expire"); + }); + + it("should show correct tooltip in create mode", () => { + renderWithProviders(); + + const tooltips = screen.getAllByTestId("tooltip"); + const expiryTooltip = tooltips.find((tooltip) => + tooltip.getAttribute("title")?.includes("Leave empty to never expire") + ); + expect(expiryTooltip).toBeInTheDocument(); + expect(expiryTooltip).toHaveAttribute( + "title", + "Set when this key should expire. Format: 30s (seconds), 30m (minutes), 30h (hours), 30d (days). Leave empty to never expire." + ); + }); + + it("should show correct tooltip in edit mode", () => { + renderWithProviders(); + + const tooltips = screen.getAllByTestId("tooltip"); + const expiryTooltip = tooltips.find((tooltip) => + tooltip.getAttribute("title")?.includes("Use -1 to never expire") + ); + expect(expiryTooltip).toBeInTheDocument(); + expect(expiryTooltip).toHaveAttribute( + "title", + "Set when this key should expire. Format: 30s (seconds), 30m (minutes), 30h (hours), 30d (days). Use -1 to never expire." + ); + }); + + it("should initialize with form value if present", () => { + mockForm.getFieldValue.mockReturnValue("30d"); + renderWithProviders(); + + const input = screen.getByTestId("duration-input") as HTMLInputElement; + expect(input.value).toBe("30d"); + }); + + it("should update form using setFieldValue when duration changes", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const input = screen.getByTestId("duration-input"); + await user.type(input, "60d"); + + expect(mockForm.setFieldValue).toHaveBeenCalledWith("duration", "60d"); + }); + + it("should update form using setFieldsValue when setFieldValue is not available", async () => { + const user = userEvent.setup(); + const formWithoutSetFieldValue = { + getFieldValue: vi.fn().mockReturnValue(""), + setFieldsValue: vi.fn(), + }; + renderWithProviders( + + ); + + const input = screen.getByTestId("duration-input"); + await user.type(input, "90d"); + + expect(formWithoutSetFieldValue.setFieldsValue).toHaveBeenCalledWith({ duration: "90d" }); + }); + }); + + describe("Auto-Rotation Settings", () => { + it("should render auto-rotation switch", () => { + renderWithProviders(); + + expect(screen.getByText("Enable Auto-Rotation")).toBeInTheDocument(); + expect(screen.getByTestId("switch")).toBeInTheDocument(); + }); + + it("should show switch as unchecked when autoRotationEnabled is false", () => { + renderWithProviders(); + + const switchElement = screen.getByTestId("switch") as HTMLInputElement; + expect(switchElement.checked).toBe(false); + }); + + it("should show switch as checked when autoRotationEnabled is true", () => { + renderWithProviders(); + + const switchElement = screen.getByTestId("switch") as HTMLInputElement; + expect(switchElement.checked).toBe(true); + }); + + it("should call onAutoRotationChange when switch is toggled", async () => { + const user = userEvent.setup(); + const onAutoRotationChange = vi.fn(); + renderWithProviders( + + ); + + const switchElement = screen.getByTestId("switch"); + await user.click(switchElement); + + expect(onAutoRotationChange).toHaveBeenCalledWith(true); + }); + + it("should not show rotation interval section when auto-rotation is disabled", () => { + renderWithProviders(); + + expect(screen.queryByText("Rotation Interval")).not.toBeInTheDocument(); + expect(screen.queryByTestId("select")).not.toBeInTheDocument(); + }); + + it("should show rotation interval section when auto-rotation is enabled", () => { + renderWithProviders( + + ); + + expect(screen.getByText("Rotation Interval")).toBeInTheDocument(); + expect(screen.getByTestId("select")).toBeInTheDocument(); + }); + + it("should show all predefined interval options", () => { + renderWithProviders( + + ); + + expect(screen.getByText("7 days")).toBeInTheDocument(); + expect(screen.getByText("30 days")).toBeInTheDocument(); + expect(screen.getByText("90 days")).toBeInTheDocument(); + expect(screen.getByText("180 days")).toBeInTheDocument(); + expect(screen.getByText("365 days")).toBeInTheDocument(); + expect(screen.getByText("Custom interval")).toBeInTheDocument(); + }); + + it("should display current rotation interval in select", () => { + renderWithProviders( + + ); + + const select = screen.getByTestId("select") as HTMLSelectElement; + expect(select.value).toBe("90d"); + }); + + it("should call onRotationIntervalChange when predefined interval is selected", async () => { + const user = userEvent.setup(); + const onRotationIntervalChange = vi.fn(); + renderWithProviders( + + ); + + const select = screen.getByTestId("select"); + await user.selectOptions(select, "30d"); + + expect(onRotationIntervalChange).toHaveBeenCalledWith("30d"); + }); + + it("should show custom input when custom option is selected", async () => { + const user = userEvent.setup(); + renderWithProviders( + + ); + + const select = screen.getByTestId("select"); + await user.selectOptions(select, "custom"); + + expect(screen.getByTestId("custom-interval-input")).toBeInTheDocument(); + expect(screen.getByText("Supported formats: seconds (s), minutes (m), hours (h), days (d)")).toBeInTheDocument(); + }); + + it("should hide custom input when predefined interval is selected after custom", async () => { + const user = userEvent.setup(); + const onRotationIntervalChange = vi.fn(); + renderWithProviders( + + ); + + const select = screen.getByTestId("select"); + await user.selectOptions(select, "7d"); + + expect(screen.queryByTestId("custom-interval-input")).not.toBeInTheDocument(); + expect(onRotationIntervalChange).toHaveBeenCalledWith("7d"); + }); + + it("should call onRotationIntervalChange when custom interval is entered", async () => { + const user = userEvent.setup(); + const onRotationIntervalChange = vi.fn(); + renderWithProviders( + + ); + + const select = screen.getByTestId("select"); + await user.selectOptions(select, "custom"); + + const customInput = screen.getByTestId("custom-interval-input"); + await user.type(customInput, "14d"); + + expect(onRotationIntervalChange).toHaveBeenCalledWith("14d"); + }); + + it("should show info message when auto-rotation is enabled", () => { + renderWithProviders(); + + expect( + screen.getByText( + "When rotation occurs, you'll receive a notification with the new key. The old key will be deactivated after a brief grace period." + ) + ).toBeInTheDocument(); + }); + + it("should not show info message when auto-rotation is disabled", () => { + renderWithProviders(); + + expect( + screen.queryByText( + "When rotation occurs, you'll receive a notification with the new key. The old key will be deactivated after a brief grace period." + ) + ).not.toBeInTheDocument(); + }); + + it("should initialize with custom interval input visible when custom interval is provided", () => { + renderWithProviders( + + ); + + expect(screen.getByTestId("custom-interval-input")).toBeInTheDocument(); + const customInput = screen.getByTestId("custom-interval-input") as HTMLInputElement; + expect(customInput.value).toBe("14d"); + }); + + it("should show custom option selected when custom interval is provided", () => { + renderWithProviders( + + ); + + const select = screen.getByTestId("select") as HTMLSelectElement; + expect(select.value).toBe("custom"); + }); + + it("should not call onRotationIntervalChange when selecting custom option", async () => { + const user = userEvent.setup(); + const onRotationIntervalChange = vi.fn(); + renderWithProviders( + + ); + + const select = screen.getByTestId("select"); + await user.selectOptions(select, "custom"); + + expect(onRotationIntervalChange).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/common_components/KeyLifecycleSettings.tsx b/ui/litellm-dashboard/src/components/common_components/KeyLifecycleSettings.tsx index 81d22b56347..0f29a47d1dc 100644 --- a/ui/litellm-dashboard/src/components/common_components/KeyLifecycleSettings.tsx +++ b/ui/litellm-dashboard/src/components/common_components/KeyLifecycleSettings.tsx @@ -11,6 +11,7 @@ interface KeyLifecycleSettingsProps { onAutoRotationChange: (enabled: boolean) => void; rotationInterval: string; onRotationIntervalChange: (interval: string) => void; + isCreateMode?: boolean; // If true, shows "leave empty to never expire" instead of "-1 to never expire" } const KeyLifecycleSettings: React.FC = ({ @@ -19,6 +20,7 @@ const KeyLifecycleSettings: React.FC = ({ onAutoRotationChange, rotationInterval, onRotationIntervalChange, + isCreateMode = false, }) => { // Predefined intervals const predefinedIntervals = ["7d", "30d", "90d", "180d", "365d"]; @@ -64,13 +66,19 @@ const KeyLifecycleSettings: React.FC = ({
({ let mockUseThemeImpl = () => ({ logoUrl: null as string | null }); let mockUseHealthReadinessImpl = () => ({ data: null as any }); let mockGetLocalStorageItemImpl = () => null as string | null; +let mockUseDisableShowPromptsImpl = () => false; vi.mock("@/contexts/ThemeContext", () => ({ useTheme: () => mockUseThemeImpl(), @@ -25,7 +26,12 @@ vi.mock("@/app/(dashboard)/hooks/healthReadiness/useHealthReadiness", () => ({ useHealthReadiness: () => mockUseHealthReadinessImpl(), })); +vi.mock("@/app/(dashboard)/hooks/useDisableShowPrompts", () => ({ + useDisableShowPrompts: () => mockUseDisableShowPromptsImpl(), +})); + vi.mock("@/utils/localStorageUtils", () => ({ + LOCAL_STORAGE_EVENT: "local-storage-change", getLocalStorageItem: () => mockGetLocalStorageItemImpl(), setLocalStorageItem: vi.fn(), removeLocalStorageItem: vi.fn(), @@ -52,6 +58,8 @@ describe("Navbar", () => { setProxySettings: vi.fn(), accessToken: "test-token", isPublicPage: false, + isDarkMode: false, + toggleDarkMode: vi.fn(), }; it("should render without crashing", () => { @@ -198,4 +206,11 @@ describe("Navbar", () => { expect(cookieUtils.clearTokenCookies).toHaveBeenCalled(); expect(window.location.href).toBe(""); }); + + it("should not render dark mode toggle slider", () => { + renderWithProviders(); + + // DO NOT RENDER THIS UNTIL ALL COMPONENTS ARE CONFIRMED TO SUPPORT DARK MODE STYLES. IT IS AN ISSUE IF THIS TEST FAILS. + expect(screen.queryByTestId("dark-mode-toggle")).not.toBeInTheDocument(); + }); }); diff --git a/ui/litellm-dashboard/src/components/navbar.tsx b/ui/litellm-dashboard/src/components/navbar.tsx index 6dac073b3a6..c78f355ff19 100644 --- a/ui/litellm-dashboard/src/components/navbar.tsx +++ b/ui/litellm-dashboard/src/components/navbar.tsx @@ -1,4 +1,5 @@ import { useHealthReadiness } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadiness"; +import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts"; import { getProxyBaseUrl } from "@/components/networking"; import { useTheme } from "@/contexts/ThemeContext"; import { clearTokenCookies } from "@/utils/cookieUtils"; @@ -16,8 +17,10 @@ import { MailOutlined, MenuFoldOutlined, MenuUnfoldOutlined, + MoonOutlined, SafetyOutlined, SlackOutlined, + SunOutlined, UserOutlined, } from "@ant-design/icons"; import type { MenuProps } from "antd"; @@ -36,6 +39,8 @@ interface NavbarProps { isPublicPage: boolean; sidebarCollapsed?: boolean; onToggleSidebar?: () => void; + isDarkMode: boolean; + toggleDarkMode: () => void; } const Navbar: React.FC = ({ @@ -49,11 +54,13 @@ const Navbar: React.FC = ({ isPublicPage = false, sidebarCollapsed = false, onToggleSidebar, + isDarkMode, + toggleDarkMode }) => { const baseUrl = getProxyBaseUrl(); - console.log("baseUrl", baseUrl); const [logoutUrl, setLogoutUrl] = useState(""); const [disableShowNewBadge, setDisableShowNewBadge] = useState(false); + const disableShowPrompts = useDisableShowPrompts(); const { logoUrl } = useTheme(); const { data: healthData } = useHealthReadiness(); const version = healthData?.litellm_version; @@ -152,6 +159,27 @@ const Navbar: React.FC = ({ aria-label="Toggle hide new feature indicators" />
+
e.stopPropagation()} + > + Hide All Prompts + { + if (checked) { + setLocalStorageItem("disableShowPrompts", "true"); + emitLocalStorageChange("disableShowPrompts"); + } else { + removeLocalStorageItem("disableShowPrompts"); + emitLocalStorageChange("disableShowPrompts"); + } + }} + aria-label="Toggle hide all prompts" + /> +
), @@ -227,6 +255,15 @@ const Navbar: React.FC = ({ > Star us on GitHub + {/* Dark mode is currently a work in progress. To test, you can change 'false' to 'true' below. + Do not set this to true by default until all components are confirmed to support dark mode styles. */} + {false && } + unCheckedChildren={} + />} => { + try { + let url = proxyBaseUrl ? `${proxyBaseUrl}/rag/ingest` : `/rag/ingest`; + + const formData = new FormData(); + formData.append("file", file); + + const ingestOptions: any = { + ingest_options: { + vector_store: { + custom_llm_provider: customLlmProvider, + ...(vectorStoreId && { vector_store_id: vectorStoreId }), + }, + }, + }; + + // Add litellm_vector_store_params if name or description provided + if (vectorStoreName || vectorStoreDescription) { + ingestOptions.ingest_options.litellm_vector_store_params = {}; + if (vectorStoreName) { + ingestOptions.ingest_options.litellm_vector_store_params.vector_store_name = vectorStoreName; + } + if (vectorStoreDescription) { + ingestOptions.ingest_options.litellm_vector_store_params.vector_store_description = vectorStoreDescription; + } + } + + formData.append("request", JSON.stringify(ingestOptions)); + + const response = await fetch(url, { + method: "POST", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + }, + body: formData, + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error?.message || error.detail || "Failed to ingest document"); + } + + return await response.json(); + } catch (error) { + console.error("Error ingesting document:", error); + throw error; + } +}; + export const getEmailEventSettings = async (accessToken: string): Promise => { try { const url = proxyBaseUrl ? `${proxyBaseUrl}/email/event_settings` : `/email/event_settings`; 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 1edbba28afc..4c0db429886 100644 --- a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx +++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx @@ -321,9 +321,9 @@ const CreateKey: React.FC = ({ team, teams, data, addKey }) => { formValues.rotation_interval = rotationInterval; } - // Handle duration field for key expiry - if (formValues.duration) { - formValues.duration = formValues.duration; + // Handle duration field for key expiry - convert empty string to null + if (!formValues.duration || formValues.duration.trim() === "") { + formValues.duration = null; } // Update the formValues with the final metadata @@ -1270,6 +1270,7 @@ const CreateKey: React.FC = ({ team, teams, data, addKey }) => { onAutoRotationChange={setAutoRotationEnabled} rotationInterval={rotationInterval} onRotationIntervalChange={setRotationInterval} + isCreateMode={true} />
diff --git a/ui/litellm-dashboard/src/components/public_model_hub.tsx b/ui/litellm-dashboard/src/components/public_model_hub.tsx index e751f4d65b8..db316d44106 100644 --- a/ui/litellm-dashboard/src/components/public_model_hub.tsx +++ b/ui/litellm-dashboard/src/components/public_model_hub.tsx @@ -962,6 +962,8 @@ const PublicModelHub: React.FC = ({ accessToken, isEmbedded proxySettings={proxySettings} accessToken={accessToken || null} isPublicPage={true} + isDarkMode={false} + toggleDarkMode={() => { }} /> )} diff --git a/ui/litellm-dashboard/src/components/survey/NudgePrompt.test.tsx b/ui/litellm-dashboard/src/components/survey/NudgePrompt.test.tsx new file mode 100644 index 00000000000..26db8a680c5 --- /dev/null +++ b/ui/litellm-dashboard/src/components/survey/NudgePrompt.test.tsx @@ -0,0 +1,101 @@ +import { render, screen } from "@testing-library/react"; +import { MessageSquare } from "lucide-react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { NudgePrompt } from "./NudgePrompt"; + +vi.mock("@/app/(dashboard)/hooks/useDisableShowPrompts", () => ({ + useDisableShowPrompts: vi.fn(), +})); + +vi.mock("@/utils/localStorageUtils", () => ({ + setLocalStorageItem: vi.fn(), + emitLocalStorageChange: vi.fn(), + LOCAL_STORAGE_EVENT: "local-storage-change", +})); + +import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts"; +import { emitLocalStorageChange, setLocalStorageItem } from "@/utils/localStorageUtils"; + +const mockUseDisableShowPrompts = vi.mocked(useDisableShowPrompts); +const mockSetLocalStorageItem = vi.mocked(setLocalStorageItem); +const mockEmitLocalStorageChange = vi.mocked(emitLocalStorageChange); + +const defaultProps = { + onOpen: vi.fn(), + onDismiss: vi.fn(), + isVisible: true, + title: "Test Title", + description: "Test Description", + buttonText: "Open Modal", + icon: MessageSquare, + accentColor: "#3b82f6", +}; + +describe("NudgePrompt", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseDisableShowPrompts.mockReturnValue(false); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should render", () => { + render(); + + expect(screen.getByText("Test Title")).toBeInTheDocument(); + }); + + it("should render with all provided props", () => { + const { container } = render(); + + expect(screen.getByText("Test Title")).toBeInTheDocument(); + expect(screen.getByText("Test Description")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Open Modal" })).toBeInTheDocument(); + expect(container.querySelector("svg")).toBeInTheDocument(); + }); + + it("should not render when isVisible is false", () => { + render(); + + expect(screen.queryByText("Test Title")).not.toBeInTheDocument(); + }); + + it("should not render when disableShowPrompts is true", () => { + mockUseDisableShowPrompts.mockReturnValue(true); + + render(); + + expect(screen.queryByText("Test Title")).not.toBeInTheDocument(); + }); + + it("should display progress bar with correct accent color", () => { + const { container } = render(); + + const progressBar = container.querySelector("div[style*='width']"); + expect(progressBar).toHaveStyle({ backgroundColor: "#ff0000" }); + }); + + it("should reset progress when isVisible becomes false", () => { + const { rerender, container } = render(); + + vi.advanceTimersByTime(5000); + + rerender(); + + rerender(); + + const progressBar = container.querySelector("div[style*='width']"); + expect(progressBar?.getAttribute("style")).toContain("width: 100%"); + }); + + it("should apply custom button style when provided", () => { + const buttonStyle = { backgroundColor: "#custom-color" }; + render(); + + const openButton = screen.getByRole("button", { name: "Open Modal" }); + expect(openButton).toHaveStyle(buttonStyle); + }); +}); diff --git a/ui/litellm-dashboard/src/components/survey/NudgePrompt.tsx b/ui/litellm-dashboard/src/components/survey/NudgePrompt.tsx index 9095c6c21c5..3c7f10af74c 100644 --- a/ui/litellm-dashboard/src/components/survey/NudgePrompt.tsx +++ b/ui/litellm-dashboard/src/components/survey/NudgePrompt.tsx @@ -1,6 +1,8 @@ import React, { useEffect, useState } from "react"; -import { X, LucideIcon } from "lucide-react"; +import { X, LucideIcon, Check } from "lucide-react"; import { Button } from "antd"; +import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts"; +import { setLocalStorageItem, emitLocalStorageChange } from "@/utils/localStorageUtils"; interface NudgePromptProps { onOpen: () => void; @@ -15,6 +17,7 @@ interface NudgePromptProps { } const DISMISS_DURATION = 15000; // 15 seconds +const CONFIRMATION_DURATION = 5000; // 5 seconds export function NudgePrompt({ onOpen, @@ -27,11 +30,14 @@ export function NudgePrompt({ accentColor, buttonStyle, }: NudgePromptProps) { + const disableShowPrompts = useDisableShowPrompts(); const [progress, setProgress] = useState(100); + const [showConfirmation, setShowConfirmation] = useState(false); useEffect(() => { if (!isVisible) { setProgress(100); + setShowConfirmation(false); return; } @@ -49,13 +55,53 @@ export function NudgePrompt({ return () => clearInterval(interval); }, [isVisible]); - if (!isVisible) return null; + useEffect(() => { + if (showConfirmation) { + const timer = setTimeout(() => { + setShowConfirmation(false); + onDismiss(); + }, CONFIRMATION_DURATION); + + return () => clearTimeout(timer); + } + }, [showConfirmation, onDismiss]); + + const handleDontAskAgain = () => { + setLocalStorageItem("disableShowPrompts", "true"); + emitLocalStorageChange("disableShowPrompts"); + setShowConfirmation(true); + }; + + // Show confirmation even if disableShowPrompts is true (since we just set it) + if (showConfirmation) { + return ( +
+
+
+
+ +
+
+

+ Got it, we will not ask again. Reactivate this at any time in the User Menu. +

+
+
+
+
+ ); + } + + // Don't show the prompt if disabled (unless we're showing confirmation) + if (!isVisible || disableShowPrompts) return null; return (
{/* Progress bar at top showing time remaining */}
@@ -81,9 +127,20 @@ export function NudgePrompt({

{description}

- +
+ + +
); diff --git a/ui/litellm-dashboard/src/components/vector_store_management/CreateVectorStore.test.tsx b/ui/litellm-dashboard/src/components/vector_store_management/CreateVectorStore.test.tsx new file mode 100644 index 00000000000..db2975781ba --- /dev/null +++ b/ui/litellm-dashboard/src/components/vector_store_management/CreateVectorStore.test.tsx @@ -0,0 +1,166 @@ +import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import CreateVectorStore from "./CreateVectorStore"; +import * as networking from "../networking"; + +// Mock the networking module +vi.mock("../networking", () => ({ + ragIngestCall: vi.fn(), +})); + +// Mock NotificationsManager +vi.mock("../molecules/notifications_manager", () => ({ + default: { + success: vi.fn(), + fromBackend: vi.fn(), + }, +})); + +// Mock vector_store_providers +vi.mock("../vector_store_providers", () => ({ + VectorStoreProviders: { + BEDROCK: "Amazon Bedrock", + OPENAI: "OpenAI", + AZURE_OPENAI: "Azure OpenAI", + }, + vectorStoreProviderMap: { + BEDROCK: "bedrock", + OPENAI: "openai", + AZURE_OPENAI: "azure_openai", + }, + vectorStoreProviderLogoMap: { + "Amazon Bedrock": "https://example.com/bedrock.png", + "OpenAI": "https://example.com/openai.png", + "Azure OpenAI": "https://example.com/azure.png", + }, +})); + +describe("CreateVectorStore", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should render the component successfully", () => { + render(); + + expect(screen.getByText("Create Vector Store")).toBeInTheDocument(); + expect(screen.getByText("Step 1: Upload Documents")).toBeInTheDocument(); + expect(screen.getByText("Step 2: Select Provider")).toBeInTheDocument(); + }); + + it("should display upload area with correct text", () => { + render(); + + expect(screen.getByText("Click or drag files to this area to upload")).toBeInTheDocument(); + expect(screen.getByText(/Support for single or bulk upload/)).toBeInTheDocument(); + }); + + it("should have provider selection dropdown", () => { + render(); + + expect(screen.getByText("Provider")).toBeInTheDocument(); + }); + + it("should have create button disabled initially when no documents", () => { + render(); + + const createButton = screen.getByRole("button", { name: /Create Vector Store/i }); + expect(createButton).toBeDisabled(); + }); + + it("should show uploaded documents table when files are added", async () => { + render(); + + // Create a mock file + const file = new File(["test content"], "test.pdf", { type: "application/pdf" }); + + // Find the upload input (it's hidden but accessible) + const uploadInput = document.querySelector('input[type="file"]') as HTMLInputElement; + + await act(async () => { + if (uploadInput) { + fireEvent.change(uploadInput, { target: { files: [file] } }); + } + }); + + await waitFor(() => { + expect(screen.getByText("Uploaded Documents (1)")).toBeInTheDocument(); + }); + }); + + it("should call ragIngestCall when create button is clicked", async () => { + const mockRagIngestCall = vi.spyOn(networking, "ragIngestCall"); + mockRagIngestCall.mockResolvedValue({ + id: "test-id", + status: "completed", + vector_store_id: "vs_123", + file_id: "file_123", + }); + + const onSuccess = vi.fn(); + render(); + + // Create a mock file + const file = new File(["test content"], "test.pdf", { type: "application/pdf" }); + const uploadInput = document.querySelector('input[type="file"]') as HTMLInputElement; + + await act(async () => { + if (uploadInput) { + fireEvent.change(uploadInput, { target: { files: [file] } }); + } + }); + + // Wait for file to be added + await waitFor(() => { + expect(screen.getByText("Uploaded Documents (1)")).toBeInTheDocument(); + }); + + // Click create button + const createButton = screen.getByRole("button", { name: /Create Vector Store/i }); + + await act(async () => { + fireEvent.click(createButton); + }); + + await waitFor(() => { + expect(mockRagIngestCall).toHaveBeenCalledWith("test-token", expect.any(File), "bedrock", undefined); + }); + }); + + it("should display success message after successful creation", async () => { + const mockRagIngestCall = vi.spyOn(networking, "ragIngestCall"); + mockRagIngestCall.mockResolvedValue({ + id: "test-id", + status: "completed", + vector_store_id: "vs_123", + file_id: "file_123", + }); + + render(); + + // Create and upload a mock file + const file = new File(["test content"], "test.pdf", { type: "application/pdf" }); + const uploadInput = document.querySelector('input[type="file"]') as HTMLInputElement; + + await act(async () => { + if (uploadInput) { + fireEvent.change(uploadInput, { target: { files: [file] } }); + } + }); + + await waitFor(() => { + expect(screen.getByText("Uploaded Documents (1)")).toBeInTheDocument(); + }); + + // Click create button + const createButton = screen.getByRole("button", { name: /Create Vector Store/i }); + + await act(async () => { + fireEvent.click(createButton); + }); + + await waitFor(() => { + expect(screen.getByText("Vector Store Created Successfully")).toBeInTheDocument(); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/vector_store_management/CreateVectorStore.tsx b/ui/litellm-dashboard/src/components/vector_store_management/CreateVectorStore.tsx new file mode 100644 index 00000000000..685e37ac739 --- /dev/null +++ b/ui/litellm-dashboard/src/components/vector_store_management/CreateVectorStore.tsx @@ -0,0 +1,340 @@ +import React, { useState } from "react"; +import { Card, Title, Text } from "@tremor/react"; +import { Upload, Button, Select, Form, message, Alert, Tooltip, Input } from "antd"; +import { InboxOutlined, InfoCircleOutlined } from "@ant-design/icons"; +import type { UploadProps } from "antd"; +import { ragIngestCall } from "../networking"; +import { DocumentUpload, RAGIngestResponse } from "./types"; +import DocumentsTable from "./DocumentsTable"; +import { + VectorStoreProviders, + vectorStoreProviderLogoMap, + vectorStoreProviderMap, +} from "../vector_store_providers"; +import NotificationsManager from "../molecules/notifications_manager"; + +const { Dragger } = Upload; + +interface CreateVectorStoreProps { + accessToken: string | null; + onSuccess?: (vectorStoreId: string) => void; +} + +const CreateVectorStore: React.FC = ({ accessToken, onSuccess }) => { + const [form] = Form.useForm(); + const [documents, setDocuments] = useState([]); + const [isCreating, setIsCreating] = useState(false); + const [selectedProvider, setSelectedProvider] = useState("bedrock"); + const [vectorStoreName, setVectorStoreName] = useState(""); + const [vectorStoreDescription, setVectorStoreDescription] = useState(""); + const [ingestResults, setIngestResults] = useState([]); + + const uploadProps: UploadProps = { + name: "file", + multiple: true, + accept: ".pdf,.txt,.docx,.md,.doc", + beforeUpload: (file) => { + const isValidType = [ + "application/pdf", + "text/plain", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/msword", + "text/markdown", + ].includes(file.type); + + if (!isValidType) { + message.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!`); + return Upload.LIST_IGNORE; + } + + const newDoc: DocumentUpload = { + uid: file.uid, + name: file.name, + status: "done", + size: file.size, + type: file.type, + originFileObj: file, + }; + + setDocuments((prev) => [...prev, newDoc]); + return false; // Prevent auto upload + }, + onRemove: (file) => { + setDocuments((prev) => prev.filter((doc) => doc.uid !== file.uid)); + }, + fileList: documents.map((doc) => ({ + uid: doc.uid, + name: doc.name, + status: doc.status, + size: doc.size, + })), + showUploadList: false, // We'll use our custom table + }; + + const handleRemoveDocument = (uid: string) => { + setDocuments((prev) => prev.filter((doc) => doc.uid !== uid)); + }; + + const handleCreateVectorStore = async () => { + if (documents.length === 0) { + message.warning("Please upload at least one document"); + return; + } + + if (!selectedProvider) { + message.warning("Please select a provider"); + return; + } + + if (!accessToken) { + message.error("No access token available"); + return; + } + + setIsCreating(true); + const results: RAGIngestResponse[] = []; + let vectorStoreId: string | undefined; + + try { + // Ingest each document + for (const doc of documents) { + if (!doc.originFileObj) continue; + + // Update document status to uploading + setDocuments((prev) => + prev.map((d) => (d.uid === doc.uid ? { ...d, status: "uploading" as const } : d)) + ); + + try { + const result = await ragIngestCall( + accessToken, + doc.originFileObj, + selectedProvider, + vectorStoreId, // Use the same vector store ID for subsequent uploads + vectorStoreName || undefined, + vectorStoreDescription || undefined + ); + + // Store the vector store ID from the first successful ingest + if (!vectorStoreId && result.vector_store_id) { + vectorStoreId = result.vector_store_id; + } + + results.push(result); + + // Update document status to done + setDocuments((prev) => + prev.map((d) => (d.uid === doc.uid ? { ...d, status: "done" as const } : d)) + ); + } catch (error) { + console.error(`Error ingesting ${doc.name}:`, error); + // Update document status to error + setDocuments((prev) => + prev.map((d) => (d.uid === doc.uid ? { ...d, status: "error" as const } : d)) + ); + throw error; // Stop processing on first error + } + } + + setIngestResults(results); + NotificationsManager.success( + `Successfully created vector store with ${results.length} document(s). Vector Store ID: ${vectorStoreId}` + ); + + if (onSuccess && vectorStoreId) { + onSuccess(vectorStoreId); + } + + // Clear documents after successful creation + setTimeout(() => { + setDocuments([]); + setIngestResults([]); + }, 3000); + } catch (error) { + console.error("Error creating vector store:", error); + NotificationsManager.fromBackend(`Failed to create vector store: ${error}`); + } finally { + setIsCreating(false); + } + }; + + return ( +
+
+ Create Vector Store + + Upload documents and select a provider to create a new vector store with embedded content. + +
+ + {/* Upload Area */} + +
+ Step 1: Upload Documents + + Upload one or more documents (PDF, TXT, DOCX, MD). Maximum file size: 50MB per file. + +
+ +

+ +

+

Click or drag files to this area to upload

+

+ Support for single or bulk upload. Supported formats: PDF, TXT, DOCX, MD +

+
+
+ + {/* Documents Table */} + {documents.length > 0 && ( + +
+ Uploaded Documents ({documents.length}) +
+ +
+ )} + + {/* Provider Selection and Vector Store Details */} + +
+
+ Step 2: Configure Vector Store + + Choose the provider and optionally provide a name and description for your vector store. + +
+ +
+ + Vector Store Name{" "} + + + + + } + > + setVectorStoreName(e.target.value)} + placeholder="e.g., Product Documentation, Customer Support KB" + size="large" + className="rounded-md" + /> + + + + Description{" "} + + + + + } + > + setVectorStoreDescription(e.target.value)} + placeholder="e.g., Contains all product documentation and user guides" + rows={2} + size="large" + className="rounded-md" + /> + + + + Provider{" "} + + + + + } + required + > + + +
+ +
+ +
+
+
+ + {/* Success Message */} + {ingestResults.length > 0 && ( + +

+ Vector Store ID: {ingestResults[0]?.vector_store_id} +

+

+ Documents Ingested: {ingestResults.length} +

+
+ } + type="success" + showIcon + closable + /> + )} + + ); +}; + +export default CreateVectorStore; diff --git a/ui/litellm-dashboard/src/components/vector_store_management/DocumentsTable.test.tsx b/ui/litellm-dashboard/src/components/vector_store_management/DocumentsTable.test.tsx new file mode 100644 index 00000000000..761bc2b7df7 --- /dev/null +++ b/ui/litellm-dashboard/src/components/vector_store_management/DocumentsTable.test.tsx @@ -0,0 +1,102 @@ +import { render, screen, fireEvent, act } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import DocumentsTable from "./DocumentsTable"; +import { DocumentUpload } from "./types"; + +// Mock antd message +vi.mock("antd", async () => { + const actual = await vi.importActual("antd"); + return { + ...actual, + message: { + success: vi.fn(), + }, + }; +}); + +describe("DocumentsTable", () => { + const mockDocuments: DocumentUpload[] = [ + { + uid: "1", + name: "test1.pdf", + status: "done", + size: 1024000, + type: "application/pdf", + }, + { + uid: "2", + name: "test2.txt", + status: "uploading", + size: 2048000, + type: "text/plain", + }, + { + uid: "3", + name: "test3.docx", + status: "error", + size: 512000, + type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + }, + ]; + + it("should render the table successfully", () => { + const onRemove = vi.fn(); + render(); + + expect(screen.getByText("test1.pdf")).toBeInTheDocument(); + expect(screen.getByText("test2.txt")).toBeInTheDocument(); + expect(screen.getByText("test3.docx")).toBeInTheDocument(); + }); + + it("should display correct status badges", () => { + const onRemove = vi.fn(); + render(); + + expect(screen.getByText("Ready")).toBeInTheDocument(); + expect(screen.getByText("Uploading")).toBeInTheDocument(); + expect(screen.getByText("Error")).toBeInTheDocument(); + }); + + it("should display file sizes", () => { + const onRemove = vi.fn(); + render(); + + expect(screen.getByText(/1000.00 KB/)).toBeInTheDocument(); + expect(screen.getByText(/2.00 MB/)).toBeInTheDocument(); + expect(screen.getByText(/500.00 KB/)).toBeInTheDocument(); + }); + + it("should call onRemove when delete button is clicked", () => { + const onRemove = vi.fn(); + render(); + + const deleteButtons = screen.getAllByLabelText(/delete/i); + + act(() => { + fireEvent.click(deleteButtons[0]); + }); + + expect(onRemove).toHaveBeenCalledWith("1"); + }); + + it("should show empty state when no documents", () => { + const onRemove = vi.fn(); + render(); + + expect(screen.getByText(/No documents uploaded yet/)).toBeInTheDocument(); + }); + + it("should have action buttons for each document", () => { + const onRemove = vi.fn(); + render(); + + // Each document should have 3 action buttons (view, copy, delete) + const viewButtons = screen.getAllByLabelText(/eye/i); + const copyButtons = screen.getAllByLabelText(/copy/i); + const deleteButtons = screen.getAllByLabelText(/delete/i); + + expect(viewButtons).toHaveLength(3); + expect(copyButtons).toHaveLength(3); + expect(deleteButtons).toHaveLength(3); + }); +}); diff --git a/ui/litellm-dashboard/src/components/vector_store_management/DocumentsTable.tsx b/ui/litellm-dashboard/src/components/vector_store_management/DocumentsTable.tsx new file mode 100644 index 00000000000..aeb4240d366 --- /dev/null +++ b/ui/litellm-dashboard/src/components/vector_store_management/DocumentsTable.tsx @@ -0,0 +1,98 @@ +import React from "react"; +import { Table, Badge, Tooltip, message } from "antd"; +import { EyeOutlined, CopyOutlined, DeleteOutlined } from "@ant-design/icons"; +import { DocumentUpload } from "./types"; + +interface DocumentsTableProps { + documents: DocumentUpload[]; + onRemove: (uid: string) => void; +} + +const DocumentsTable: React.FC = ({ documents, onRemove }) => { + const handleCopyId = (uid: string) => { + navigator.clipboard.writeText(uid); + message.success("Document ID copied to clipboard"); + }; + + const getStatusBadge = (status: DocumentUpload["status"]) => { + const statusConfig = { + uploading: { color: "blue", text: "Uploading" }, + done: { color: "green", text: "Ready" }, + error: { color: "red", text: "Error" }, + removed: { color: "default", text: "Removed" }, + }; + + const config = statusConfig[status]; + return ; + }; + + const formatFileSize = (bytes?: number) => { + if (!bytes) return "-"; + const kb = bytes / 1024; + if (kb < 1024) return `${kb.toFixed(2)} KB`; + return `${(kb / 1024).toFixed(2)} MB`; + }; + + const columns = [ + { + title: "Name", + dataIndex: "name", + key: "name", + render: (name: string, record: DocumentUpload) => ( +
+ {name} + {record.size && ({formatFileSize(record.size)})} +
+ ), + }, + { + title: "Status", + dataIndex: "status", + key: "status", + width: 150, + render: (status: DocumentUpload["status"]) => getStatusBadge(status), + }, + { + title: "Actions", + key: "actions", + width: 120, + render: (_: any, record: DocumentUpload) => ( +
+ + console.log("View", record)} + /> + + + handleCopyId(record.uid)} + /> + + + onRemove(record.uid)} + /> + +
+ ), + }, + ]; + + return ( + + ); +}; + +export default DocumentsTable; diff --git a/ui/litellm-dashboard/src/components/vector_store_management/TestVectorStoreTab.test.tsx b/ui/litellm-dashboard/src/components/vector_store_management/TestVectorStoreTab.test.tsx new file mode 100644 index 00000000000..ad6ef02e87b --- /dev/null +++ b/ui/litellm-dashboard/src/components/vector_store_management/TestVectorStoreTab.test.tsx @@ -0,0 +1,90 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import TestVectorStoreTab from "./TestVectorStoreTab"; +import { VectorStore } from "./types"; + +// Mock VectorStoreTester component +vi.mock("./VectorStoreTester", () => ({ + VectorStoreTester: ({ vectorStoreId, accessToken }: { vectorStoreId: string; accessToken: string }) => ( +
+
{vectorStoreId}
+
{accessToken}
+
+ ), +})); + +const mockVectorStores: VectorStore[] = [ + { + vector_store_id: "vs_123", + custom_llm_provider: "openai", + vector_store_name: "Test Store 1", + vector_store_description: "Description 1", + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + }, + { + vector_store_id: "vs_456", + custom_llm_provider: "bedrock", + vector_store_name: "Test Store 2", + vector_store_description: "Description 2", + created_at: "2024-01-02T00:00:00Z", + updated_at: "2024-01-02T00:00:00Z", + }, +]; + +describe("TestVectorStoreTab", () => { + it("should render the component successfully", () => { + render(); + + expect(screen.getByText("Select Vector Store")).toBeInTheDocument(); + expect(screen.getByText("Choose a vector store to test search queries against")).toBeInTheDocument(); + }); + + it("should show message when no access token", () => { + render(); + + expect(screen.getByText("Access token is required to test vector stores.")).toBeInTheDocument(); + }); + + it("should show message when no vector stores available", () => { + render(); + + expect(screen.getByText("No vector stores available. Create one first to test it.")).toBeInTheDocument(); + }); + + it("should render VectorStoreTester with first vector store by default", () => { + render(); + + expect(screen.getByTestId("vector-store-tester")).toBeInTheDocument(); + expect(screen.getByTestId("tester-vector-store-id")).toHaveTextContent("vs_123"); + expect(screen.getByTestId("tester-access-token")).toHaveTextContent("test-token"); + }); + + it("should update VectorStoreTester when selecting different vector store", () => { + render(); + + // Find the select component + const selectElement = screen.getByRole("combobox"); + + // Change selection + fireEvent.mouseDown(selectElement); + + // Wait for options to appear and click the second one + const option2 = screen.getByText("Test Store 2"); + fireEvent.click(option2); + + // Verify the tester component updated + expect(screen.getByTestId("tester-vector-store-id")).toHaveTextContent("vs_456"); + }); + + it("should display vector store names in select options", () => { + render(); + + const selectElement = screen.getByRole("combobox"); + fireEvent.mouseDown(selectElement); + + // Use getAllByText since the selected value also shows the name + expect(screen.getAllByText("Test Store 1").length).toBeGreaterThan(0); + expect(screen.getByText("Test Store 2")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/vector_store_management/TestVectorStoreTab.tsx b/ui/litellm-dashboard/src/components/vector_store_management/TestVectorStoreTab.tsx new file mode 100644 index 00000000000..da005491ca6 --- /dev/null +++ b/ui/litellm-dashboard/src/components/vector_store_management/TestVectorStoreTab.tsx @@ -0,0 +1,75 @@ +import React, { useState } from "react"; +import { Card, Select, Typography } from "antd"; +import { VectorStoreTester } from "./VectorStoreTester"; +import { VectorStore } from "./types"; + +const { Text, Title } = Typography; + +interface TestVectorStoreTabProps { + accessToken: string | null; + vectorStores: VectorStore[]; +} + +const TestVectorStoreTab: React.FC = ({ accessToken, vectorStores }) => { + const [selectedVectorStoreId, setSelectedVectorStoreId] = useState( + vectorStores.length > 0 ? vectorStores[0].vector_store_id : undefined + ); + + if (!accessToken) { + return ( + + Access token is required to test vector stores. + + ); + } + + if (vectorStores.length === 0) { + return ( + +
+ No vector stores available. Create one first to test it. +
+
+ ); + } + + return ( +
+ +
+
+ Select Vector Store + Choose a vector store to test search queries against +
+ + +
+
+ + {selectedVectorStoreId && ( + + )} +
+ ); +}; + +export default TestVectorStoreTab; diff --git a/ui/litellm-dashboard/src/components/vector_store_management/VectorStoreTable.test.tsx b/ui/litellm-dashboard/src/components/vector_store_management/VectorStoreTable.test.tsx index 65d15260c4c..0e2be7f62df 100644 --- a/ui/litellm-dashboard/src/components/vector_store_management/VectorStoreTable.test.tsx +++ b/ui/litellm-dashboard/src/components/vector_store_management/VectorStoreTable.test.tsx @@ -130,9 +130,9 @@ describe("VectorStoreTable", () => { expect(screen.getByText("Provider")).toBeInTheDocument(); expect(screen.getByText("Created At")).toBeInTheDocument(); expect(screen.getByText("Updated At")).toBeInTheDocument(); - // Check that we have the expected number of header cells (6 data + 1 actions) + // Check that we have the expected number of header cells (7 data + 1 actions) const headers = screen.getAllByRole("columnheader"); - expect(headers).toHaveLength(7); + expect(headers).toHaveLength(8); }); it("should render all vector store rows", () => { @@ -183,7 +183,7 @@ describe("VectorStoreTable", () => { it("should render fallback for missing name", () => { renderComponent(); const fallbackElements = screen.getAllByText("-"); - expect(fallbackElements.length).toBe(2); // One for missing name, one for missing description + expect(fallbackElements.length).toBe(3); // One for missing name, one for missing description, one for missing files }); it("should wrap name in tooltip", () => { @@ -203,7 +203,7 @@ describe("VectorStoreTable", () => { it("should render fallback for missing description", () => { renderComponent(); const fallbackElements = screen.getAllByText("-"); - expect(fallbackElements.length).toBe(2); // One for missing name, one for missing description + expect(fallbackElements.length).toBe(3); // One for missing name, one for missing description, one for missing files }); it("should wrap description in tooltip", () => { @@ -386,7 +386,7 @@ describe("VectorStoreTable", () => { it("should span all columns in empty state", () => { renderComponent({ data: [] }); const emptyCell = screen.getByText("No vector stores found").closest("td"); - expect(emptyCell).toHaveAttribute("colSpan", "7"); // 6 data columns + 1 actions column + expect(emptyCell).toHaveAttribute("colSpan", "8"); // 7 data columns + 1 actions column }); }); @@ -403,7 +403,7 @@ describe("VectorStoreTable", () => { renderComponent({ data: minimalData }); expect(screen.getByText("minimal")).toBeInTheDocument(); - expect(screen.getAllByText("-")).toHaveLength(2); // Name and description fallbacks + expect(screen.getAllByText("-")).toHaveLength(3); // Name, description, and files fallbacks }); it("should handle single vector store", () => { diff --git a/ui/litellm-dashboard/src/components/vector_store_management/VectorStoreTable.tsx b/ui/litellm-dashboard/src/components/vector_store_management/VectorStoreTable.tsx index 52462c02e98..41e2b63112e 100644 --- a/ui/litellm-dashboard/src/components/vector_store_management/VectorStoreTable.tsx +++ b/ui/litellm-dashboard/src/components/vector_store_management/VectorStoreTable.tsx @@ -66,6 +66,32 @@ const VectorStoreTable: React.FC = ({ data, onView, onEdi ); }, }, + { + header: "Files", + accessorKey: "vector_store_metadata", + cell: ({ row }) => { + const vectorStore = row.original; + const ingestedFiles = vectorStore.vector_store_metadata?.ingested_files || []; + + if (ingestedFiles.length === 0) { + return -; + } + + const filenames = ingestedFiles + .map((file) => file.filename || file.file_url || "Unknown") + .join(", "); + + const displayText = ingestedFiles.length === 1 + ? ingestedFiles[0].filename || ingestedFiles[0].file_url || "1 file" + : `${ingestedFiles.length} files`; + + return ( + + {displayText} + + ); + }, + }, { header: "Provider", accessorKey: "custom_llm_provider", diff --git a/ui/litellm-dashboard/src/components/vector_store_management/index.tsx b/ui/litellm-dashboard/src/components/vector_store_management/index.tsx index 6d21e861d4a..9cb57b8b9f6 100644 --- a/ui/litellm-dashboard/src/components/vector_store_management/index.tsx +++ b/ui/litellm-dashboard/src/components/vector_store_management/index.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from "react"; -import { Icon, Button as TremorButton, Col, Text, Grid } from "@tremor/react"; +import { Icon, Button as TremorButton, Col, Text, Grid, TabGroup, TabList, Tab, TabPanels, TabPanel } from "@tremor/react"; import { RefreshIcon } from "@heroicons/react/outline"; import { vectorStoreListCall, vectorStoreDeleteCall, credentialListCall, CredentialItem } from "../networking"; import { VectorStore } from "./types"; @@ -7,6 +7,8 @@ import VectorStoreTable from "./VectorStoreTable"; import VectorStoreForm from "./VectorStoreForm"; import DeleteResourceModal from "../common_components/DeleteResourceModal"; import VectorStoreInfoView from "./vector_store_info"; +import CreateVectorStore from "./CreateVectorStore"; +import TestVectorStoreTab from "./TestVectorStoreTab"; import { isAdminRole } from "@/utils/roles"; import NotificationsManager from "../molecules/notifications_manager"; @@ -101,6 +103,12 @@ const VectorStoreManagement: React.FC = ({ accessToken, userID fetchVectorStores(); }; + const handleVectorStoreCreated = (vectorStoreId: string) => { + console.log("Vector store created:", vectorStoreId); + fetchVectorStores(); + // Optionally switch to the manage tab + }; + useEffect(() => { fetchVectorStores(); fetchCredentials(); @@ -134,18 +142,46 @@ const VectorStoreManagement: React.FC = ({ accessToken, userID -

You can use vector stores to store and retrieve LLM embeddings..

+

You can use vector stores to store and retrieve LLM embeddings.

- setIsCreateModalVisible(true)}> - + Add Vector Store - + + + Create Vector Store + Manage Vector Stores + Test Vector Store + - -
- - - + + {/* Tab 1: Create Vector Store */} + + + + + {/* Tab 2: Manage Vector Stores */} + + setIsCreateModalVisible(true)}> + + Add Vector Store + + + + + + + + + + {/* Tab 3: Test Vector Store */} + + + + + {/* Create Vector Store Modal */} ; + vector_store_metadata?: VectorStoreMetadata; created_at: string; updated_at: string; created_by?: string; @@ -41,3 +55,32 @@ export interface VectorStoreListResponse { current_page: number; total_pages: number; } + +// Document ingestion types +export interface DocumentUpload { + uid: string; + name: string; + status: "uploading" | "done" | "error" | "removed"; + size?: number; + type?: string; + originFileObj?: File; +} + +export interface RAGIngestRequest { + file_url?: string; + file_id?: string; + ingest_options: { + vector_store: { + custom_llm_provider: string; + vector_store_id?: string; + }; + }; +} + +export interface RAGIngestResponse { + id: string; + status: "completed" | "processing" | "failed"; + vector_store_id: string; + file_id: string; + error?: string; +} diff --git a/ui/litellm-dashboard/src/components/view_users.tsx b/ui/litellm-dashboard/src/components/view_users.tsx index f8cf8302a5e..8a09c6d1f97 100644 --- a/ui/litellm-dashboard/src/components/view_users.tsx +++ b/ui/litellm-dashboard/src/components/view_users.tsx @@ -2,7 +2,7 @@ import { Tab, TabGroup, TabList, TabPanel, TabPanels } from "@tremor/react"; import React, { useEffect, useState } from "react"; import { Button } from "@tremor/react"; -import BulkEditUserModal from "./bulk_edit_user"; +import BulkEditUserModal from "./BulkEditUsers"; import CreateUser from "./create_user_button"; import EditUserModal from "./edit_user"; import { @@ -286,7 +286,7 @@ const ViewUserDashboard: React.FC = ({ accessToken, toke }, handleDelete, handleResetPassword, - () => {}, // placeholder function, will be overridden in UserDataTable + () => { }, // placeholder function, will be overridden in UserDataTable ); return ( @@ -415,7 +415,7 @@ const ViewUserDashboard: React.FC = ({ accessToken, toke /> setIsBulkEditModalVisible(false)} selectedUsers={selectedUsers} possibleUIRoles={possibleUIRoles}