diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/CreateVectorStore.characterization.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/CreateVectorStore.characterization.test.tsx new file mode 100644 index 00000000000..c79d35d46c3 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/CreateVectorStore.characterization.test.tsx @@ -0,0 +1,231 @@ +import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import CreateVectorStore from "./CreateVectorStore"; +import * as networking from "@/components/networking"; +import * as fetchModels from "@/components/llm_calls/fetch_models"; + +vi.mock("@/components/networking", () => ({ + ragIngestCall: vi.fn(), +})); + +vi.mock("@/components/llm_calls/fetch_models", () => ({ + fetchAvailableModels: vi.fn(), +})); + +vi.mock("@/components/vector_store_providers", () => ({ + VectorStoreProviders: { + BEDROCK: "Amazon Bedrock", + S3Vectors: "AWS S3 Vectors", + PGVECTOR: "PG Vector", + }, + vectorStoreProviderMap: { + BEDROCK: "bedrock", + S3Vectors: "s3_vectors", + PGVECTOR: "pg_vector", + }, + vectorStoreProviderLogoMap: { + "Amazon Bedrock": "https://example.com/bedrock.png", + "AWS S3 Vectors": "https://example.com/aws.png", + "PG Vector": "https://example.com/pg.png", + }, + getProviderSpecificFields: vi.fn((provider: string) => { + if (provider === "pg_vector") { + return [ + { + name: "api_base", + label: "API Base", + tooltip: "Base URL of the pgvector server", + placeholder: "http://localhost:8000", + required: true, + type: "text", + }, + { + name: "api_key", + label: "API Key", + tooltip: "Secret for the pgvector server", + placeholder: "sk-...", + required: false, + type: "password", + }, + { + name: "embedding_model", + label: "Embedding Model", + tooltip: "Model used to embed documents", + placeholder: "text-embedding-3-small", + required: false, + type: "select", + }, + ]; + } + return []; + }), +})); + +const uploadFile = async (name = "test.pdf") => { + const file = new File(["test content"], name, { type: "application/pdf" }); + const uploadInput = document.querySelector('input[type="file"]') as HTMLInputElement; + await act(async () => { + fireEvent.change(uploadInput, { target: { files: [file] } }); + }); + await screen.findByText(/Uploaded Documents \(1\)/); +}; + +const pickProvider = async (label: string) => { + const user = userEvent.setup(); + const trigger = screen.getAllByRole("combobox")[0]; + await user.click(trigger); + const option = await screen.findByText(label); + await user.click(option); +}; + +const clickCreate = async () => { + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /Create Vector Store/i })); + }); +}; + +describe("CreateVectorStore submit payload characterization", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(fetchModels.fetchAvailableModels).mockResolvedValue([]); + vi.mocked(networking.ragIngestCall).mockResolvedValue({ + id: "test-id", + status: "completed", + vector_store_id: "vs_123", + file_id: "file_123", + }); + }); + + it("sends undefined, not empty string, for an untouched name and description", async () => { + render(); + await uploadFile(); + await clickCreate(); + + await waitFor(() => expect(networking.ragIngestCall).toHaveBeenCalledTimes(1)); + expect(networking.ragIngestCall).toHaveBeenCalledWith( + "test-token", + expect.any(File), + "bedrock", + undefined, + undefined, + undefined, + {}, + ); + }); + + it("forwards the typed name and description verbatim", async () => { + render(); + await uploadFile(); + + fireEvent.change(screen.getByPlaceholderText("e.g., Product Documentation, Customer Support KB"), { + target: { value: " Product Docs " }, + }); + fireEvent.change(screen.getByPlaceholderText("e.g., Contains all product documentation and user guides"), { + target: { value: "All the guides" }, + }); + await clickCreate(); + + await waitFor(() => expect(networking.ragIngestCall).toHaveBeenCalledTimes(1)); + expect(networking.ragIngestCall).toHaveBeenCalledWith( + "test-token", + expect.any(File), + "bedrock", + undefined, + " Product Docs ", + "All the guides", + {}, + ); + }); + + it("accumulates provider-specific fields into the providerParams argument", async () => { + render(); + await uploadFile(); + await pickProvider("PG Vector"); + + fireEvent.change(screen.getByPlaceholderText("http://localhost:8000"), { + target: { value: "http://pg.internal:8000" }, + }); + fireEvent.change(screen.getByPlaceholderText("sk-..."), { target: { value: "sk-secret" } }); + await clickCreate(); + + await waitFor(() => expect(networking.ragIngestCall).toHaveBeenCalledTimes(1)); + expect(networking.ragIngestCall).toHaveBeenCalledWith( + "test-token", + expect.any(File), + "pg_vector", + undefined, + undefined, + undefined, + { api_base: "http://pg.internal:8000", api_key: "sk-secret" }, + ); + }); + + it("loads the S3 embedding models, filters them by typing and sends the chosen one", async () => { + vi.mocked(fetchModels.fetchAvailableModels).mockResolvedValue([ + { model_group: "text-embedding-3-small", mode: "embedding" }, + { model_group: "text-embedding-3-large", mode: "embedding" }, + { model_group: "gpt-5", mode: "chat" }, + ] as Awaited>); + const user = userEvent.setup(); + render(); + await uploadFile(); + await pickProvider("AWS S3 Vectors"); + + const modelInput = screen.getAllByRole("combobox").at(-1) as HTMLElement; + await user.click(modelInput); + expect((await screen.findAllByText("text-embedding-3-small")).at(-1)).toBeInTheDocument(); + expect(screen.queryByText("gpt-5")).not.toBeInTheDocument(); + + await user.type(modelInput, "large"); + await user.click((await screen.findAllByText("text-embedding-3-large")).at(-1) as HTMLElement); + await clickCreate(); + + await waitFor(() => expect(networking.ragIngestCall).toHaveBeenCalledTimes(1)); + expect(vi.mocked(networking.ragIngestCall).mock.calls.at(-1)?.[6]).toEqual({ + embedding_model: "text-embedding-3-large", + }); + }); + + it("blocks the submit when a required provider field is missing", async () => { + render(); + await uploadFile(); + await pickProvider("PG Vector"); + await clickCreate(); + + expect(networking.ragIngestCall).not.toHaveBeenCalled(); + }); + + it("renders the password provider field as a masked input", async () => { + render(); + await pickProvider("PG Vector"); + + expect(screen.getByPlaceholderText("sk-...")).toHaveAttribute("type", "password"); + }); + + it("reuses the vector store id returned by the first ingest for later documents", async () => { + render(); + + const files = [ + new File(["a"], "a.pdf", { type: "application/pdf" }), + new File(["b"], "b.pdf", { type: "application/pdf" }), + ]; + const uploadInput = document.querySelector('input[type="file"]') as HTMLInputElement; + await act(async () => { + fireEvent.change(uploadInput, { target: { files } }); + }); + await screen.findByText(/Uploaded Documents \(2\)/); + await clickCreate(); + + await waitFor(() => expect(networking.ragIngestCall).toHaveBeenCalledTimes(2)); + expect(vi.mocked(networking.ragIngestCall).mock.calls[0][3]).toBeUndefined(); + expect(vi.mocked(networking.ragIngestCall).mock.calls[1][3]).toBe("vs_123"); + }); + + it("does not submit at all when no document has been uploaded", async () => { + render(); + await clickCreate(); + + expect(networking.ragIngestCall).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/CreateVectorStore.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/CreateVectorStore.test.tsx index 7405fa5c2fc..e0bd4feef59 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/CreateVectorStore.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/CreateVectorStore.test.tsx @@ -1,4 +1,5 @@ import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { describe, it, expect, vi, beforeEach } from "vitest"; import CreateVectorStore from "./CreateVectorStore"; import * as networking from "@/components/networking"; @@ -204,17 +205,10 @@ describe("CreateVectorStore", () => { // Find and click the provider dropdown const providerSelect = screen.getByRole("combobox"); - await act(async () => { - fireEvent.mouseDown(providerSelect); - }); + await userEvent.click(providerSelect); // Wait for dropdown options to appear - await waitFor(() => { - const s3Option = screen.queryByText("AWS S3 Vectors"); - if (s3Option) { - fireEvent.click(s3Option); - } - }); + await userEvent.click(await screen.findByText("AWS S3 Vectors")); // Check if S3-specific fields are displayed await waitFor(() => { @@ -244,16 +238,9 @@ describe("CreateVectorStore", () => { // Select S3 Vectors provider const providerSelect = screen.getByRole("combobox"); - await act(async () => { - fireEvent.mouseDown(providerSelect); - }); + await userEvent.click(providerSelect); - await waitFor(() => { - const s3Option = screen.queryByText("AWS S3 Vectors"); - if (s3Option) { - fireEvent.click(s3Option); - } - }); + await userEvent.click(await screen.findByText("AWS S3 Vectors")); // Try to create without filling required fields const createButton = screen.getByRole("button", { name: /Create Vector Store/i }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/CreateVectorStore.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/CreateVectorStore.tsx index a0a2759eccb..baa0170c278 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/CreateVectorStore.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/CreateVectorStore.tsx @@ -1,9 +1,10 @@ import React, { useState } from "react"; import { Card, Title, Text } from "@tremor/react"; -import { Upload, Button, Select, Form, Alert, Tooltip, Input } from "antd"; +import { Upload, Alert } from "antd"; import { toast } from "@/lib/toast"; -import { InboxOutlined, InfoCircleOutlined } from "@ant-design/icons"; +import { InboxOutlined } from "@ant-design/icons"; import type { UploadProps } from "antd"; +import { CircleHelp } from "lucide-react"; import { ragIngestCall } from "@/components/networking"; import { DocumentUpload, RAGIngestResponse } from "@/components/vector_store_management/types"; import DocumentsTable from "./DocumentsTable"; @@ -15,24 +16,42 @@ import { VectorStoreFieldConfig, } from "@/components/vector_store_providers"; import { Logo } from "@/components/molecules/logo/Logo"; +import { Field, FieldGroup, FieldLabel } from "@/components/shared/form/field"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import S3VectorsConfig from "./S3VectorsConfig"; const { Dragger } = Upload; +const asText = (value: unknown): string => (typeof value === "string" ? value : ""); + +const labelWithHint = (label: string, hint: string): React.ReactNode => ( + <> + {label} + + } /> + {hint} + + +); + interface CreateVectorStoreProps { accessToken: string | null; onSuccess?: (vectorStoreId: string) => void; } const CreateVectorStore: React.FC = ({ accessToken, onSuccess }) => { - const [form] = Form.useForm(); const [documents, setDocuments] = useState([]); const [isCreating, setIsCreating] = useState(false); const [selectedProvider, setSelectedProvider] = useState("bedrock"); const [vectorStoreName, setVectorStoreName] = useState(""); const [vectorStoreDescription, setVectorStoreDescription] = useState(""); const [ingestResults, setIngestResults] = useState([]); - const [providerParams, setProviderParams] = useState>({}); + const [providerParams, setProviderParams] = useState>({}); const uploadProps: UploadProps = { name: "file", @@ -108,11 +127,13 @@ const CreateVectorStore: React.FC = ({ accessToken, onSu // S3 Vectors specific validation if (selectedProvider === "s3_vectors") { - if (providerParams.vector_bucket_name && providerParams.vector_bucket_name.length < 3) { + const bucketName = asText(providerParams.vector_bucket_name); + const indexName = asText(providerParams.index_name); + if (bucketName && bucketName.length < 3) { toast.warning("Vector bucket name must be at least 3 characters"); return; } - if (providerParams.index_name && providerParams.index_name.length > 0 && providerParams.index_name.length < 3) { + if (indexName && indexName.length > 0 && indexName.length < 3) { toast.warning("Index name must be at least 3 characters if provided"); return; } @@ -186,225 +207,165 @@ const CreateVectorStore: React.FC = ({ accessToken, onSu }; return ( -
-
- Create Vector Store - - Upload documents and select a provider to create a new vector store with embedded content. - -
- - {/* Upload Area */} - -
- Step 1: Upload Documents - - Upload one or more documents (PDF, TXT, DOCX, MD). Maximum file size: 50MB per file. + +
+
+ Create Vector Store + + Upload documents and select a provider to create a new vector store with embedded content.
- -

- -

-

Click or drag files to this area to upload

-

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

-
- - {/* Documents Table */} - {documents.length > 0 && ( + {/* Upload Area */}
- Uploaded Documents ({documents.length}) -
- -
- )} - - {/* Provider Selection and Vector Store Details */} - -
-
- Step 2: Configure Vector Store - - Choose the provider and optionally provide a name and description for your vector store. + Step 1: Upload Documents + + Upload one or more documents (PDF, TXT, DOCX, MD). Maximum file size: 50MB per file.
+ +

+ +

+

Click or drag files to this area to upload

+

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

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