Merge branch 'main' into litellm_release_notes_01_26_2026

This commit is contained in:
Krish Dholakia 2026-01-26 20:20:12 -08:00 committed by GitHub
commit 0e56e44701
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 2610 additions and 311 deletions

View file

@ -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`)

View file

@ -267,6 +267,7 @@ Support for more providers. Missing a provider or LLM Platform, raise a [feature
<td><img height="60" alt="Greptile" src="https://github.com/user-attachments/assets/0be4bd8a-7cfa-48d3-9090-f415fe948280" /></td>
<td><img height="60" alt="OpenHands" src="https://github.com/user-attachments/assets/a6150c4c-149e-4cae-888b-8b92be6e003f" /></td>
<td><h2>Netflix</h2></td>
<td><img height="60" alt="OpenAI Agents SDK" src="https://github.com/user-attachments/assets/c02f7be0-8c2e-4d27-aea7-7c024bfaebc0" /></td>
</tr>
</table>

View file

@ -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:

View file

@ -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",

View file

@ -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);
}

View file

@ -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={() => { }}
/>
<div className="flex flex-1 overflow-auto">
<div className="mt-2">

View file

@ -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 (
<Suspense fallback={<LoadingScreen />}>
<QueryClientProvider client={queryClient}>
<ThemeProvider accessToken={accessToken}>
{invitation_id ? (
<UserDashboard
userID={userID}
userRole={userRole}
premiumUser={premiumUser}
teams={teams}
keys={keys}
setUserRole={setUserRole}
userEmail={userEmail}
setUserEmail={setUserEmail}
setTeams={setTeams}
setKeys={setKeys}
organizations={organizations}
addKey={addKey}
createClicked={createClicked}
/>
) : (
<div className="flex flex-col min-h-screen">
<Navbar
<ConfigProvider theme={{
algorithm: isDarkMode ? theme.darkAlgorithm : theme.defaultAlgorithm,
}}>
<ThemeProvider accessToken={accessToken}>
{invitation_id ? (
<UserDashboard
userID={userID}
userRole={userRole}
premiumUser={premiumUser}
teams={teams}
keys={keys}
setUserRole={setUserRole}
userEmail={userEmail}
setProxySettings={setProxySettings}
proxySettings={proxySettings}
accessToken={accessToken}
isPublicPage={false}
sidebarCollapsed={sidebarCollapsed}
onToggleSidebar={toggleSidebar}
setUserEmail={setUserEmail}
setTeams={setTeams}
setKeys={setKeys}
organizations={organizations}
addKey={addKey}
createClicked={createClicked}
/>
<div className="flex flex-1">
<div className="mt-2">
<SidebarProvider setPage={updatePage} defaultSelectedKey={page} sidebarCollapsed={sidebarCollapsed} />
</div>
) : (
<div className="flex flex-col min-h-screen">
<Navbar
userID={userID}
userRole={userRole}
premiumUser={premiumUser}
userEmail={userEmail}
setProxySettings={setProxySettings}
proxySettings={proxySettings}
accessToken={accessToken}
isPublicPage={false}
sidebarCollapsed={sidebarCollapsed}
onToggleSidebar={toggleSidebar}
isDarkMode={isDarkMode}
toggleDarkMode={toggleDarkMode}
/>
<div className="flex flex-1">
<div className="mt-2">
<SidebarProvider setPage={updatePage} defaultSelectedKey={page} sidebarCollapsed={sidebarCollapsed} />
</div>
{page == "api-keys" ? (
<UserDashboard
userID={userID}
userRole={userRole}
premiumUser={premiumUser}
teams={teams}
keys={keys}
setUserRole={setUserRole}
userEmail={userEmail}
setUserEmail={setUserEmail}
setTeams={setTeams}
setKeys={setKeys}
organizations={organizations}
addKey={addKey}
createClicked={createClicked}
/>
) : page == "models" ? (
<OldModelDashboard
token={token}
keys={keys}
modelData={modelData}
setModelData={setModelData}
premiumUser={premiumUser}
teams={teams}
/>
) : page == "llm-playground" ? (
<PlaygroundPage />
) : page == "users" ? (
<ViewUserDashboard
userID={userID}
userRole={userRole}
token={token}
keys={keys}
teams={teams}
accessToken={accessToken}
setKeys={setKeys}
/>
) : page == "teams" ? (
<OldTeams
teams={teams}
setTeams={setTeams}
accessToken={accessToken}
userID={userID}
userRole={userRole}
organizations={organizations}
premiumUser={premiumUser}
searchParams={searchParams}
/>
) : page == "organizations" ? (
<Organizations
organizations={organizations}
setOrganizations={setOrganizations}
userModels={userModels}
accessToken={accessToken}
userRole={userRole}
premiumUser={premiumUser}
/>
) : page == "admin-panel" ? (
<AdminPanel
setTeams={setTeams}
searchParams={searchParams}
accessToken={accessToken}
userID={userID}
showSSOBanner={showSSOBanner}
premiumUser={premiumUser}
proxySettings={proxySettings}
/>
) : page == "api_ref" ? (
<APIReferenceView proxySettings={proxySettings} />
) : page == "logging-and-alerts" ? (
<Settings userID={userID} userRole={userRole} accessToken={accessToken} premiumUser={premiumUser} />
) : page == "budgets" ? (
<BudgetPanel accessToken={accessToken} />
) : page == "guardrails" ? (
<GuardrailsPanel accessToken={accessToken} userRole={userRole} />
) : page == "policies" ? (
<PoliciesPanel accessToken={accessToken} userRole={userRole} />
) : page == "agents" ? (
<AgentsPanel accessToken={accessToken} userRole={userRole} />
) : page == "prompts" ? (
<PromptsPanel accessToken={accessToken} userRole={userRole} />
) : page == "transform-request" ? (
<TransformRequestPanel accessToken={accessToken} />
) : page == "router-settings" ? (
<GeneralSettings
userID={userID}
userRole={userRole}
accessToken={accessToken}
modelData={modelData}
/>
) : page == "ui-theme" ? (
<UIThemeSettings userID={userID} userRole={userRole} accessToken={accessToken} />
) : page == "cost-tracking" ? (
<CostTrackingSettings userID={userID} userRole={userRole} accessToken={accessToken} />
) : page == "model-hub-table" ? (
isAdminRole(userRole) ? (
<ModelHubTable
accessToken={accessToken}
publicPage={false}
premiumUser={premiumUser}
{page == "api-keys" ? (
<UserDashboard
userID={userID}
userRole={userRole}
premiumUser={premiumUser}
teams={teams}
keys={keys}
setUserRole={setUserRole}
userEmail={userEmail}
setUserEmail={setUserEmail}
setTeams={setTeams}
setKeys={setKeys}
organizations={organizations}
addKey={addKey}
createClicked={createClicked}
/>
) : page == "models" ? (
<OldModelDashboard
token={token}
keys={keys}
modelData={modelData}
setModelData={setModelData}
premiumUser={premiumUser}
teams={teams}
/>
) : page == "llm-playground" ? (
<PlaygroundPage />
) : page == "users" ? (
<ViewUserDashboard
userID={userID}
userRole={userRole}
token={token}
keys={keys}
teams={teams}
accessToken={accessToken}
setKeys={setKeys}
/>
) : page == "teams" ? (
<OldTeams
teams={teams}
setTeams={setTeams}
accessToken={accessToken}
userID={userID}
userRole={userRole}
organizations={organizations}
premiumUser={premiumUser}
searchParams={searchParams}
/>
) : page == "organizations" ? (
<Organizations
organizations={organizations}
setOrganizations={setOrganizations}
userModels={userModels}
accessToken={accessToken}
userRole={userRole}
premiumUser={premiumUser}
/>
) : page == "admin-panel" ? (
<AdminPanel
setTeams={setTeams}
searchParams={searchParams}
accessToken={accessToken}
userID={userID}
showSSOBanner={showSSOBanner}
premiumUser={premiumUser}
proxySettings={proxySettings}
/>
) : page == "api_ref" ? (
<APIReferenceView proxySettings={proxySettings} />
) : page == "logging-and-alerts" ? (
<Settings userID={userID} userRole={userRole} accessToken={accessToken} premiumUser={premiumUser} />
) : page == "budgets" ? (
<BudgetPanel accessToken={accessToken} />
) : page == "guardrails" ? (
<GuardrailsPanel accessToken={accessToken} userRole={userRole} />
) : page == "policies" ? (
<PoliciesPanel accessToken={accessToken} userRole={userRole} />
) : page == "agents" ? (
<AgentsPanel accessToken={accessToken} userRole={userRole} />
) : page == "prompts" ? (
<PromptsPanel accessToken={accessToken} userRole={userRole} />
) : page == "transform-request" ? (
<TransformRequestPanel accessToken={accessToken} />
) : page == "router-settings" ? (
<GeneralSettings
userID={userID}
userRole={userRole}
accessToken={accessToken}
modelData={modelData}
/>
) : page == "ui-theme" ? (
<UIThemeSettings userID={userID} userRole={userRole} accessToken={accessToken} />
) : page == "cost-tracking" ? (
<CostTrackingSettings userID={userID} userRole={userRole} accessToken={accessToken} />
) : page == "model-hub-table" ? (
isAdminRole(userRole) ? (
<ModelHubTable
accessToken={accessToken}
publicPage={false}
premiumUser={premiumUser}
userRole={userRole}
/>
) : (
<PublicModelHub accessToken={accessToken} isEmbedded={true} />
)
) : page == "caching" ? (
<CacheDashboard
userID={userID}
userRole={userRole}
token={token}
accessToken={accessToken}
premiumUser={premiumUser}
/>
) : page == "pass-through-settings" ? (
<PassThroughSettings
userID={userID}
userRole={userRole}
accessToken={accessToken}
modelData={modelData}
premiumUser={premiumUser}
/>
) : page == "logs" ? (
<SpendLogsTable
userID={userID}
userRole={userRole}
token={token}
accessToken={accessToken}
allTeams={(teams as Team[]) ?? []}
premiumUser={premiumUser}
/>
) : page == "mcp-servers" ? (
<MCPServers accessToken={accessToken} userRole={userRole} userID={userID} />
) : page == "search-tools" ? (
<SearchTools accessToken={accessToken} userRole={userRole} userID={userID} />
) : page == "tag-management" ? (
<TagManagement accessToken={accessToken} userRole={userRole} userID={userID} />
) : page == "claude-code-plugins" ? (
<ClaudeCodePluginsPanel accessToken={accessToken} userRole={userRole} />
) : page == "vector-stores" ? (
<VectorStoreManagement accessToken={accessToken} userRole={userRole} userID={userID} />
) : page == "new_usage" ? (
<NewUsagePage
teams={(teams as Team[]) ?? []}
organizations={(organizations as Organization[]) ?? []}
/>
) : (
<PublicModelHub accessToken={accessToken} isEmbedded={true} />
)
) : page == "caching" ? (
<CacheDashboard
userID={userID}
userRole={userRole}
token={token}
accessToken={accessToken}
premiumUser={premiumUser}
/>
) : page == "pass-through-settings" ? (
<PassThroughSettings
userID={userID}
userRole={userRole}
accessToken={accessToken}
modelData={modelData}
premiumUser={premiumUser}
/>
) : page == "logs" ? (
<SpendLogsTable
userID={userID}
userRole={userRole}
token={token}
accessToken={accessToken}
allTeams={(teams as Team[]) ?? []}
premiumUser={premiumUser}
/>
) : page == "mcp-servers" ? (
<MCPServers accessToken={accessToken} userRole={userRole} userID={userID} />
) : page == "search-tools" ? (
<SearchTools accessToken={accessToken} userRole={userRole} userID={userID} />
) : page == "tag-management" ? (
<TagManagement accessToken={accessToken} userRole={userRole} userID={userID} />
) : page == "claude-code-plugins" ? (
<ClaudeCodePluginsPanel accessToken={accessToken} userRole={userRole} />
) : page == "vector-stores" ? (
<VectorStoreManagement accessToken={accessToken} userRole={userRole} userID={userID} />
) : page == "new_usage" ? (
<NewUsagePage
teams={(teams as Team[]) ?? []}
organizations={(organizations as Organization[]) ?? []}
/>
) : (
<Usage
userID={userID}
userRole={userRole}
token={token}
accessToken={accessToken}
keys={keys}
premiumUser={premiumUser}
/>
)}
<Usage
userID={userID}
userRole={userRole}
token={token}
accessToken={accessToken}
keys={keys}
premiumUser={premiumUser}
/>
)}
</div>
{/* Survey Components */}
<SurveyPrompt
isVisible={showSurveyPrompt}
onOpen={handleOpenSurvey}
onDismiss={handleDismissSurveyPrompt}
/>
<SurveyModal
isOpen={showSurveyModal}
onClose={handleSurveyModalClose}
onComplete={handleSurveyComplete}
/>
{/* Claude Code Components */}
<ClaudeCodePrompt
isVisible={showClaudeCodePrompt}
onOpen={handleOpenClaudeCode}
onDismiss={handleDismissClaudeCodePrompt}
/>
<ClaudeCodeModal
isOpen={showClaudeCodeModal}
onClose={handleClaudeCodeModalClose}
onComplete={handleClaudeCodeComplete}
/>
</div>
{/* Survey Components */}
<SurveyPrompt
isVisible={showSurveyPrompt}
onOpen={handleOpenSurvey}
onDismiss={handleDismissSurveyPrompt}
/>
<SurveyModal
isOpen={showSurveyModal}
onClose={handleSurveyModalClose}
onComplete={handleSurveyComplete}
/>
{/* Claude Code Components */}
<ClaudeCodePrompt
isVisible={showClaudeCodePrompt}
onOpen={handleOpenClaudeCode}
onDismiss={handleDismissClaudeCodePrompt}
/>
<ClaudeCodeModal
isOpen={showClaudeCodeModal}
onClose={handleClaudeCodeModalClose}
onComplete={handleClaudeCodeComplete}
/>
</div>
)}
</ThemeProvider>
)}
</ThemeProvider>
</ConfigProvider>
</QueryClientProvider>
</Suspense>
);

View file

@ -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 }) => (
<div data-testid="user-edit-view">
<button onClick={() => onSubmit({ user_role: "admin", max_budget: 100 })}>Submit</button>
<button onClick={onCancel}>Cancel</button>
</div>
),
}));
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(<BulkEditUserModal {...defaultProps} />);
expect(screen.getByText(`Bulk Edit ${defaultProps.selectedUsers.length} User(s)`)).toBeInTheDocument();
});
it("should display modal title with correct user count", () => {
renderWithProviders(<BulkEditUserModal {...defaultProps} />);
expect(screen.getByText("Bulk Edit 2 User(s)")).toBeInTheDocument();
});
it("should display selected users table when modal is open", () => {
renderWithProviders(<BulkEditUserModal {...defaultProps} />);
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(<BulkEditUserModal {...defaultProps} />);
expect(screen.getByText("User")).toBeInTheDocument();
expect(screen.getByText("Admin")).toBeInTheDocument();
});
it("should display budget information in table", () => {
renderWithProviders(<BulkEditUserModal {...defaultProps} />);
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(<BulkEditUserModal {...defaultProps} onCancel={onCancel} />);
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(<BulkEditUserModal {...defaultProps} allowAllUsers={true} />);
expect(screen.getByRole("checkbox", { name: /update all users/i })).toBeInTheDocument();
});
it("should not show update all users checkbox when allowAllUsers is false", () => {
renderWithProviders(<BulkEditUserModal {...defaultProps} allowAllUsers={false} />);
expect(screen.queryByRole("checkbox", { name: /update all users/i })).not.toBeInTheDocument();
});
it("should toggle update all users mode", async () => {
const user = userEvent.setup();
renderWithProviders(<BulkEditUserModal {...defaultProps} allowAllUsers={true} />);
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(<BulkEditUserModal {...defaultProps} allowAllUsers={true} />);
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(<BulkEditUserModal {...defaultProps} allowAllUsers={true} />);
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(<BulkEditUserModal {...defaultProps} />);
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(<BulkEditUserModal {...defaultProps} />);
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(<BulkEditUserModal {...defaultProps} />);
expect(screen.getByTestId("user-edit-view")).toBeInTheDocument();
});
it("should show error when access token is missing", async () => {
const user = userEvent.setup();
renderWithProviders(<BulkEditUserModal {...defaultProps} accessToken={null} />);
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(<BulkEditUserModal {...defaultProps} />);
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(<BulkEditUserModal {...defaultProps} allowAllUsers={true} />);
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(<BulkEditUserModal {...defaultProps} />);
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(<BulkEditUserModal {...defaultProps} allowAllUsers={true} />);
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(<BulkEditUserModal {...defaultProps} />);
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(<BulkEditUserModal {...defaultProps} onSuccess={onSuccess} onCancel={onCancel} />);
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(<BulkEditUserModal {...propsWithLongId} />);
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(<BulkEditUserModal {...propsWithoutEmail} />);
expect(screen.getByText("No email")).toBeInTheDocument();
});
it("should display role label from possibleUIRoles when available", () => {
renderWithProviders(<BulkEditUserModal {...defaultProps} />);
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(<BulkEditUserModal {...propsWithoutUIRoles} />);
expect(screen.getByText("user")).toBeInTheDocument();
expect(screen.getByText("admin")).toBeInTheDocument();
});
});

View file

@ -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<string, Record<string, string>> | null;
@ -31,7 +31,7 @@ interface BulkEditUserModalProps {
}
const BulkEditUserModal: React.FC<BulkEditUserModalProps> = ({
visible,
open,
onCancel,
selectedUsers,
possibleUIRoles,
@ -75,7 +75,7 @@ const BulkEditUserModal: React.FC<BulkEditUserModalProps> = ({
keys: [],
teams: teams || [],
}),
[teams, visible],
[teams, open],
);
const handleSubmit = async (formValues: any) => {
@ -145,7 +145,7 @@ const BulkEditUserModal: React.FC<BulkEditUserModalProps> = ({
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<BulkEditUserModalProps> = ({
return (
<Modal
visible={visible}
open={open}
onCancel={handleCancel}
footer={null}
title={updateAllUsers ? "Bulk Edit All Users" : `Bulk Edit ${selectedUsers.length} User(s)`}

View file

@ -0,0 +1,383 @@
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { renderWithProviders, screen } from "../../../tests/test-utils";
import KeyLifecycleSettings from "./KeyLifecycleSettings";
vi.mock("antd", () => {
const Option = ({ children, value }: any) => (
<option value={value}>{children}</option>
);
const Select = ({ children, value, onChange, placeholder }: any) => (
<select
data-testid="select"
value={value}
onChange={(e) => onChange(e.target.value)}
data-placeholder={placeholder}
>
{children}
</select>
);
Select.Option = Option;
return {
Select,
Tooltip: ({ children, title }: any) => (
<div data-testid="tooltip" title={title}>
{children}
</div>
),
Switch: ({ checked, onChange }: any) => (
<input
type="checkbox"
data-testid="switch"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
/>
),
Divider: () => <hr data-testid="divider" />,
};
});
vi.mock("@ant-design/icons", () => ({
InfoCircleOutlined: () => <span data-testid="info-icon"></span>,
}));
vi.mock("@tremor/react", () => ({
TextInput: ({ value, onValueChange, onChange, placeholder, name, className }: any) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (onChange) {
onChange(e);
}
if (onValueChange) {
onValueChange(e.target.value);
}
};
return (
<input
data-testid={name === "duration" ? "duration-input" : "custom-interval-input"}
value={value}
onChange={handleChange}
placeholder={placeholder}
className={className}
/>
);
},
}));
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(<KeyLifecycleSettings {...defaultProps} />);
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(<KeyLifecycleSettings {...defaultProps} />);
expect(screen.getByText("Expire Key")).toBeInTheDocument();
expect(screen.getByTestId("duration-input")).toBeInTheDocument();
});
it("should show correct placeholder in create mode", () => {
renderWithProviders(<KeyLifecycleSettings {...defaultProps} isCreateMode={true} />);
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(<KeyLifecycleSettings {...defaultProps} isCreateMode={false} />);
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(<KeyLifecycleSettings {...defaultProps} isCreateMode={true} />);
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(<KeyLifecycleSettings {...defaultProps} isCreateMode={false} />);
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(<KeyLifecycleSettings {...defaultProps} />);
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(<KeyLifecycleSettings {...defaultProps} />);
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(
<KeyLifecycleSettings {...defaultProps} form={formWithoutSetFieldValue} />
);
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(<KeyLifecycleSettings {...defaultProps} />);
expect(screen.getByText("Enable Auto-Rotation")).toBeInTheDocument();
expect(screen.getByTestId("switch")).toBeInTheDocument();
});
it("should show switch as unchecked when autoRotationEnabled is false", () => {
renderWithProviders(<KeyLifecycleSettings {...defaultProps} autoRotationEnabled={false} />);
const switchElement = screen.getByTestId("switch") as HTMLInputElement;
expect(switchElement.checked).toBe(false);
});
it("should show switch as checked when autoRotationEnabled is true", () => {
renderWithProviders(<KeyLifecycleSettings {...defaultProps} autoRotationEnabled={true} />);
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(
<KeyLifecycleSettings {...defaultProps} onAutoRotationChange={onAutoRotationChange} />
);
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(<KeyLifecycleSettings {...defaultProps} autoRotationEnabled={false} />);
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(
<KeyLifecycleSettings {...defaultProps} autoRotationEnabled={true} rotationInterval="30d" />
);
expect(screen.getByText("Rotation Interval")).toBeInTheDocument();
expect(screen.getByTestId("select")).toBeInTheDocument();
});
it("should show all predefined interval options", () => {
renderWithProviders(
<KeyLifecycleSettings {...defaultProps} autoRotationEnabled={true} rotationInterval="30d" />
);
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(
<KeyLifecycleSettings {...defaultProps} autoRotationEnabled={true} rotationInterval="90d" />
);
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(
<KeyLifecycleSettings
{...defaultProps}
autoRotationEnabled={true}
rotationInterval="7d"
onRotationIntervalChange={onRotationIntervalChange}
/>
);
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(
<KeyLifecycleSettings {...defaultProps} autoRotationEnabled={true} rotationInterval="30d" />
);
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(
<KeyLifecycleSettings
{...defaultProps}
autoRotationEnabled={true}
rotationInterval="custom-value"
onRotationIntervalChange={onRotationIntervalChange}
/>
);
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(
<KeyLifecycleSettings
{...defaultProps}
autoRotationEnabled={true}
rotationInterval=""
onRotationIntervalChange={onRotationIntervalChange}
/>
);
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(<KeyLifecycleSettings {...defaultProps} autoRotationEnabled={true} />);
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(<KeyLifecycleSettings {...defaultProps} autoRotationEnabled={false} />);
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(
<KeyLifecycleSettings {...defaultProps} autoRotationEnabled={true} rotationInterval="14d" />
);
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(
<KeyLifecycleSettings {...defaultProps} autoRotationEnabled={true} rotationInterval="14d" />
);
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(
<KeyLifecycleSettings
{...defaultProps}
autoRotationEnabled={true}
rotationInterval="30d"
onRotationIntervalChange={onRotationIntervalChange}
/>
);
const select = screen.getByTestId("select");
await user.selectOptions(select, "custom");
expect(onRotationIntervalChange).not.toHaveBeenCalled();
});
});
});

View file

@ -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<KeyLifecycleSettingsProps> = ({
@ -19,6 +20,7 @@ const KeyLifecycleSettings: React.FC<KeyLifecycleSettingsProps> = ({
onAutoRotationChange,
rotationInterval,
onRotationIntervalChange,
isCreateMode = false,
}) => {
// Predefined intervals
const predefinedIntervals = ["7d", "30d", "90d", "180d", "365d"];
@ -64,13 +66,19 @@ const KeyLifecycleSettings: React.FC<KeyLifecycleSettingsProps> = ({
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700 flex items-center space-x-1">
<span>Expire Key</span>
<Tooltip title="Set when this key should expire. Format: 30s (seconds), 30m (minutes), 30h (hours), 30d (days). Use -1 to never expire.">
<Tooltip
title={
isCreateMode
? "Set when this key should expire. Format: 30s (seconds), 30m (minutes), 30h (hours), 30d (days). Leave empty to never expire."
: "Set when this key should expire. Format: 30s (seconds), 30m (minutes), 30h (hours), 30d (days). Use -1 to never expire."
}
>
<InfoCircleOutlined className="text-gray-400 cursor-help text-xs" />
</Tooltip>
</label>
<TextInput
name="duration"
placeholder="e.g., 30d or -1 to never expire"
placeholder={isCreateMode ? "e.g., 30d or leave empty to never expire" : "e.g., 30d or -1 to never expire"}
className="w-full"
value={durationValue}
onValueChange={handleDurationChange}

View file

@ -16,6 +16,7 @@ vi.mock("@/utils/proxyUtils", () => ({
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(<Navbar {...defaultProps} />);
// 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();
});
});

View file

@ -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<NavbarProps> = ({
@ -49,11 +54,13 @@ const Navbar: React.FC<NavbarProps> = ({
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<NavbarProps> = ({
aria-label="Toggle hide new feature indicators"
/>
</div>
<div
className="flex items-center text-sm pt-2 mt-2 border-t border-gray-100"
onClick={(e) => e.stopPropagation()}
>
<span className="text-gray-500 text-xs">Hide All Prompts</span>
<Switch
className="ml-auto"
size="small"
checked={disableShowPrompts}
onChange={(checked) => {
if (checked) {
setLocalStorageItem("disableShowPrompts", "true");
emitLocalStorageChange("disableShowPrompts");
} else {
removeLocalStorageItem("disableShowPrompts");
emitLocalStorageChange("disableShowPrompts");
}
}}
aria-label="Toggle hide all prompts"
/>
</div>
</div>
</div>
),
@ -227,6 +255,15 @@ const Navbar: React.FC<NavbarProps> = ({
>
Star us on GitHub
</Button>
{/* 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 && <Switch
data-testid="dark-mode-toggle"
checked={isDarkMode}
onChange={toggleDarkMode}
checkedChildren={<MoonOutlined />}
unCheckedChildren={<SunOutlined />}
/>}
<a
href="https://docs.litellm.ai/docs/"
target="_blank"

View file

@ -6948,6 +6948,62 @@ export const vectorStoreUpdateCall = async (accessToken: string, formValues: Rec
}
};
export const ragIngestCall = async (
accessToken: string,
file: File,
customLlmProvider: string,
vectorStoreId?: string,
vectorStoreName?: string,
vectorStoreDescription?: string
): Promise<any> => {
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<EmailEventSettingsResponse> => {
try {
const url = proxyBaseUrl ? `${proxyBaseUrl}/email/event_settings` : `/email/event_settings`;

View file

@ -321,9 +321,9 @@ const CreateKey: React.FC<CreateKeyProps> = ({ 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<CreateKeyProps> = ({ team, teams, data, addKey }) => {
onAutoRotationChange={setAutoRotationEnabled}
rotationInterval={rotationInterval}
onRotationIntervalChange={setRotationInterval}
isCreateMode={true}
/>
</div>
</AccordionBody>

View file

@ -962,6 +962,8 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
proxySettings={proxySettings}
accessToken={accessToken || null}
isPublicPage={true}
isDarkMode={false}
toggleDarkMode={() => { }}
/>
)}

View file

@ -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(<NudgePrompt {...defaultProps} />);
expect(screen.getByText("Test Title")).toBeInTheDocument();
});
it("should render with all provided props", () => {
const { container } = render(<NudgePrompt {...defaultProps} />);
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(<NudgePrompt {...defaultProps} isVisible={false} />);
expect(screen.queryByText("Test Title")).not.toBeInTheDocument();
});
it("should not render when disableShowPrompts is true", () => {
mockUseDisableShowPrompts.mockReturnValue(true);
render(<NudgePrompt {...defaultProps} />);
expect(screen.queryByText("Test Title")).not.toBeInTheDocument();
});
it("should display progress bar with correct accent color", () => {
const { container } = render(<NudgePrompt {...defaultProps} accentColor="#ff0000" />);
const progressBar = container.querySelector("div[style*='width']");
expect(progressBar).toHaveStyle({ backgroundColor: "#ff0000" });
});
it("should reset progress when isVisible becomes false", () => {
const { rerender, container } = render(<NudgePrompt {...defaultProps} />);
vi.advanceTimersByTime(5000);
rerender(<NudgePrompt {...defaultProps} isVisible={false} />);
rerender(<NudgePrompt {...defaultProps} isVisible={true} />);
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(<NudgePrompt {...defaultProps} buttonStyle={buttonStyle} />);
const openButton = screen.getByRole("button", { name: "Open Modal" });
expect(openButton).toHaveStyle(buttonStyle);
});
});

View file

@ -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 (
<div
className={`fixed bottom-6 right-6 z-40 w-80 bg-white rounded-lg shadow-xl border border-gray-200 overflow-hidden transform transition-all duration-300 ease-out ${isVisible ? "translate-y-0 opacity-100 scale-100" : "translate-y-4 opacity-0 scale-95"
}`}
>
<div className="p-4">
<div className="flex items-center gap-3">
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-green-100 flex items-center justify-center">
<Check className="h-5 w-5 text-green-600" />
</div>
<div className="flex-1">
<p className="text-sm text-gray-700 font-medium">
Got it, we will not ask again. Reactivate this at any time in the User Menu.
</p>
</div>
</div>
</div>
</div>
);
}
// Don't show the prompt if disabled (unless we're showing confirmation)
if (!isVisible || disableShowPrompts) return null;
return (
<div
className={`fixed bottom-6 right-6 z-40 w-80 bg-white rounded-lg shadow-xl border border-gray-200 overflow-hidden transform transition-all duration-300 ease-out ${
isVisible ? "translate-y-0 opacity-100 scale-100" : "translate-y-4 opacity-0 scale-95"
}`}
className={`fixed bottom-6 right-6 z-40 w-80 bg-white rounded-lg shadow-xl border border-gray-200 overflow-hidden transform transition-all duration-300 ease-out ${isVisible ? "translate-y-0 opacity-100 scale-100" : "translate-y-4 opacity-0 scale-95"
}`}
>
{/* Progress bar at top showing time remaining */}
<div className="h-1 bg-gray-100 w-full">
@ -81,9 +127,20 @@ export function NudgePrompt({
<p className="text-sm text-gray-600 mb-3">{description}</p>
<Button type="primary" block onClick={onOpen} style={buttonStyle}>
{buttonText}
</Button>
<div className="space-y-2">
<Button type="primary" block onClick={onOpen} style={buttonStyle}>
{buttonText}
</Button>
<Button
variant="outlined"
danger
block
onClick={handleDontAskAgain}
className="text-xs"
>
Don&apos;t ask me again
</Button>
</div>
</div>
</div>
);

View file

@ -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(<CreateVectorStore accessToken="test-token" />);
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(<CreateVectorStore accessToken="test-token" />);
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(<CreateVectorStore accessToken="test-token" />);
expect(screen.getByText("Provider")).toBeInTheDocument();
});
it("should have create button disabled initially when no documents", () => {
render(<CreateVectorStore accessToken="test-token" />);
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(<CreateVectorStore accessToken="test-token" />);
// 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(<CreateVectorStore accessToken="test-token" onSuccess={onSuccess} />);
// 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(<CreateVectorStore accessToken="test-token" />);
// 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();
});
});
});

View file

@ -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<CreateVectorStoreProps> = ({ accessToken, onSuccess }) => {
const [form] = Form.useForm();
const [documents, setDocuments] = useState<DocumentUpload[]>([]);
const [isCreating, setIsCreating] = useState(false);
const [selectedProvider, setSelectedProvider] = useState<string>("bedrock");
const [vectorStoreName, setVectorStoreName] = useState<string>("");
const [vectorStoreDescription, setVectorStoreDescription] = useState<string>("");
const [ingestResults, setIngestResults] = useState<RAGIngestResponse[]>([]);
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 (
<div className="space-y-6">
<div>
<Title>Create Vector Store</Title>
<Text className="text-gray-500">
Upload documents and select a provider to create a new vector store with embedded content.
</Text>
</div>
{/* Upload Area */}
<Card>
<div className="mb-4">
<Text className="font-medium">Step 1: Upload Documents</Text>
<Text className="text-sm text-gray-500 block mt-1">
Upload one or more documents (PDF, TXT, DOCX, MD). Maximum file size: 50MB per file.
</Text>
</div>
<Dragger {...uploadProps}>
<p className="ant-upload-drag-icon">
<InboxOutlined style={{ fontSize: "48px", color: "#1890ff" }} />
</p>
<p className="ant-upload-text">Click or drag files to this area to upload</p>
<p className="ant-upload-hint">
Support for single or bulk upload. Supported formats: PDF, TXT, DOCX, MD
</p>
</Dragger>
</Card>
{/* Documents Table */}
{documents.length > 0 && (
<Card>
<div className="mb-4">
<Text className="font-medium">Uploaded Documents ({documents.length})</Text>
</div>
<DocumentsTable documents={documents} onRemove={handleRemoveDocument} />
</Card>
)}
{/* Provider Selection and Vector Store Details */}
<Card>
<div className="space-y-4">
<div>
<Text className="font-medium">Step 2: Configure Vector Store</Text>
<Text className="text-sm text-gray-500 block mt-1">
Choose the provider and optionally provide a name and description for your vector store.
</Text>
</div>
<Form form={form} layout="vertical">
<Form.Item
label={
<span>
Vector Store Name{" "}
<Tooltip title="Optional: Give your vector store a meaningful name">
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
}
>
<Input
value={vectorStoreName}
onChange={(e) => setVectorStoreName(e.target.value)}
placeholder="e.g., Product Documentation, Customer Support KB"
size="large"
className="rounded-md"
/>
</Form.Item>
<Form.Item
label={
<span>
Description{" "}
<Tooltip title="Optional: Describe what this vector store contains">
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
}
>
<Input.TextArea
value={vectorStoreDescription}
onChange={(e) => setVectorStoreDescription(e.target.value)}
placeholder="e.g., Contains all product documentation and user guides"
rows={2}
size="large"
className="rounded-md"
/>
</Form.Item>
<Form.Item
label={
<span>
Provider{" "}
<Tooltip title="Select the provider for embedding and vector store operations">
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
}
required
>
<Select
value={selectedProvider}
onChange={setSelectedProvider}
placeholder="Select a provider"
size="large"
style={{ width: "100%" }}
>
{Object.entries(VectorStoreProviders).map(([providerEnum, providerDisplayName]) => {
return (
<Select.Option key={providerEnum} value={vectorStoreProviderMap[providerEnum]}>
<div className="flex items-center space-x-2">
<img
src={vectorStoreProviderLogoMap[providerDisplayName]}
alt={`${providerEnum} logo`}
className="w-5 h-5"
onError={(e) => {
// Create a div with provider initial as fallback
const target = e.target as HTMLImageElement;
const parent = target.parentElement;
if (parent) {
const fallbackDiv = document.createElement("div");
fallbackDiv.className =
"w-5 h-5 rounded-full bg-gray-200 flex items-center justify-center text-xs";
fallbackDiv.textContent = providerDisplayName.charAt(0);
parent.replaceChild(fallbackDiv, target);
}
}}
/>
<span>{providerDisplayName}</span>
</div>
</Select.Option>
);
})}
</Select>
</Form.Item>
</Form>
<div className="flex justify-end">
<Button
type="primary"
size="large"
onClick={handleCreateVectorStore}
loading={isCreating}
disabled={documents.length === 0 || !selectedProvider}
>
{isCreating ? "Creating Vector Store..." : "Create Vector Store"}
</Button>
</div>
</div>
</Card>
{/* Success Message */}
{ingestResults.length > 0 && (
<Alert
message="Vector Store Created Successfully"
description={
<div>
<p>
<strong>Vector Store ID:</strong> {ingestResults[0]?.vector_store_id}
</p>
<p>
<strong>Documents Ingested:</strong> {ingestResults.length}
</p>
</div>
}
type="success"
showIcon
closable
/>
)}
</div>
);
};
export default CreateVectorStore;

View file

@ -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(<DocumentsTable documents={mockDocuments} onRemove={onRemove} />);
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(<DocumentsTable documents={mockDocuments} onRemove={onRemove} />);
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(<DocumentsTable documents={mockDocuments} onRemove={onRemove} />);
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(<DocumentsTable documents={mockDocuments} onRemove={onRemove} />);
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(<DocumentsTable documents={[]} onRemove={onRemove} />);
expect(screen.getByText(/No documents uploaded yet/)).toBeInTheDocument();
});
it("should have action buttons for each document", () => {
const onRemove = vi.fn();
render(<DocumentsTable documents={mockDocuments} onRemove={onRemove} />);
// 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);
});
});

View file

@ -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<DocumentsTableProps> = ({ 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 <Badge color={config.color} text={config.text} />;
};
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) => (
<div className="flex items-center space-x-2">
<span className="text-sm">{name}</span>
{record.size && <span className="text-xs text-gray-400">({formatFileSize(record.size)})</span>}
</div>
),
},
{
title: "Status",
dataIndex: "status",
key: "status",
width: 150,
render: (status: DocumentUpload["status"]) => getStatusBadge(status),
},
{
title: "Actions",
key: "actions",
width: 120,
render: (_: any, record: DocumentUpload) => (
<div className="flex items-center space-x-2">
<Tooltip title="View details">
<EyeOutlined
className="cursor-pointer text-gray-600 hover:text-blue-500"
onClick={() => console.log("View", record)}
/>
</Tooltip>
<Tooltip title="Copy ID">
<CopyOutlined
className="cursor-pointer text-gray-600 hover:text-blue-500"
onClick={() => handleCopyId(record.uid)}
/>
</Tooltip>
<Tooltip title="Remove">
<DeleteOutlined
className="cursor-pointer text-gray-600 hover:text-red-500"
onClick={() => onRemove(record.uid)}
/>
</Tooltip>
</div>
),
},
];
return (
<Table
dataSource={documents}
columns={columns}
rowKey="uid"
pagination={false}
locale={{
emptyText: "No documents uploaded yet. Upload documents above to get started.",
}}
size="small"
/>
);
};
export default DocumentsTable;

View file

@ -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 }) => (
<div data-testid="vector-store-tester">
<div data-testid="tester-vector-store-id">{vectorStoreId}</div>
<div data-testid="tester-access-token">{accessToken}</div>
</div>
),
}));
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(<TestVectorStoreTab accessToken="test-token" vectorStores={mockVectorStores} />);
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(<TestVectorStoreTab accessToken={null} vectorStores={mockVectorStores} />);
expect(screen.getByText("Access token is required to test vector stores.")).toBeInTheDocument();
});
it("should show message when no vector stores available", () => {
render(<TestVectorStoreTab accessToken="test-token" vectorStores={[]} />);
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(<TestVectorStoreTab accessToken="test-token" vectorStores={mockVectorStores} />);
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(<TestVectorStoreTab accessToken="test-token" vectorStores={mockVectorStores} />);
// 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(<TestVectorStoreTab accessToken="test-token" vectorStores={mockVectorStores} />);
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();
});
});

View file

@ -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<TestVectorStoreTabProps> = ({ accessToken, vectorStores }) => {
const [selectedVectorStoreId, setSelectedVectorStoreId] = useState<string | undefined>(
vectorStores.length > 0 ? vectorStores[0].vector_store_id : undefined
);
if (!accessToken) {
return (
<Card>
<Text type="secondary">Access token is required to test vector stores.</Text>
</Card>
);
}
if (vectorStores.length === 0) {
return (
<Card>
<div className="text-center py-8">
<Text type="secondary">No vector stores available. Create one first to test it.</Text>
</div>
</Card>
);
}
return (
<div className="space-y-4">
<Card>
<div className="space-y-4">
<div>
<Title level={5}>Select Vector Store</Title>
<Text type="secondary">Choose a vector store to test search queries against</Text>
</div>
<Select
value={selectedVectorStoreId}
onChange={setSelectedVectorStoreId}
placeholder="Select a vector store"
size="large"
style={{ width: "100%" }}
showSearch
optionFilterProp="children"
>
{vectorStores.map((vs) => (
<Select.Option key={vs.vector_store_id} value={vs.vector_store_id}>
<div className="flex flex-col">
<span className="font-medium">{vs.vector_store_name || vs.vector_store_id}</span>
{vs.vector_store_name && (
<span className="text-xs text-gray-500 font-mono">{vs.vector_store_id}</span>
)}
</div>
</Select.Option>
))}
</Select>
</div>
</Card>
{selectedVectorStoreId && (
<VectorStoreTester vectorStoreId={selectedVectorStoreId} accessToken={accessToken} />
)}
</div>
);
};
export default TestVectorStoreTab;

View file

@ -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", () => {

View file

@ -66,6 +66,32 @@ const VectorStoreTable: React.FC<VectorStoreTableProps> = ({ 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 <span className="text-xs text-gray-400">-</span>;
}
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 (
<Tooltip title={filenames}>
<span className="text-xs text-blue-600">{displayText}</span>
</Tooltip>
);
},
},
{
header: "Provider",
accessorKey: "custom_llm_provider",

View file

@ -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<VectorStoreProps> = ({ 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<VectorStoreProps> = ({ accessToken, userID
</div>
<Text className="mb-4">
<p>You can use vector stores to store and retrieve LLM embeddings..</p>
<p>You can use vector stores to store and retrieve LLM embeddings.</p>
</Text>
<TremorButton className="mb-4" onClick={() => setIsCreateModalVisible(true)}>
+ Add Vector Store
</TremorButton>
<TabGroup>
<TabList className="mb-6">
<Tab>Create Vector Store</Tab>
<Tab>Manage Vector Stores</Tab>
<Tab>Test Vector Store</Tab>
</TabList>
<Grid numItems={1} className="gap-2 pt-2 pb-2 h-[75vh] w-full mt-2">
<Col numColSpan={1}>
<VectorStoreTable data={vectorStores} onView={handleView} onEdit={handleEdit} onDelete={handleDelete} />
</Col>
</Grid>
<TabPanels>
{/* Tab 1: Create Vector Store */}
<TabPanel>
<CreateVectorStore accessToken={accessToken} onSuccess={handleVectorStoreCreated} />
</TabPanel>
{/* Tab 2: Manage Vector Stores */}
<TabPanel>
<TremorButton className="mb-4" onClick={() => setIsCreateModalVisible(true)}>
+ Add Vector Store
</TremorButton>
<Grid numItems={1} className="gap-2 pt-2 pb-2 w-full mt-2">
<Col numColSpan={1}>
<VectorStoreTable
data={vectorStores}
onView={handleView}
onEdit={handleEdit}
onDelete={handleDelete}
/>
</Col>
</Grid>
</TabPanel>
{/* Tab 3: Test Vector Store */}
<TabPanel>
<TestVectorStoreTab accessToken={accessToken} vectorStores={vectorStores} />
</TabPanel>
</TabPanels>
</TabGroup>
{/* Create Vector Store Modal */}
<VectorStoreForm

View file

@ -1,9 +1,23 @@
export interface IngestedFile {
file_id?: string;
filename?: string;
file_url?: string;
ingested_at: string;
file_size?: number;
content_type?: string;
}
export interface VectorStoreMetadata {
ingested_files?: IngestedFile[];
[key: string]: any;
}
export interface VectorStore {
vector_store_id: string;
custom_llm_provider: string;
vector_store_name?: string;
vector_store_description?: string;
vector_store_metadata?: Record<string, any>;
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;
}

View file

@ -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<ViewUserDashboardProps> = ({ 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<ViewUserDashboardProps> = ({ accessToken, toke
/>
<BulkEditUserModal
visible={isBulkEditModalVisible}
open={isBulkEditModalVisible}
onCancel={() => setIsBulkEditModalVisible(false)}
selectedUsers={selectedUsers}
possibleUIRoles={possibleUIRoles}