diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index dd90eadad08..5cc055a50ff 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -322,12 +322,6 @@ } }, "src/app/(dashboard)/guardrails/_components/content_filter/CompetitorIntentConfiguration.tsx": { - "no-nested-ternary": { - "count": 1 - }, - "no-restricted-imports": { - "count": 1 - }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -1546,9 +1540,6 @@ "count": 2 }, "no-restricted-imports": { - "count": 2 - }, - "react/no-unescaped-entities": { "count": 1 } }, @@ -1565,7 +1556,7 @@ "count": 1 }, "no-restricted-imports": { - "count": 2 + "count": 1 }, "react-hooks/set-state-in-effect": { "count": 1 @@ -3240,4 +3231,4 @@ "count": 1 } } -} \ No newline at end of file +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/TeamGuardrailsTab.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/TeamGuardrailsTab.integration.test.tsx new file mode 100644 index 00000000000..8eefa6dcf81 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/TeamGuardrailsTab.integration.test.tsx @@ -0,0 +1,193 @@ +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { renderWithProviders } from "@/../tests/test-utils"; +import { listGuardrailSubmissions } from "@/components/networking"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; + +import { TeamGuardrailsTab } from "./TeamGuardrailsTab"; + +const mutateAsync = vi.fn(); + +vi.mock("@/components/networking", () => ({ + listGuardrailSubmissions: vi.fn(), + approveGuardrailSubmission: vi.fn(), + rejectGuardrailSubmission: vi.fn(), + updateGuardrailCall: vi.fn(), +})); + +vi.mock("@/app/(dashboard)/hooks/guardrails/useRegisterGuardrail", () => ({ + useRegisterGuardrail: () => ({ mutateAsync, isPending: false }), +})); + +vi.mock("@/components/common_components/team_dropdown", () => ({ + default: ({ value, onChange }: { value?: string; onChange?: (value: string) => void }) => ( + onChange?.(event.target.value)} /> + ), +})); + +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: vi.fn() })); + +const authorized = { + isLoading: false, + isAuthorized: true, + token: "sk-test", + accessToken: "sk-test", + userId: "user-1", + userEmail: "user@example.com", + userRole: "Admin", + userRoleLabel: "Admin", + isViewOnly: false, + premiumUser: false, + disabledPersonalKeyCreation: null, + showSSOBanner: false, +}; + +const openSubmitModal = async (user: ReturnType) => { + renderWithProviders(); + await user.click(await screen.findByRole("button", { name: /Add Guardrail/ })); + await screen.findByText("Submit Guardrail for Review"); +}; + +const fillRequiredFields = async (user: ReturnType, apiBase: string) => { + await user.type(screen.getByLabelText("team"), "team-1"); + await user.type(screen.getByPlaceholderText("e.g. pii-detection"), "pii-detection"); + await user.type(screen.getByPlaceholderText("https://your-guardrail-api.com/v1/check"), apiBase); +}; + +const submit = (user: ReturnType) => + user.click(screen.getByRole("button", { name: "Submit for Review" })); + +const registeredPayload = () => mutateAsync.mock.calls[0][0]; + +const VALIDATION_MESSAGES = [ + "Select a team", + "Enter a guardrail name", + "Enter the API base URL", + "Must be a valid URL", + "Must be a JSON object", + "Invalid JSON", +]; + +const visibleErrors = () => VALIDATION_MESSAGES.filter((message) => screen.queryByText(message) !== null); + +describe("TeamGuardrailsTab submit payload", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useAuthorized).mockReturnValue(authorized); + vi.mocked(listGuardrailSubmissions).mockResolvedValue({ + submissions: [], + summary: { total: 0, pending_review: 0, active: 0, rejected: 0 }, + }); + }); + + it("sends the guardrail defaults and leaves guardrail_info undefined when the optional fields are blank", async () => { + const user = userEvent.setup(); + await openSubmitModal(user); + + await fillRequiredFields(user, "https://guard.example.com/v1/check"); + await submit(user); + + await vi.waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1)); + expect(registeredPayload()).toStrictEqual({ + team_id: "team-1", + guardrail_name: "pii-detection", + litellm_params: { + guardrail: "generic_guardrail_api", + mode: "pre_call", + api_base: "https://guard.example.com/v1/check", + }, + guardrail_info: undefined, + }); + }); + + it("merges the extra params underneath the form fields so the form's api_base and mode win", async () => { + const user = userEvent.setup(); + await openSubmitModal(user); + + await fillRequiredFields(user, "https://guard.example.com/v1/check"); + await user.type( + screen.getByPlaceholderText('{"forward_api_key": true, "headers": {"X-Custom": "value"}}'), + '{{"forward_api_key": true, "api_base": "https://ignored.example.com", "mode": "post_call"}', + ); + await user.type( + screen.getByPlaceholderText('{"description": "Detects PII in requests"}'), + '{{"description": "Detects PII"}', + ); + await submit(user); + + await vi.waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1)); + expect(registeredPayload()).toStrictEqual({ + team_id: "team-1", + guardrail_name: "pii-detection", + litellm_params: { + forward_api_key: true, + api_base: "https://guard.example.com/v1/check", + mode: "pre_call", + guardrail: "generic_guardrail_api", + }, + guardrail_info: { description: "Detects PII" }, + }); + }); + + it("sends the mode the user picked", async () => { + const user = userEvent.setup(); + await openSubmitModal(user); + + await fillRequiredFields(user, "https://guard.example.com/v1/check"); + await user.click(screen.getAllByRole("combobox")[1]); + const options = await screen.findAllByText("During Call"); + await user.click(options[options.length - 1]); + await submit(user); + + await vi.waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1)); + expect(registeredPayload().litellm_params.mode).toBe("during_call"); + }); + + it("blocks an empty submit and reports every required field", async () => { + const user = userEvent.setup(); + await openSubmitModal(user); + + await submit(user); + + expect(await screen.findByText("Enter a guardrail name")).toBeInTheDocument(); + expect(visibleErrors()).toStrictEqual(["Select a team", "Enter a guardrail name", "Enter the API base URL"]); + expect(mutateAsync).not.toHaveBeenCalled(); + }); + + it("accepts a protocol-less www host but rejects a bare domain", async () => { + const user = userEvent.setup(); + await openSubmitModal(user); + + await user.type(screen.getByPlaceholderText("https://your-guardrail-api.com/v1/check"), "www.example.com"); + await submit(user); + await screen.findByText("Enter a guardrail name"); + expect(visibleErrors()).toStrictEqual(["Select a team", "Enter a guardrail name"]); + + await user.clear(screen.getByPlaceholderText("https://your-guardrail-api.com/v1/check")); + await user.type(screen.getByPlaceholderText("https://your-guardrail-api.com/v1/check"), "example.com"); + await submit(user); + + await vi.waitFor(() => + expect(visibleErrors()).toStrictEqual(["Select a team", "Enter a guardrail name", "Must be a valid URL"]), + ); + expect(mutateAsync).not.toHaveBeenCalled(); + }); + + it("rejects a non-object extra params value and unparsable guardrail info", async () => { + const user = userEvent.setup(); + await openSubmitModal(user); + + await fillRequiredFields(user, "https://guard.example.com/v1/check"); + await user.type( + screen.getByPlaceholderText('{"forward_api_key": true, "headers": {"X-Custom": "value"}}'), + '"a plain string"', + ); + await user.type(screen.getByPlaceholderText('{"description": "Detects PII in requests"}'), "nope"); + await submit(user); + + await vi.waitFor(() => expect(visibleErrors()).toStrictEqual(["Must be a JSON object", "Invalid JSON"])); + expect(mutateAsync).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/TeamGuardrailsTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/TeamGuardrailsTab.tsx index 92fc117ebf8..3909857cc8d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/TeamGuardrailsTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/TeamGuardrailsTab.tsx @@ -15,8 +15,10 @@ import { ServerIcon, AlertCircleIcon, InfoIcon, + CircleHelp, } from "lucide-react"; -import { Modal, Form, Input, Select } from "antd"; +import { Modal } from "antd"; +import { z } from "zod/v4"; import { listGuardrailSubmissions, approveGuardrailSubmission, @@ -29,6 +31,67 @@ import TeamDropdown from "@/components/common_components/team_dropdown"; import { useRegisterGuardrail } from "@/app/(dashboard)/hooks/guardrails/useRegisterGuardrail"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { isProxyAdminRole } from "@/utils/roles"; +import { FieldGroup } from "@/components/shared/form/field"; +import { FormField } from "@/components/shared/form/FormField"; +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 { isAntdUrl } from "@/lib/forms/antdUrl"; +import { useZodForm } from "@/lib/forms/useZodForm"; + +const GUARDRAIL_MODES = [ + { value: "pre_call", label: "Pre Call" }, + { value: "post_call", label: "Post Call" }, + { value: "during_call", label: "During Call" }, +] as const; + +const submitGuardrailSchema = z.object({ + team_id: z.string().min(1, "Select a team"), + guardrail_name: z.string().min(1, "Enter a guardrail name"), + mode: z.string().min(1, "Select a mode"), + api_base: z.string().min(1, "Enter the API base URL").refine(isAntdUrl, "Must be a valid URL"), + extra_litellm_params: z.string().superRefine((value, ctx) => { + if (!value) return; + try { + const parsed: unknown = JSON.parse(value); + if (typeof parsed !== "object" || Array.isArray(parsed)) { + ctx.addIssue({ code: "custom", message: "Must be a JSON object" }); + } + } catch { + ctx.addIssue({ code: "custom", message: "Invalid JSON" }); + } + }), + guardrail_info: z.string().superRefine((value, ctx) => { + if (!value) return; + try { + JSON.parse(value); + } catch { + ctx.addIssue({ code: "custom", message: "Invalid JSON" }); + } + }), +}); + +type SubmitGuardrailValues = z.output; + +const EMPTY_SUBMIT_VALUES: SubmitGuardrailValues = { + team_id: "", + guardrail_name: "", + mode: "pre_call", + api_base: "", + extra_litellm_params: "", + guardrail_info: "", +}; + +const labelWithHint = (label: string, hint: string): React.ReactNode => ( + <> + {label} + + } /> + {hint} + + > +); type GuardrailStatus = "active" | "pending" | "rejected"; @@ -116,31 +179,31 @@ function submissionToTeamGuardrail(item: GuardrailSubmissionItem): TeamGuardrail const STATUS_CONFIG: Record = { active: { label: "Active", - bg: "bg-green-50", - text: "text-green-700", + bg: "bg-green-50 dark:bg-green-950", + text: "text-green-700 dark:text-green-300", dot: "bg-green-500", }, pending: { label: "Pending Review", - bg: "bg-yellow-50", - text: "text-yellow-700", + bg: "bg-yellow-50 dark:bg-yellow-950", + text: "text-yellow-700 dark:text-yellow-300", dot: "bg-yellow-500", }, rejected: { label: "Rejected", - bg: "bg-red-50", - text: "text-red-700", + bg: "bg-red-50 dark:bg-red-950", + text: "text-red-700 dark:text-red-300", dot: "bg-red-500", }, }; const TEAM_COLORS: Record = { - "ML Platform": "bg-purple-100 text-purple-700", - "Data Science": "bg-blue-100 text-blue-700", - Security: "bg-red-100 text-red-700", - "Customer Success": "bg-orange-100 text-orange-700", - Legal: "bg-gray-100 text-gray-700", - Finance: "bg-green-100 text-green-700", + "ML Platform": "bg-purple-100 dark:bg-purple-900 text-purple-700 dark:text-purple-300", + "Data Science": "bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300", + Security: "bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-300", + "Customer Success": "bg-orange-100 dark:bg-orange-900 text-orange-700 dark:text-orange-300", + Legal: "bg-muted text-foreground", + Finance: "bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300", }; function buildEquivalentConfigYaml(g: TeamGuardrail): string { @@ -183,9 +246,9 @@ function buildEquivalentConfigYaml(g: TeamGuardrail): string { function StatCard({ label, value, color }: { label: string; value: number; color: string }) { return ( - + {value} - {label} + {label} ); } @@ -207,7 +270,7 @@ function Toggle({ aria-checked={enabled} disabled={disabled} className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 ${ - enabled ? "bg-blue-500" : "bg-gray-200" + enabled ? "bg-blue-500" : "bg-muted" } ${disabled ? "opacity-50 cursor-not-allowed" : ""}`} > @@ -261,31 +324,31 @@ function GuardrailCard({ {status.label} - {g.name} - {g.description} + {g.name} + {g.description} - - {g.endpoint} + + {g.endpoint} - + - Model: {g.model} + Model: {g.model} - Submitted: {g.submittedAt} + Submitted: {g.submittedAt} - Forward API Key + Forward API Key {isSelected ? "Close" : "Review"} @@ -301,7 +364,7 @@ function GuardrailCard({ Reject @@ -310,16 +373,16 @@ function GuardrailCard({ - + {isHeadersExpanded ? : } Static headers {g.customHeaders.length > 0 && ( - + {g.customHeaders.length} )} @@ -327,16 +390,16 @@ function GuardrailCard({ {isHeadersExpanded && ( {g.customHeaders.length === 0 ? ( - No static headers configured. + No static headers configured. ) : ( {g.customHeaders.map((h, i) => ( - + {h.key} - : - + : + {h.value} @@ -353,7 +416,7 @@ function GuardrailCard({ function ConfigRow({ label, children }: { label: string; children: React.ReactNode }) { return ( - {label} + {label} {children} ); @@ -385,9 +448,9 @@ function DetailPanel({ const [newStaticHeaderKey, setNewStaticHeaderKey] = useState(""); const [newStaticHeaderValue, setNewStaticHeaderValue] = useState(""); const status = STATUS_CONFIG[g.status]; - const teamColor = TEAM_COLORS[g.team] ?? "bg-gray-100 text-gray-700"; + const teamColor = TEAM_COLORS[g.team] ?? "bg-muted text-foreground"; return ( - + @@ -400,82 +463,82 @@ function DetailPanel({ {status.label} - {g.name} - + {g.name} + Submitted by {g.submittedBy} on {g.submittedAt} - {g.description} + {g.description} - {g.endpoint} + {g.endpoint} - + {g.method} - + - Forward LiteLLM API Key + Forward LiteLLM API Key - + When enabled, the caller's LiteLLM API key is forwarded as an{" "} - Authorization header to your guardrail - endpoint. This allows your guardrail to authenticate model calls using the original caller's - credentials. + Authorization header to + your guardrail endpoint. This allows your guardrail to authenticate model calls using the original + caller's credentials. - Static headers + Static headers {g.customHeaders.length > 0 && ( - + {g.customHeaders.length} )} - Sent with every request to the guardrail. + Sent with every request to the guardrail. {g.customHeaders.length === 0 ? ( - No static headers configured. + No static headers configured. ) : ( {g.customHeaders.map((h, i) => ( - + {h.key}: {h.value} {isAdmin && ( onUpdateCustomHeaders(g.customHeaders.filter((_, idx) => idx !== i))} - className="text-gray-400 hover:text-red-600 shrink-0" + className="text-muted-foreground hover:text-red-600 shrink-0" aria-label={`Remove ${h.key}`} > @@ -492,7 +555,7 @@ function DetailPanel({ value={newStaticHeaderKey} onChange={(e) => setNewStaticHeaderKey(e.target.value)} placeholder="Header name (e.g. X-API-Key)" - className="flex-1 min-w-0 text-xs font-mono border border-gray-200 rounded-sm px-2 py-1.5 text-gray-700 placeholder-gray-400 focus:outline-hidden focus:ring-1 focus:ring-blue-500" + className="flex-1 min-w-0 text-xs font-mono border border-border rounded-sm px-2 py-1.5 text-foreground placeholder:text-muted-foreground focus:outline-hidden focus:ring-1 focus:ring-blue-500" onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); @@ -511,7 +574,7 @@ function DetailPanel({ value={newStaticHeaderValue} onChange={(e) => setNewStaticHeaderValue(e.target.value)} placeholder="Value" - className="flex-1 min-w-0 text-xs font-mono border border-gray-200 rounded-sm px-2 py-1.5 text-gray-700 placeholder-gray-400 focus:outline-hidden focus:ring-1 focus:ring-blue-500" + className="flex-1 min-w-0 text-xs font-mono border border-border rounded-sm px-2 py-1.5 text-foreground placeholder:text-muted-foreground focus:outline-hidden focus:ring-1 focus:ring-blue-500" onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); @@ -536,7 +599,7 @@ function DetailPanel({ setNewStaticHeaderValue(""); } }} - className="text-xs font-medium text-blue-600 hover:text-blue-700 border border-blue-200 bg-blue-50 hover:bg-blue-100 px-2 py-1.5 rounded-sm transition-colors shrink-0" + className="text-xs font-medium text-blue-600 hover:text-blue-700 dark:hover:text-blue-300 dark:text-blue-300 border border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-950 hover:bg-blue-100 dark:hover:bg-blue-900 dark:bg-blue-900 px-2 py-1.5 rounded-sm transition-colors shrink-0" > Add @@ -545,31 +608,31 @@ function DetailPanel({ - Forward client headers + Forward client headers {g.extraHeaders.length > 0 && ( - + {g.extraHeaders.length} )} - + Allowed header names to forward from the client request to the guardrail (e.g. x-request-id). {g.extraHeaders.length === 0 ? ( - No forward client headers configured. + No forward client headers configured. ) : ( {g.extraHeaders.map((name, i) => ( - {name} + {name} {isAdmin && ( onUpdateExtraHeaders(g.extraHeaders.filter((_, idx) => idx !== i))} - className="text-gray-400 hover:text-red-600 shrink-0" + className="text-muted-foreground hover:text-red-600 shrink-0" aria-label={`Remove ${name}`} > @@ -586,7 +649,7 @@ function DetailPanel({ value={newExtraHeader} onChange={(e) => setNewExtraHeader(e.target.value)} placeholder="e.g. x-request-id" - className="flex-1 min-w-0 text-xs font-mono border border-gray-200 rounded-sm px-2 py-1.5 text-gray-700 placeholder-gray-400 focus:outline-hidden focus:ring-1 focus:ring-blue-500" + className="flex-1 min-w-0 text-xs font-mono border border-border rounded-sm px-2 py-1.5 text-foreground placeholder:text-muted-foreground focus:outline-hidden focus:ring-1 focus:ring-blue-500" onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); @@ -607,35 +670,35 @@ function DetailPanel({ setNewExtraHeader(""); } }} - className="text-xs font-medium text-blue-600 hover:text-blue-700 border border-blue-200 bg-blue-50 hover:bg-blue-100 px-2 py-1.5 rounded-sm transition-colors" + className="text-xs font-medium text-blue-600 hover:text-blue-700 dark:hover:text-blue-300 dark:text-blue-300 border border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-950 hover:bg-blue-100 dark:hover:bg-blue-900 dark:bg-blue-900 px-2 py-1.5 rounded-sm transition-colors" > Add )} - + setConfigExpanded(!configExpanded)} - className="w-full flex items-center justify-between px-3 py-2 text-left text-xs font-semibold text-gray-700 bg-gray-50 hover:bg-gray-100 transition-colors" + className="w-full flex items-center justify-between px-3 py-2 text-left text-xs font-semibold text-foreground bg-muted hover:bg-muted transition-colors" > Equivalent config {configExpanded ? ( - + ) : ( - + )} {configExpanded && ( - + {buildEquivalentConfigYaml(g)} )} - - - + + + This guardrail runs on a separate instance. It receives the user request and forwards the result to the next step in the pipeline. See{" "} - + Test Endpoint @@ -671,7 +734,7 @@ function DetailPanel({ Reject @@ -695,10 +758,10 @@ function ConfirmDialog({ action, guardrailName, onConfirm, onCancel }: ConfirmDi const isApprove = action === "approve"; return ( - + {isApprove ? ( @@ -707,12 +770,12 @@ function ConfirmDialog({ action, guardrailName, onConfirm, onCancel }: ConfirmDi )} - + {isApprove ? "Approve Guardrail" : "Reject Guardrail"} - + Are you sure you want to {action}{" "} - "{guardrailName}"?{" "} + "{guardrailName}"?{" "} {isApprove ? "This will make it active and available for use." : "This will mark it as rejected and notify the team."} @@ -721,7 +784,7 @@ function ConfirmDialog({ action, guardrailName, onConfirm, onCancel }: ConfirmDi Cancel @@ -766,7 +829,7 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) { const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [isSubmitModalOpen, setIsSubmitModalOpen] = useState(false); - const [submitForm] = Form.useForm(); + const submitForm = useZodForm(submitGuardrailSchema, { defaultValues: EMPTY_SUBMIT_VALUES }); const registerGuardrail = useRegisterGuardrail(); const fetchSubmissions = useCallback(async () => { @@ -797,6 +860,29 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) { fetchSubmissions(); }, [fetchSubmissions]); + const handleSubmitGuardrail = submitForm.handleSubmit(async (values) => { + const litellm_params: Record = { + ...(values.extra_litellm_params ? JSON.parse(values.extra_litellm_params) : {}), + guardrail: "generic_guardrail_api", + mode: values.mode, + api_base: values.api_base, + }; + try { + await registerGuardrail.mutateAsync({ + team_id: values.team_id, + guardrail_name: values.guardrail_name, + litellm_params, + guardrail_info: values.guardrail_info ? JSON.parse(values.guardrail_info) : undefined, + }); + toast.success("Guardrail submitted for review"); + setIsSubmitModalOpen(false); + submitForm.reset(); + fetchSubmissions(); + } catch { + return; + } + }); + const filtered = guardrails; const selected = guardrails.find((g) => g.id === selectedId) ?? null; const totalCount = summary.total; @@ -896,28 +982,28 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) { return ( - + - + - + setSearch(e.target.value)} - className="w-full pl-9 pr-4 py-2 border border-gray-200 rounded-md text-sm text-gray-700 placeholder-gray-400 focus:outline-hidden focus:ring-1 focus:ring-blue-500 focus:border-blue-500" + className="w-full pl-9 pr-4 py-2 border border-border rounded-md text-sm text-foreground placeholder:text-muted-foreground focus:outline-hidden focus:ring-1 focus:ring-blue-500 focus:border-blue-500" /> setStatusFilter(e.target.value as typeof statusFilter)} - className="border border-gray-200 rounded-md px-3 py-2 text-sm text-gray-700 focus:outline-hidden focus:ring-1 focus:ring-blue-500 focus:border-blue-500 bg-white" + className="border border-border rounded-md px-3 py-2 text-sm text-foreground focus:outline-hidden focus:ring-1 focus:ring-blue-500 focus:border-blue-500 bg-background" > All Status Pending Review @@ -934,10 +1020,10 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) { - {isLoading && Loading submissions…} + {isLoading && Loading submissions…} {error && {error}} {!isLoading && !error && filtered.length === 0 && ( - No guardrails match your filters. + No guardrails match your filters. )} {!isLoading && !error && @@ -985,119 +1071,86 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) { open={isSubmitModalOpen} onCancel={() => { setIsSubmitModalOpen(false); - submitForm.resetFields(); + submitForm.reset(); }} - onOk={() => submitForm.submit()} + onOk={handleSubmitGuardrail} okText="Submit for Review" > - + Your guardrail will be sent for admin review before it becomes active. - { - const litellm_params: Record = { - ...(values.extra_litellm_params ? JSON.parse(values.extra_litellm_params) : {}), - guardrail: "generic_guardrail_api", - mode: values.mode, - api_base: values.api_base, - }; - try { - await registerGuardrail.mutateAsync({ - team_id: values.team_id, - guardrail_name: values.guardrail_name, - litellm_params, - guardrail_info: values.guardrail_info ? JSON.parse(values.guardrail_info) : undefined, - }); - toast.success("Guardrail submitted for review"); - setIsSubmitModalOpen(false); - submitForm.resetFields(); - fetchSubmissions(); - } catch { - // error already handled by networking layer - } - }} - > - - - - - - - - - Pre Call - Post Call - During Call - - - - - - { - if (!value) return Promise.resolve(); - try { - const parsed = JSON.parse(value); - if (typeof parsed !== "object" || Array.isArray(parsed)) { - return Promise.reject("Must be a JSON object"); - } - return Promise.resolve(); - } catch { - return Promise.reject("Invalid JSON"); - } - }, - }, - ]} - > - - - { - if (!value) return Promise.resolve(); - try { - JSON.parse(value); - return Promise.resolve(); - } catch { - return Promise.reject("Invalid JSON"); - } - }, - }, - ]} - > - - - + + + + + {({ id, value, onChange }) => } + + + {({ ref, ...field }) => } + + + {({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => ( + + + + + + {GUARDRAIL_MODES.map((mode) => ( + + {mode.label} + + ))} + + + )} + + + {({ ref, ...field }) => ( + + )} + + + {({ ref, ...field }) => ( + + )} + + + {({ ref, ...field }) => ( + + )} + + + + ); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/CompetitorIntentConfiguration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/CompetitorIntentConfiguration.test.tsx new file mode 100644 index 00000000000..e1e7a62668e --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/CompetitorIntentConfiguration.test.tsx @@ -0,0 +1,178 @@ +import { useState } from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { getMajorAirlines } from "@/components/networking"; + +import CompetitorIntentConfiguration, { type CompetitorIntentConfig } from "./CompetitorIntentConfiguration"; + +vi.mock("@/components/networking", () => ({ getMajorAirlines: vi.fn() })); + +const mockAirlines = vi.mocked(getMajorAirlines); +const onChange = vi.fn(); + +const DEFAULT_CONFIG: CompetitorIntentConfig = { + competitor_intent_type: "airline", + brand_self: [], + locations: [], + policy: { + competitor_comparison: "refuse", + possible_competitor_comparison: "reframe", + }, + threshold_high: 0.7, + threshold_medium: 0.45, + threshold_low: 0.3, +}; + +const Harness = ({ initialEnabled = true }: { initialEnabled?: boolean }) => { + const [enabled, setEnabled] = useState(initialEnabled); + const [config, setConfig] = useState(initialEnabled ? DEFAULT_CONFIG : null); + const handleChange = (nextEnabled: boolean, nextConfig: CompetitorIntentConfig | null) => { + onChange(nextEnabled, nextConfig); + setEnabled(nextEnabled); + setConfig(nextConfig); + }; + return ( + + ); +}; + +const lastConfig = (): CompetitorIntentConfig => onChange.mock.calls[onChange.mock.calls.length - 1][1]; + +const chooseOption = async (user: ReturnType, index: number, optionText: string) => { + await user.click(screen.getAllByRole("combobox")[index]); + const options = await screen.findAllByText(optionText); + await user.click(options[options.length - 1]); +}; + +describe("CompetitorIntentConfiguration reported config", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockAirlines.mockResolvedValue({ airlines: [] }); + }); + + it("reports the seeded config when switched on and null when switched off", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("switch")); + expect(onChange).toHaveBeenNthCalledWith(1, true, DEFAULT_CONFIG); + + await user.click(screen.getByRole("switch")); + expect(onChange).toHaveBeenNthCalledWith(2, false, null); + }); + + it("keeps every other key when the intent type changes", async () => { + const user = userEvent.setup(); + render(); + + await chooseOption(user, 0, "Generic (specify competitors manually)"); + + expect(lastConfig()).toStrictEqual({ ...DEFAULT_CONFIG, competitor_intent_type: "generic" }); + expect(screen.getByText("Competitors")).toBeInTheDocument(); + expect(screen.queryByText("Locations (optional)")).not.toBeInTheDocument(); + }); + + it("reports a policy change without dropping the other policy key", async () => { + const user = userEvent.setup(); + render(); + + await chooseOption(user, 3, "Reframe (suggest alternative)"); + + expect(lastConfig()).toStrictEqual({ + ...DEFAULT_CONFIG, + policy: { competitor_comparison: "reframe", possible_competitor_comparison: "reframe" }, + }); + }); + + it("commits comma separated brand terms as separate tags", async () => { + const user = userEvent.setup(); + render(); + + const brandSelf = screen.getAllByRole("combobox")[1]; + await user.click(brandSelf); + await user.type(brandSelf, "acme,globex,"); + + expect(lastConfig().brand_self).toStrictEqual(["acme", "globex"]); + }); + + it("commits the pending brand term when the field loses focus", async () => { + const user = userEvent.setup(); + render(); + + const brandSelf = screen.getAllByRole("combobox")[1]; + await user.click(brandSelf); + await user.type(brandSelf, "acme"); + await user.tab(); + + expect(lastConfig().brand_self).toStrictEqual(["acme"]); + }); + + it("expands a picked airline into all of its match variants, lowercased", async () => { + mockAirlines.mockResolvedValue({ airlines: [{ id: "qr", match: "Qatar Airways|qatar|qr", tags: [] }] }); + const user = userEvent.setup(); + render(); + + await user.click(screen.getAllByRole("combobox")[1]); + const options = await screen.findAllByText(/Qatar Airways/); + await user.click(options[options.length - 1]); + + expect(lastConfig().brand_self).toStrictEqual(["qatar airways", "qatar", "qr"]); + }); + + it("reports locations only while the airline type is selected", async () => { + const user = userEvent.setup(); + render(); + + const locations = screen.getAllByRole("combobox")[2]; + await user.click(locations); + await user.type(locations, "doha,"); + + expect(lastConfig()).toStrictEqual({ ...DEFAULT_CONFIG, locations: ["doha"] }); + }); + + it("reports a typed decimal threshold and leaves the other two alone", async () => { + const user = userEvent.setup(); + render(); + + const thresholds = screen.getAllByRole("spinbutton"); + await user.clear(thresholds[0]); + await user.type(thresholds[0], "0.55"); + + expect(lastConfig()).toStrictEqual({ ...DEFAULT_CONFIG, threshold_high: 0.55 }); + }); + + it("falls back to the default threshold when the field is cleared", async () => { + const user = userEvent.setup(); + render(); + + await user.clear(screen.getAllByRole("spinbutton")[1]); + + expect(lastConfig()).toStrictEqual(DEFAULT_CONFIG); + }); + + it("clamps a threshold above the maximum back to 1 when the field is left", async () => { + const user = userEvent.setup(); + render(); + + const thresholds = screen.getAllByRole("spinbutton"); + await user.clear(thresholds[2]); + await user.type(thresholds[2], "5"); + await user.tab(); + + expect(lastConfig()).toStrictEqual({ ...DEFAULT_CONFIG, threshold_low: 1 }); + }); + + it("explains the filter without rendering any control while switched off", () => { + render(); + + expect( + screen.getByText( + "Block or reframe competitor comparison questions. When enabled, airline type auto-loads competitors from IATA; generic type requires manual competitor list.", + ), + ).toBeInTheDocument(); + expect(screen.queryAllByRole("combobox")).toHaveLength(0); + expect(screen.queryAllByRole("spinbutton")).toHaveLength(0); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/CompetitorIntentConfiguration.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/CompetitorIntentConfiguration.tsx index e3867156d01..48ef20cd35b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/CompetitorIntentConfiguration.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/CompetitorIntentConfiguration.tsx @@ -1,9 +1,13 @@ -import React, { useEffect, useState } from "react"; -import { Card, Typography, Select, Switch, Form, Space, InputNumber } from "antd"; -import { getMajorAirlines } from "@/components/networking"; +import React, { useEffect, useId, useState } from "react"; -const { Title, Text } = Typography; -const { Option } = Select; +import { getMajorAirlines } from "@/components/networking"; +import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/shared/form/field"; +import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; + +import { TagsInput } from "./TagsInput"; +import { ThresholdInput } from "./ThresholdInput"; export interface MajorAirline { id: string; @@ -45,6 +49,27 @@ const DEFAULT_CONFIG: CompetitorIntentConfig = { threshold_low: 0.3, }; +const INTENT_TYPES = [ + { value: "airline", label: "Airline (auto-load competitors from IATA)" }, + { value: "generic", label: "Generic (specify competitors manually)" }, +] as const; + +const COMPETITOR_COMPARISON_POLICIES = [ + { value: "refuse", label: "Refuse (block request)" }, + { value: "reframe", label: "Reframe (suggest alternative)" }, +] as const; + +const POSSIBLE_COMPETITOR_COMPARISON_POLICIES = [ + { value: "refuse", label: "Refuse (block request)" }, + { value: "reframe", label: "Reframe (suggest alternative to backend LLM)" }, +] as const; + +const THRESHOLDS = [ + { field: "threshold_high", label: "High", hint: "e.g. 0.7", fallback: 0.7 }, + { field: "threshold_medium", label: "Medium", hint: "e.g. 0.45", fallback: 0.45 }, + { field: "threshold_low", label: "Low", hint: "e.g. 0.3", fallback: 0.3 }, +] as const; + const CompetitorIntentConfiguration: React.FC = ({ enabled, config, @@ -54,6 +79,7 @@ const CompetitorIntentConfiguration: React.FC([]); const [loadingAirlines, setLoadingAirlines] = useState(false); + const fieldId = useId(); useEffect(() => { if (effectiveConfig.competitor_intent_type === "airline" && accessToken && airlineOptions.length === 0) { @@ -111,212 +137,206 @@ const CompetitorIntentConfiguration: React.FC + Competitor Intent Filter + + + + + ); + if (!enabled) { return ( - - - Competitor Intent Filter - - - - } - size="small" - > - - Block or reframe competitor comparison questions. When enabled, airline type auto-loads competitors from IATA; - generic type requires manual competitor list. - + + {header} + + + Block or reframe competitor comparison questions. When enabled, airline type auto-loads competitors from + IATA; generic type requires manual competitor list. + + ); } - return ( - - - Competitor Intent Filter - - - - } - size="small" - > - - Block or reframe competitor comparison questions. Airline type uses major airlines (excluding your brand); - generic requires manual competitor list. - - - - handleConfigChange("competitor_intent_type", v)} - style={{ width: "100%" }} - > - Airline (auto-load competitors from IATA) - Generic (specify competitors manually) - - + const airlineTags = + effectiveConfig.competitor_intent_type === "airline" && airlineOptions.length > 0 + ? airlineOptions.map((a) => { + const primary = a.match.split("|")[0]?.trim() ?? a.id; + const variants = a.match + .split("|") + .map((s) => s.trim().toLowerCase()) + .filter(Boolean); + return { + value: primary.toLowerCase(), + label: `${primary}${variants.length > 1 ? ` (${variants.slice(1).join(", ")})` : ""}`, + }; + }) + : []; - - + {header} + + + Block or reframe competitor comparison questions. Airline type uses major airlines (excluding your brand); + generic requires manual competitor list. + + + + Type + v !== null && handleConfigChange("competitor_intent_type", v)} + > + + + + + {INTENT_TYPES.map((type) => ( + + {type.label} + + ))} + + + + + + Your Brand (brand_self) + + effectiveConfig.competitor_intent_type === "airline" && airlineOptions.length > 0 + ? handleBrandSelfChange(v) + : handleNestedArrayChange("brand_self", v) + } + options={airlineTags} + tokenSeparators={[","]} + loading={loadingAirlines} + placeholder={ + effectiveConfig.competitor_intent_type === "airline" ? "Search or select airline, or type to add custom" : "Type and press Enter to add" - } - value={effectiveConfig.brand_self} - onChange={(v) => - effectiveConfig.competitor_intent_type === "airline" && airlineOptions.length > 0 - ? handleBrandSelfChange(v ?? []) - : handleNestedArrayChange("brand_self", v ?? []) - } - tokenSeparators={[","]} - loading={loadingAirlines} - showSearch - filterOption={(input, option) => - (option?.label?.toString().toLowerCase() ?? "").includes(input.toLowerCase()) - } - optionFilterProp="label" - options={ - effectiveConfig.competitor_intent_type === "airline" && airlineOptions.length > 0 - ? airlineOptions.map((a) => { - const primary = a.match.split("|")[0]?.trim() ?? a.id; - const variants = a.match - .split("|") - .map((s) => s.trim().toLowerCase()) - .filter(Boolean); - return { - value: primary.toLowerCase(), - label: `${primary}${variants.length > 1 ? ` (${variants.slice(1).join(", ")})` : ""}`, - }; - }) - : undefined - } - /> - - - {effectiveConfig.competitor_intent_type === "airline" && ( - - handleNestedArrayChange("locations", v ?? [])} - tokenSeparators={[","]} + } /> - - )} + + {effectiveConfig.competitor_intent_type === "airline" + ? "Select your airline from the list (excluded from competitors) or type to add a custom term" + : "Names/codes users use for your brand"} + + - {effectiveConfig.competitor_intent_type === "generic" && ( - + {effectiveConfig.competitor_intent_type === "airline" && ( + + Locations (optional) + handleNestedArrayChange("locations", v)} + tokenSeparators={[","]} + placeholder="Type and press Enter to add" + /> + Countries, cities, airports for disambiguation (e.g. qatar, doha) + + )} + + {effectiveConfig.competitor_intent_type === "generic" && ( + + Competitors + handleNestedArrayChange("competitors", v)} + tokenSeparators={[","]} + placeholder="Type and press Enter to add" + /> + Competitor names to detect (required for generic type) + + )} + + + Policy: Competitor comparison handleNestedArrayChange("competitors", v ?? [])} - tokenSeparators={[","]} - /> - - )} + value={effectiveConfig.policy?.competitor_comparison ?? "refuse"} + onValueChange={(v: string | null) => v !== null && handlePolicyChange("competitor_comparison", v)} + > + + + + + {COMPETITOR_COMPARISON_POLICIES.map((policy) => ( + + {policy.label} + + ))} + + + - - handlePolicyChange("competitor_comparison", v)} - style={{ width: "100%" }} - > - Refuse (block request) - Reframe (suggest alternative) - - + + + Policy: Possible competitor comparison + + + v !== null && handlePolicyChange("possible_competitor_comparison", v) + } + > + + + + + {POSSIBLE_COMPETITOR_COMPARISON_POLICIES.map((policy) => ( + + {policy.label} + + ))} + + + - - handlePolicyChange("possible_competitor_comparison", v)} - style={{ width: "100%" }} - > - Refuse (block request) - Reframe (suggest alternative to backend LLM) - - - - - Classify competitor intent by confidence (0–1). Higher confidence → stronger intent. - + + Confidence thresholds + + {THRESHOLDS.map((threshold) => ( + + {threshold.label} + handleConfigChange(threshold.field, v ?? threshold.fallback)} + min={0} + max={1} + step={0.05} + /> + {threshold.hint} + + ))} + + + Classify competitor intent by confidence (0–1). Higher confidence -> stronger intent. + - High (≥): Treat as full competitor comparison → uses "Competitor + High (≥): Treat as full competitor comparison -> uses "Competitor comparison" policy - Medium (≥): Treat as possible comparison → uses "Possible competitor + Medium (≥): Treat as possible comparison -> uses "Possible competitor comparison" policy - Low (≥): Log only; allow request. Below Low → allow with no action + Low (≥): Log only; allow request. Below Low -> allow with no action Raise thresholds to be more permissive; lower them to be stricter. - > - } - > - - - handleConfigChange("threshold_high", v ?? 0.7)} - style={{ width: 80 }} - /> - - - handleConfigChange("threshold_medium", v ?? 0.45)} - style={{ width: 80 }} - /> - - - handleConfigChange("threshold_low", v ?? 0.3)} - style={{ width: 80 }} - /> - - - - + + + + ); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/TagsInput.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/TagsInput.test.tsx new file mode 100644 index 00000000000..95b9b2d0f03 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/TagsInput.test.tsx @@ -0,0 +1,88 @@ +import { useState } from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { TagsInput, type TagsInputOption } from "./TagsInput"; + +const Harness = ({ + initial = [], + options, + onValueChange, +}: { + initial?: string[]; + options?: TagsInputOption[]; + onValueChange?: (value: string[]) => void; +}) => { + const [value, setValue] = useState(initial); + return ( + { + onValueChange?.(next); + setValue(next); + }} + /> + ); +}; + +describe("TagsInput", () => { + it("commits each token separated value and keeps the unterminated remainder in the field", async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render(); + + const input = screen.getByRole("combobox"); + await user.type(input, "acme,globex,initech"); + + expect(onValueChange).toHaveBeenLastCalledWith(["acme", "globex"]); + expect(input).toHaveValue("initech"); + }); + + it("commits the pending value when the field loses focus", async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render(); + + await user.type(screen.getByRole("combobox"), "acme"); + await user.tab(); + + expect(onValueChange).toHaveBeenLastCalledWith(["acme"]); + }); + + it("commits the pending value on Enter without submitting the surrounding form", async () => { + const user = userEvent.setup(); + const onSubmit = vi.fn((event: React.FormEvent) => event.preventDefault()); + const onValueChange = vi.fn(); + render( + + + Save + , + ); + + await user.type(screen.getByRole("combobox"), "acme{Enter}"); + + expect(onValueChange).toHaveBeenLastCalledWith(["acme"]); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it("ignores a value that is already a tag", async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render(); + + await user.type(screen.getByRole("combobox"), "acme,"); + + expect(onValueChange).not.toHaveBeenCalled(); + }); + + it("labels a chip with the matching option label rather than the raw value", () => { + render(); + + expect(screen.getByLabelText("Qatar Airways (qr)")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/TagsInput.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/TagsInput.tsx new file mode 100644 index 00000000000..0d799474399 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/TagsInput.tsx @@ -0,0 +1,139 @@ +"use client"; + +import React, { useState } from "react"; + +import { + Combobox, + ComboboxChip, + ComboboxChips, + ComboboxChipsInput, + ComboboxContent, + ComboboxEmpty, + ComboboxItem, + ComboboxList, + ComboboxValue, + useComboboxAnchor, +} from "@/components/ui/combobox"; + +export interface TagsInputOption { + label: string; + value: string; +} + +interface TagsInputProps { + value: string[]; + onValueChange: (value: string[]) => void; + options?: TagsInputOption[]; + placeholder?: string; + emptyText?: string; + tokenSeparators?: string[]; + loading?: boolean; + id?: string; +} + +const splitOnSeparators = (raw: string, separators: string[]): string[] => + separators.reduce((parts, separator) => parts.flatMap((part) => part.split(separator)), [raw]); + +const toOption = (options: TagsInputOption[], value: string): TagsInputOption => + options.find((option) => option.value === value) ?? { label: value, value }; + +const matchesQuery = (option: TagsInputOption, query: string): boolean => + option.label.toLowerCase().includes(query.trim().toLowerCase()); + +export const TagsInput = ({ + value, + onValueChange, + options = [], + placeholder, + emptyText = "No matching options", + tokenSeparators = [], + loading = false, + id, +}: TagsInputProps) => { + const anchor = useComboboxAnchor(); + const [query, setQuery] = useState(""); + + const selected = value.map((tag) => toOption(options, tag)); + const pending = query.trim(); + const isCreatable = pending.length > 0 && !options.some((option) => option.value === pending); + const items = isCreatable ? [{ label: pending, value: pending }, ...options] : options; + + const addTags = (tags: string[]) => { + const additions = tags + .map((tag) => tag.trim()) + .filter(Boolean) + .filter((tag, index, all) => all.indexOf(tag) === index && !value.includes(tag)); + if (additions.length > 0) onValueChange([...value, ...additions]); + }; + + const commitPending = () => { + setQuery(""); + addTags([query]); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key !== "Enter") return; + event.preventDefault(); + if (event.currentTarget.getAttribute("aria-activedescendant")) return; + commitPending(); + }; + + const handleInputValueChange = (next: string) => { + if (!tokenSeparators.some((separator) => next.includes(separator))) { + setQuery(next); + return; + } + const parts = splitOnSeparators(next, tokenSeparators); + setQuery(parts[parts.length - 1] ?? ""); + addTags(parts.slice(0, -1)); + }; + + return ( + { + setQuery(""); + onValueChange(next.map((option) => option.value)); + }} + inputValue={query} + onInputValueChange={handleInputValueChange} + isItemEqualToValue={(option: TagsInputOption, other: TagsInputOption) => option.value === other.value} + itemToStringLabel={(option: TagsInputOption) => option.label} + filter={matchesQuery} + openOnInputClick + > + } className="min-h-8 py-1 text-sm"> + + {(chips: TagsInputOption[]) => ( + <> + {chips.map((option) => ( + + {option.label} + + ))} + + > + )} + + + + {emptyText} + + {(option: TagsInputOption) => ( + + {option.label} + + )} + + + + ); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ThresholdInput.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ThresholdInput.test.tsx new file mode 100644 index 00000000000..c9cd4eda086 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ThresholdInput.test.tsx @@ -0,0 +1,89 @@ +import { useState } from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { ThresholdInput } from "./ThresholdInput"; + +const Harness = ({ + initial = 0.7, + onValueChange, +}: { + initial?: number; + onValueChange?: (v: number | null) => void; +}) => { + const [value, setValue] = useState(initial); + return ( + { + onValueChange?.(next); + setValue(next ?? initial); + }} + /> + ); +}; + +describe("ThresholdInput", () => { + it("displays the value at the precision of the step", () => { + render(); + + expect(screen.getByRole("spinbutton")).toHaveValue("0.70"); + }); + + it("reports the parsed value while typing and null once the field is empty", async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render(); + + const input = screen.getByRole("spinbutton"); + await user.clear(input); + expect(onValueChange).toHaveBeenLastCalledWith(null); + + await user.type(input, "0.55"); + expect(onValueChange).toHaveBeenLastCalledWith(0.55); + }); + + it("clamps a value above the maximum when the field loses focus", async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render(); + + const input = screen.getByRole("spinbutton"); + await user.clear(input); + await user.type(input, "5"); + await user.tab(); + + expect(onValueChange).toHaveBeenLastCalledWith(1); + expect(input).toHaveValue("1.00"); + }); + + it("steps by the step on the arrow keys and stops at the bounds", async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render(); + + const input = screen.getByRole("spinbutton"); + await user.click(input); + await user.keyboard("{ArrowUp}"); + expect(onValueChange).toHaveBeenLastCalledWith(1); + + await user.keyboard("{ArrowUp}"); + expect(onValueChange).toHaveBeenLastCalledWith(1); + + await user.keyboard("{ArrowDown}"); + expect(onValueChange).toHaveBeenLastCalledWith(0.95); + }); + + it("exposes the bounds and the current value to assistive technology", () => { + render(); + + const input = screen.getByRole("spinbutton"); + expect(input).toHaveAttribute("aria-valuemin", "0"); + expect(input).toHaveAttribute("aria-valuemax", "1"); + expect(input).toHaveAttribute("aria-valuenow", "0.45"); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ThresholdInput.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ThresholdInput.tsx new file mode 100644 index 00000000000..a0fdb7e3aa7 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ThresholdInput.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { useState } from "react"; + +import { Input } from "@/components/ui/input"; + +interface ThresholdInputProps { + value: number; + onValueChange: (value: number | null) => void; + min: number; + max: number; + step: number; + id?: string; +} + +const decimalsOf = (step: number): number => (String(step).split(".")[1] ?? "").length; + +const clamp = (value: number, min: number, max: number): number => Math.min(Math.max(value, min), max); + +const parseDecimal = (raw: string): number | null => { + const trimmed = raw.trim(); + if (trimmed === "") return null; + const parsed = Number(trimmed); + return Number.isFinite(parsed) ? parsed : null; +}; + +export const ThresholdInput = ({ value, onValueChange, min, max, step, id }: ThresholdInputProps) => { + const [draft, setDraft] = useState(null); + const decimals = decimalsOf(step); + const display = draft ?? value.toFixed(decimals); + const current = parseDecimal(display); + + const stepBy = (direction: 1 | -1) => { + const next = clamp(Number(((current ?? value) + direction * step).toFixed(decimals)), min, max); + setDraft(next.toFixed(decimals)); + onValueChange(next); + }; + + const handleBlur = () => { + setDraft(null); + if (current === null) { + onValueChange(null); + return; + } + const clamped = clamp(current, min, max); + if (clamped !== current) onValueChange(clamped); + }; + + return ( + { + setDraft(event.target.value); + onValueChange(parseDecimal(event.target.value)); + }} + onBlur={handleBlur} + onKeyDown={(event) => { + if (event.key === "ArrowUp") { + event.preventDefault(); + stepBy(1); + } + if (event.key === "ArrowDown") { + event.preventDefault(); + stepBy(-1); + } + }} + /> + ); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.integration.test.tsx new file mode 100644 index 00000000000..3b9c714d354 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.integration.test.tsx @@ -0,0 +1,191 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { vectorStoreCreateCall } from "@/components/networking"; +import { toast } from "@/lib/toast"; + +import VectorStoreForm from "./VectorStoreForm"; + +vi.mock("@/components/networking", () => ({ + vectorStoreCreateCall: vi.fn(), +})); + +vi.mock("@/components/llm_calls/fetch_models", () => ({ + fetchAvailableModels: vi.fn().mockResolvedValue([ + { model_group: "text-embedding-3-small", mode: "embedding" }, + { model_group: "gpt-4o", mode: "chat" }, + ]), +})); + +const mockCreate = vi.mocked(vectorStoreCreateCall); +const mockToast = vi.mocked(toast); + +const onSuccess = vi.fn(); + +const renderForm = () => + render( + , + ); + +const setupUser = () => userEvent.setup({ pointerEventsCheck: 0 }); + +const chooseFromSelect = async (user: ReturnType, index: number, optionText: string) => { + const trigger = screen.getAllByRole("combobox")[index]; + await user.click(trigger); + if (trigger.getAttribute("aria-expanded") !== "true") { + trigger.focus(); + await user.keyboard("{Enter}"); + } + const options = await screen.findAllByText(optionText); + await user.click(options[options.length - 1]); +}; + +const chooseProvider = (user: ReturnType, providerLabel: string) => + chooseFromSelect(user, 0, providerLabel); + +const submit = async (user: ReturnType) => + user.click(screen.getByRole("button", { name: "Create" })); + +const createdPayload = () => mockCreate.mock.calls[0][1]; + +describe("VectorStoreForm submit payload", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockCreate.mockResolvedValue(undefined); + }); + + it("sends every payload key for the default provider, leaving untouched optional fields undefined", async () => { + const user = setupUser(); + renderForm(); + + await user.type(screen.getByPlaceholderText("Enter vector store ID from your provider"), "vs-bedrock"); + await submit(user); + + await vi.waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1)); + expect(mockCreate.mock.calls[0][0]).toBe("test-token"); + expect(createdPayload()).toStrictEqual({ + vector_store_id: "vs-bedrock", + custom_llm_provider: "bedrock", + vector_store_name: undefined, + vector_store_description: undefined, + vector_store_metadata: {}, + litellm_credential_name: undefined, + litellm_params: {}, + }); + expect(onSuccess).toHaveBeenCalledTimes(1); + }); + + it("sends the filled optional fields, parsed metadata and the selected credential", async () => { + const user = setupUser(); + renderForm(); + + await user.type(screen.getByPlaceholderText("Enter vector store ID from your provider"), "vs-full"); + const textboxes = screen.getAllByRole("textbox"); + await user.type(textboxes[1], "Support docs"); + await user.type(textboxes[2], "Docs for the support team"); + await user.clear(screen.getByPlaceholderText('{"key": "value"}')); + await user.type(screen.getByPlaceholderText('{"key": "value"}'), '{{"tier": "gold"}'); + await chooseFromSelect(user, 1, "bedrock-prod"); + await submit(user); + + await vi.waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1)); + expect(createdPayload()).toStrictEqual({ + vector_store_id: "vs-full", + custom_llm_provider: "bedrock", + vector_store_name: "Support docs", + vector_store_description: "Docs for the support team", + vector_store_metadata: { tier: "gold" }, + litellm_credential_name: "bedrock-prod", + litellm_params: {}, + }); + }); + + it("renames the milvus embedding model to litellm_embedding_model inside litellm_params", async () => { + const user = setupUser(); + renderForm(); + + await chooseProvider(user, "Milvus"); + await user.type(screen.getByPlaceholderText("Enter vector store ID from your provider"), "vs-milvus"); + await user.type(screen.getByPlaceholderText("username:password or api key"), "user:pass"); + await user.type(screen.getByPlaceholderText("https://your-milvus-endpoint.com/"), "https://milvus.example.com"); + await chooseFromSelect(user, 1, "text-embedding-3-small"); + await submit(user); + + await vi.waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1)); + expect(createdPayload().litellm_params).toStrictEqual({ + api_key: "user:pass", + api_base: "https://milvus.example.com", + litellm_embedding_model: "text-embedding-3-small", + }); + expect(createdPayload().custom_llm_provider).toBe("milvus"); + }); + + it("sends a provider field's seeded default even when the user never touches it", async () => { + const user = setupUser(); + renderForm(); + + await chooseProvider(user, "Vertex AI Search"); + await user.type( + screen.getByPlaceholderText('my-datastore_1234567890 (data store ID from Vertex AI / "Agent Search" console)'), + "vs-vertex", + ); + await user.type(screen.getByPlaceholderText("my-gcp-project-id"), "gcp-proj"); + await submit(user); + + await vi.waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1)); + expect(createdPayload().litellm_params).toStrictEqual({ + vertex_project: "gcp-proj", + vertex_location: "global", + vertex_collection_id: undefined, + vertex_engine_id: undefined, + }); + }); + + it("keeps values typed under one provider when a later provider reuses the same field name", async () => { + const user = setupUser(); + renderForm(); + + await chooseProvider(user, "PostgreSQL pgvector (LiteLLM Connector)"); + await user.type(screen.getByPlaceholderText("http://your-deployed-server:8000"), "http://pg:8000"); + await user.type(screen.getByPlaceholderText("your-deployed-api-key"), "pg-key"); + await chooseProvider(user, "Azure OpenAI"); + await user.type(screen.getByPlaceholderText("Enter vector store ID from your provider"), "vs-azure"); + await submit(user); + + await vi.waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1)); + expect(createdPayload().litellm_params).toStrictEqual({ + api_key: "pg-key", + api_base: "http://pg:8000", + }); + }); + + it("blocks the request and reports invalid metadata JSON instead of submitting", async () => { + const user = setupUser(); + renderForm(); + + await user.type(screen.getByPlaceholderText("Enter vector store ID from your provider"), "vs-bad-json"); + await user.clear(screen.getByPlaceholderText('{"key": "value"}')); + await user.type(screen.getByPlaceholderText('{"key": "value"}'), "not json"); + await submit(user); + + await vi.waitFor(() => expect(mockToast.fromError).toHaveBeenCalledWith("Invalid JSON in metadata field")); + expect(mockCreate).not.toHaveBeenCalled(); + }); + + it("keeps the required-field messages that block an empty submit", async () => { + const user = setupUser(); + renderForm(); + + await submit(user); + + expect(await screen.findByText("Please input the vector store ID from your api provider")).toBeInTheDocument(); + expect(mockCreate).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.tsx index 321ff62b1df..97c633f4f13 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.tsx @@ -1,18 +1,37 @@ import React, { useState, useEffect } from "react"; -import { TextInput, Button as TremorButton } from "@tremor/react"; -import { Modal, Form, Select, Tooltip, Input, Alert } from "antd"; -import { InfoCircleOutlined } from "@ant-design/icons"; +import { Modal, Alert } from "antd"; +import { CircleHelp, Eye, EyeOff } from "lucide-react"; +import { useWatch } from "react-hook-form"; +import { z } from "zod/v4"; import { CredentialItem, vectorStoreCreateCall } from "@/components/networking"; import { VectorStoreProviders, vectorStoreProviderLogoMap, vectorStoreProviderMap, getProviderSpecificFields, + getVectorStoreProviderLogoAndName, VectorStoreFieldConfig, } from "@/components/vector_store_providers"; import { Logo } from "@/components/molecules/logo/Logo"; import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models"; import { toast } from "@/lib/toast"; +import { FieldGroup } from "@/components/shared/form/field"; +import { FormField } from "@/components/shared/form/FormField"; +import { Button } from "@/components/ui/button"; +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from "@/components/ui/combobox"; +import { Input } from "@/components/ui/input"; +import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group"; +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 { useZodForm } from "@/lib/forms/useZodForm"; interface VectorStoreFormProps { isVisible: boolean; @@ -22,6 +41,103 @@ interface VectorStoreFormProps { credentials: CredentialItem[]; } +const PROVIDER_FIELD_NAMES = [ + "api_base", + "api_key", + "vertex_project", + "vertex_location", + "vertex_collection_id", + "vertex_engine_id", + "embedding_model", + "vector_bucket_name", + "index_name", + "aws_region_name", +] as const; + +type ProviderFieldName = (typeof PROVIDER_FIELD_NAMES)[number]; + +const isProviderFieldName = (name: string): name is ProviderFieldName => + (PROVIDER_FIELD_NAMES as readonly string[]).includes(name); + +const optionalText = z.string().optional(); + +const vectorStoreShape = { + custom_llm_provider: z.string().min(1, "Please select a provider"), + vector_store_id: z.string().min(1, "Please input the vector store ID from your api provider"), + vector_store_name: optionalText, + vector_store_description: optionalText, + litellm_credential_name: z.string().nullable().optional(), + api_base: optionalText, + api_key: optionalText, + vertex_project: optionalText, + vertex_location: optionalText, + vertex_collection_id: optionalText, + vertex_engine_id: optionalText, + embedding_model: optionalText, + vector_bucket_name: optionalText, + index_name: optionalText, + aws_region_name: optionalText, +}; + +const vectorStoreSchema = z.object(vectorStoreShape).superRefine((values, ctx) => { + getProviderSpecificFields(values.custom_llm_provider) + .filter((field) => field.required && isProviderFieldName(field.name) && !values[field.name]) + .forEach((field) => + ctx.addIssue({ + code: "custom", + path: [field.name], + message: + field.type === "select" + ? `Please select the ${field.label.toLowerCase()}` + : `Please input the ${field.label.toLowerCase()}`, + }), + ); +}); + +type VectorStoreFormValues = z.output; + +const EMPTY_VALUES: VectorStoreFormValues = { + custom_llm_provider: "bedrock", + vector_store_id: "", + vertex_location: "global", +}; + +interface CredentialOption { + label: string; + value: string | null; +} + +const labelWithHint = (label: string, hint: string): React.ReactNode => ( + <> + {label} + + } /> + {hint} + + > +); + +const PasswordInput = React.forwardRef>( + (props, ref) => { + const [revealed, setRevealed] = useState(false); + return ( + + + + setRevealed(!revealed)} + > + {revealed ? : } + + + + ); + }, +); +PasswordInput.displayName = "PasswordInput"; + const VectorStoreForm: React.FC = ({ isVisible, onCancel, @@ -29,11 +145,11 @@ const VectorStoreForm: React.FC = ({ accessToken, credentials, }) => { - const [form] = Form.useForm(); + const form = useZodForm(vectorStoreSchema, { defaultValues: EMPTY_VALUES }); const [metadataJson, setMetadataJson] = useState("{}"); const [selectedProvider, setSelectedProvider] = useState("bedrock"); const [modelInfo, setModelInfo] = useState([]); - const vertexEngineId = Form.useWatch("vertex_engine_id", form); + const vertexEngineId = useWatch({ control: form.control, name: "vertex_engine_id" }); useEffect(() => { if (!accessToken) return; @@ -52,10 +168,23 @@ const VectorStoreForm: React.FC = ({ loadModels(); }, [accessToken]); - const handleCreate = async (formValues: any) => { + const credentialOptions: CredentialOption[] = [ + { value: null, label: "None" }, + ...credentials.map((credential) => ({ + value: credential.credential_name, + label: credential.credential_name, + })), + ]; + + const makeProviderChangeHandler = (onChange: (provider: string) => void) => (provider: string | null) => { + if (provider === null) return; + onChange(provider); + setSelectedProvider(provider); + }; + + const handleCreate = async (formValues: VectorStoreFormValues) => { if (!accessToken) return; try { - // Parse metadata JSON let metadata = {}; try { metadata = metadataJson.trim() ? JSON.parse(metadataJson) : {}; @@ -64,36 +193,28 @@ const VectorStoreForm: React.FC = ({ return; } - // Prepare the payload with provider-specific fields - const payload: any = { + const providerFields = getProviderSpecificFields(formValues.custom_llm_provider); + const litellmParams = Object.fromEntries( + providerFields.filter(isSupportedProviderField).map((field) => { + const value = formValues[field.name]; + if (formValues.custom_llm_provider === "milvus" && field.name === "embedding_model") { + return ["litellm_embedding_model", value]; + } + return [field.name, value]; + }), + ); + + await vectorStoreCreateCall(accessToken, { vector_store_id: formValues.vector_store_id, custom_llm_provider: formValues.custom_llm_provider, vector_store_name: formValues.vector_store_name, vector_store_description: formValues.vector_store_description, vector_store_metadata: metadata, litellm_credential_name: formValues.litellm_credential_name, - }; - - // pass all provider fields as litellm params dict - const providerFields = getProviderSpecificFields(formValues.custom_llm_provider); - const litellmParams = providerFields.reduce( - (acc, field) => { - // Special handling for Milvus: rename embedding_model to litellm_embedding_model - if (formValues.custom_llm_provider === "milvus" && field.name === "embedding_model") { - acc["litellm_embedding_model"] = formValues[field.name]; - } else { - acc[field.name] = formValues[field.name]; - } - return acc; - }, - {} as Record, - ); - - payload["litellm_params"] = litellmParams; - - await vectorStoreCreateCall(accessToken, payload); + litellm_params: litellmParams, + }); toast.success("Vector store created successfully"); - form.resetFields(); + form.reset(EMPTY_VALUES); setMetadataJson("{}"); onSuccess(); } catch (error) { @@ -103,312 +224,333 @@ const VectorStoreForm: React.FC = ({ }; const handleCancel = () => { - form.resetFields(); + form.reset(EMPTY_VALUES); setMetadataJson("{}"); setSelectedProvider("bedrock"); onCancel(); }; + const vectorStoreIdPlaceholder = + selectedProvider === "vertex_rag_engine" + ? '6917529027641081856 (corpus ID from Vertex AI / "RAG Engine" console)' + : selectedProvider === "vertex_ai/search_api" + ? vertexEngineId + ? "Any identifier you'll use to reference this in LiteLLM" + : 'my-datastore_1234567890 (data store ID from Vertex AI / "Agent Search" console)' + : "Enter vector store ID from your provider"; + return ( - - - Provider{" "} - - - - - } - name="custom_llm_provider" - rules={[{ required: true, message: "Please select a provider" }]} - initialValue="bedrock" - > - setSelectedProvider(value)}> - {Object.entries(VectorStoreProviders).map(([providerEnum, providerDisplayName]) => { - return ( - - - - {providerDisplayName} - - - ); - })} - - - - {/* PG Vector Setup Instructions */} - {selectedProvider === "pg_vector" && ( - - LiteLLM provides a server to connect to PG Vector. To use this provider: - - - Deploy the litellm-pgvector server from:{" "} - - https://github.com/BerriAI/litellm-pgvector - - - Configure your PostgreSQL database with pgvector extension - Start the server and note the API base URL and API key - Enter those details in the fields below - - - } - type="info" - showIcon - style={{ marginBottom: "16px" }} - /> - )} - - {/* Vertex RAG Engine Setup Instructions */} - {selectedProvider === "vertex_rag_engine" && ( - - To use Vertex AI RAG Engine: - - Note: Google Cloud has renamed this to "RAG Engine" in its console — the steps below still - apply. - - - - Set up your Vertex AI RAG Engine corpus following the guide:{" "} - - Vertex AI RAG Engine Overview - - - Create a corpus in your Google Cloud project - - Note the corpus ID from the Vertex AI console (now labeled "RAG Engine" in Google Cloud) - - Enter the corpus ID in the Vector Store ID field below - - - } - type="info" - showIcon - style={{ marginBottom: "16px" }} - /> - )} - - {/* Vertex AI Search Setup Instructions */} - {selectedProvider === "vertex_ai/search_api" && ( - - To use Vertex AI Search (Discovery Engine): - - Note: Google Cloud has renamed this to "Agent Search" in its console — the steps below still - apply. - - - - Enable the Discovery Engine API on your Google Cloud project and create a data store following the - guide:{" "} - - Create a Vertex AI Search data store - - - Pick a supported location: global, us, or eu - - For most data store types (Cloud Storage, BigQuery, Media): copy the data store ID and enter it in - the Vector Store ID field below. - - - For website, healthcare, and connector-based sources (Drive, Gmail, Slack, Jira, etc.): create a - search app on top of the data store, then copy the Engine ID and enter it in the - Engine ID field. The Vector Store ID is still required as the LiteLLM-side name for this record, but - it isn't used in the GCP URL when Engine ID is set. - - - - } - type="info" - showIcon - style={{ marginBottom: "16px" }} - /> - )} - - - Vector Store ID{" "} - - - - - } - name="vector_store_id" - rules={[{ required: true, message: "Please input the vector store ID from your api provider" }]} - > - - - - {/* Provider-specific fields */} - {getProviderSpecificFields(selectedProvider).map((field: VectorStoreFieldConfig) => { - if (field.type === "select") { - const selectOptions = - field.options ?? - modelInfo - .filter((option: ModelGroup) => option.mode === "embedding" || option.mode === null) - .map((option: ModelGroup) => ({ - value: option.model_group, - label: option.model_group, - })); - - return ( - - {field.label}{" "} - - - - - } - name={field.name} - initialValue={field.initialValue} - rules={ - field.required ? [{ required: true, message: `Please select the ${field.label.toLowerCase()}` }] : [] - } - > - (option?.label ?? "").toLowerCase().includes(input.toLowerCase())} - options={selectOptions} - style={{ width: "100%" }} - /> - - ); - } - - return ( - - {field.label}{" "} - - - - - } - name={field.name} - rules={ - field.required ? [{ required: true, message: `Please input the ${field.label.toLowerCase()}` }] : [] - } + + + + - - - ); - })} + {({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => ( + + + + {(provider: string) => { + const { displayName, logo } = getVectorStoreProviderLogoAndName(provider); + return ( + <> + + {displayName} + > + ); + }} + + + + {Object.entries(VectorStoreProviders).map(([providerEnum, providerDisplayName]) => ( + + + {providerDisplayName} + + ))} + + + )} + - - Vector Store Name{" "} - - - - - } - name="vector_store_name" - > - - + {selectedProvider === "pg_vector" && ( + + LiteLLM provides a server to connect to PG Vector. To use this provider: + + + Deploy the litellm-pgvector server from:{" "} + + https://github.com/BerriAI/litellm-pgvector + + + Configure your PostgreSQL database with pgvector extension + Start the server and note the API base URL and API key + Enter those details in the fields below + + + } + type="info" + showIcon + /> + )} - - - + {selectedProvider === "vertex_rag_engine" && ( + + To use Vertex AI RAG Engine: + + Note: Google Cloud has renamed this to "RAG Engine" in its console — the steps below + still apply. + + + + Set up your Vertex AI RAG Engine corpus following the guide:{" "} + + Vertex AI RAG Engine Overview + + + Create a corpus in your Google Cloud project + + Note the corpus ID from the Vertex AI console (now labeled "RAG Engine" in Google + Cloud) + + Enter the corpus ID in the Vector Store ID field below + + + } + type="info" + showIcon + /> + )} - - Existing Credentials{" "} - - - - - } - name="litellm_credential_name" - > - (option?.label ?? "").toLowerCase().includes(input.toLowerCase())} - options={[ - { value: null, label: "None" }, - ...credentials.map((credential) => ({ - value: credential.credential_name, - label: credential.credential_name, - })), - ]} - allowClear - /> - + {selectedProvider === "vertex_ai/search_api" && ( + + To use Vertex AI Search (Discovery Engine): + + Note: Google Cloud has renamed this to "Agent Search" in its console — the steps below + still apply. + + + + Enable the Discovery Engine API on your Google Cloud project and create a data store following + the guide:{" "} + + Create a Vertex AI Search data store + + + Pick a supported location: global, us, or eu + + For most data store types (Cloud Storage, BigQuery, Media): copy the data store ID and enter it + in the Vector Store ID field below. + + + For website, healthcare, and connector-based sources (Drive, Gmail, Slack, Jira, etc.): create a + search app on top of the data store, then copy the Engine ID and enter it in + the Engine ID field. The Vector Store ID is still required as the LiteLLM-side name for this + record, but it isn't used in the GCP URL when Engine ID is set. + + + + } + type="info" + showIcon + /> + )} - - Metadata{" "} - - - - - } - > - setMetadataJson(e.target.value)} - placeholder='{"key": "value"}' - /> - + + {({ ref, ...field }) => } + - - - Cancel - - - Create - - - + {getProviderSpecificFields(selectedProvider) + .filter(isSupportedProviderField) + .map((field) => ( + + ))} + + + {({ ref, value, ...field }) => } + + + + {({ ref, value, ...field }) => } + + + + {({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => ( + option.value === value) ?? null} + onValueChange={(option: CredentialOption | null) => onChange(option ? option.value : undefined)} + itemToStringLabel={(option: CredentialOption) => option.label} + isItemEqualToValue={(option: CredentialOption, selected: CredentialOption) => + option.value === selected.value + } + > + + + No matching credentials + + {(option: CredentialOption) => ( + + {option.label} + + )} + + + + )} + + + + + {labelWithHint("Metadata", "JSON metadata for the vector store (optional)")} + + setMetadataJson(event.target.value)} + placeholder='{"key": "value"}' + /> + + + + + + Cancel + + Create + + + ); }; +type SupportedProviderField = VectorStoreFieldConfig & { name: ProviderFieldName }; + +const isSupportedProviderField = (field: VectorStoreFieldConfig): field is SupportedProviderField => + isProviderFieldName(field.name); + +interface ProviderFieldProps { + field: SupportedProviderField; + control: ReturnType>["control"]; + modelInfo: ModelGroup[]; +} + +const ProviderField: React.FC = ({ field, control, modelInfo }) => { + const label = labelWithHint(field.label, field.tooltip); + + if (field.type === "select") { + const selectOptions = + field.options ?? + modelInfo + .filter((option: ModelGroup) => option.mode === "embedding" || option.mode === null) + .map((option: ModelGroup) => ({ + value: option.model_group, + label: option.model_group, + })); + + return ( + + {({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => ( + option.value === value) ?? null} + onValueChange={(option: { value: string; label: string } | null) => onChange(option?.value)} + itemToStringLabel={(option: { value: string; label: string }) => option.label} + isItemEqualToValue={( + option: { value: string; label: string }, + selected: { value: string; label: string }, + ) => option.value === selected.value} + > + + + No matching options + + {(option: { value: string; label: string }) => ( + + {option.label} + + )} + + + + )} + + ); + } + + return ( + + {({ ref, value, ...controlProps }) => + field.type === "password" ? ( + + ) : ( + + ) + } + + ); +}; + export default VectorStoreForm; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/vector_store_info.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/vector_store_info.integration.test.tsx new file mode 100644 index 00000000000..33264c71fd6 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/vector_store_info.integration.test.tsx @@ -0,0 +1,160 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { credentialListCall, vectorStoreInfoCall, vectorStoreUpdateCall } from "@/components/networking"; +import { toast } from "@/lib/toast"; + +import VectorStoreInfoView from "./vector_store_info"; + +vi.mock("@/components/networking", () => ({ + vectorStoreInfoCall: vi.fn(), + vectorStoreUpdateCall: vi.fn(), + credentialListCall: vi.fn(), +})); + +vi.mock("./VectorStoreTester", () => ({ __esModule: true, default: () => null })); + +const mockInfo = vi.mocked(vectorStoreInfoCall); +const mockUpdate = vi.mocked(vectorStoreUpdateCall); +const mockCredentials = vi.mocked(credentialListCall); +const mockToast = vi.mocked(toast); + +const serverRecord = { + vector_store_id: "vs-1", + vector_store_name: "support-docs-store", + vector_store_description: "Docs for support", + custom_llm_provider: "bedrock", + vector_store_metadata: { tier: "gold" }, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-02-02T00:00:00Z", + litellm_credential_name: "bedrock-prod", +}; + +const renderView = (editVectorStore: boolean) => + render( + , + ); + +const savedPayload = () => mockUpdate.mock.calls[0][1]; + +describe("VectorStoreInfoView save payload", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockInfo.mockResolvedValue({ vector_store: serverRecord }); + mockCredentials.mockResolvedValue({ credentials: [{ credential_name: "bedrock-prod" }] }); + mockUpdate.mockResolvedValue({}); + }); + + it("still saves when the server left the nullable name and description null", async () => { + const user = userEvent.setup(); + mockInfo.mockResolvedValue({ + vector_store: { ...serverRecord, vector_store_name: null, vector_store_description: null }, + }); + renderView(true); + await screen.findByRole("button", { name: "Save Changes" }); + + await user.click(screen.getByRole("button", { name: "Save Changes" })); + + await vi.waitFor(() => expect(mockUpdate).toHaveBeenCalledTimes(1)); + expect(savedPayload()).toStrictEqual({ + vector_store_id: "vs-1", + custom_llm_provider: "bedrock", + vector_store_name: null, + vector_store_description: null, + vector_store_metadata: { tier: "gold" }, + }); + }); + + it("sends only the five editable keys and drops every server-only field", async () => { + const user = userEvent.setup(); + renderView(true); + + const nameInput = await screen.findByDisplayValue("support-docs-store"); + await user.clear(nameInput); + await user.type(nameInput, "renamed-store"); + await user.click(screen.getByRole("button", { name: "Save Changes" })); + + await vi.waitFor(() => expect(mockUpdate).toHaveBeenCalledTimes(1)); + expect(mockUpdate.mock.calls[0][0]).toBe("sk-test"); + expect(savedPayload()).toStrictEqual({ + vector_store_id: "vs-1", + custom_llm_provider: "bedrock", + vector_store_name: "renamed-store", + vector_store_description: "Docs for support", + vector_store_metadata: { tier: "gold" }, + }); + }); + + it("sends the same five keys when editing is entered from the details view", async () => { + const user = userEvent.setup(); + renderView(false); + + const editButtons = await screen.findAllByRole("button", { name: "Edit Vector Store" }); + await user.click(editButtons[0]); + const descriptionInput = await screen.findByDisplayValue("Docs for support"); + await user.clear(descriptionInput); + await user.type(descriptionInput, "new description"); + await user.click(screen.getByRole("button", { name: "Save Changes" })); + + await vi.waitFor(() => expect(mockUpdate).toHaveBeenCalledTimes(1)); + expect(savedPayload()).toStrictEqual({ + vector_store_id: "vs-1", + custom_llm_provider: "bedrock", + vector_store_name: "support-docs-store", + vector_store_description: "new description", + vector_store_metadata: { tier: "gold" }, + }); + }); + + it("keeps the credential field out of the payload even after it is picked", async () => { + const user = userEvent.setup(); + renderView(true); + + await screen.findByDisplayValue("support-docs-store"); + await user.click(screen.getAllByRole("combobox")[1]); + const options = await screen.findAllByText("bedrock-prod"); + await user.click(options[options.length - 1]); + await user.click(screen.getByRole("button", { name: "Save Changes" })); + + await vi.waitFor(() => expect(mockUpdate).toHaveBeenCalledTimes(1)); + expect(Object.keys(savedPayload())).toStrictEqual([ + "vector_store_id", + "custom_llm_provider", + "vector_store_name", + "vector_store_description", + "vector_store_metadata", + ]); + }); + + it("blocks the request and reports invalid metadata JSON instead of saving", async () => { + const user = userEvent.setup(); + renderView(true); + + const metadataInput = await screen.findByPlaceholderText('{"key": "value"}'); + await user.clear(metadataInput); + await user.type(metadataInput, "not json"); + await user.click(screen.getByRole("button", { name: "Save Changes" })); + + await vi.waitFor(() => expect(mockToast.fromError).toHaveBeenCalledWith("Invalid JSON in metadata field")); + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + it("keeps the required-field message that blocks saving without a vector store id", async () => { + const user = userEvent.setup(); + mockInfo.mockResolvedValue({ vector_store: { ...serverRecord, vector_store_id: "" } }); + renderView(true); + + await screen.findByDisplayValue("support-docs-store"); + await user.click(screen.getByRole("button", { name: "Save Changes" })); + + expect(await screen.findByText("Please input a vector store ID")).toBeInTheDocument(); + expect(mockUpdate).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/vector_store_info.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/vector_store_info.tsx index 517496a6e19..b76b08cf20a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/vector_store_info.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/vector_store_info.tsx @@ -1,8 +1,8 @@ import React, { useState, useEffect } from "react"; import { Card, Text, Title, Button, Badge, TabGroup, TabList, Tab, TabPanels, TabPanel } from "@tremor/react"; -import { Form, Input, Select as Select2, Tooltip, Button as AntButton } from "antd"; -import { InfoCircleOutlined } from "@ant-design/icons"; +import { CircleHelp } from "lucide-react"; import { ArrowLeftIcon } from "@heroicons/react/outline"; +import { z } from "zod/v4"; import { vectorStoreInfoCall, vectorStoreUpdateCall, @@ -15,6 +15,22 @@ import { getVectorStoreProviderLogoAndName } from "@/components/vector_store_pro import { Logo } from "@/components/molecules/logo/Logo"; import VectorStoreTester from "./VectorStoreTester"; import { toast } from "@/lib/toast"; +import { FieldGroup } from "@/components/shared/form/field"; +import { FormField } from "@/components/shared/form/FormField"; +import { Button as ShadcnButton } from "@/components/ui/button"; +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from "@/components/ui/combobox"; +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 { useZodForm } from "@/lib/forms/useZodForm"; interface VectorStoreInfoViewProps { vectorStoreId: string; @@ -24,6 +40,46 @@ interface VectorStoreInfoViewProps { editVectorStore: boolean; } +const vectorStoreEditShape = { + vector_store_id: z.string().min(1, "Please input a vector store ID"), + vector_store_name: z.string().nullish(), + vector_store_description: z.string().nullish(), + custom_llm_provider: z.string().min(1, "Please select a provider"), + litellm_credential_name: z.string().nullable().optional(), +}; + +const vectorStoreEditSchema = z.object(vectorStoreEditShape); + +type VectorStoreEditValues = z.output; + +const EMPTY_VALUES: VectorStoreEditValues = { + vector_store_id: "", + custom_llm_provider: "", +}; + +const toFormValues = (vectorStore: VectorStore): VectorStoreEditValues => ({ + vector_store_id: vectorStore.vector_store_id, + vector_store_name: vectorStore.vector_store_name, + vector_store_description: vectorStore.vector_store_description, + custom_llm_provider: vectorStore.custom_llm_provider ?? "", + litellm_credential_name: vectorStore.litellm_credential_name, +}); + +interface CredentialOption { + label: string; + value: string | null; +} + +const labelWithHint = (label: string, hint: string): React.ReactNode => ( + <> + {label} + + } /> + {hint} + + > +); + const VectorStoreInfoView: React.FC = ({ vectorStoreId, onClose, @@ -31,7 +87,7 @@ const VectorStoreInfoView: React.FC = ({ is_admin, editVectorStore, }) => { - const [form] = Form.useForm(); + const form = useZodForm(vectorStoreEditSchema, { defaultValues: EMPTY_VALUES }); const [vectorStoreDetails, setVectorStoreDetails] = useState(null); const [loadFailed, setLoadFailed] = useState(false); const [isEditing, setIsEditing] = useState(editVectorStore); @@ -49,7 +105,6 @@ const VectorStoreInfoView: React.FC = ({ } setVectorStoreDetails(response.vector_store); - // If metadata exists and is an object, stringify it for display/editing if (response.vector_store.vector_store_metadata) { const metadata = typeof response.vector_store.vector_store_metadata === "string" @@ -58,14 +113,7 @@ const VectorStoreInfoView: React.FC = ({ setMetadataString(JSON.stringify(metadata, null, 2)); } - if (editVectorStore) { - form.setFieldsValue({ - vector_store_id: response.vector_store.vector_store_id, - custom_llm_provider: response.vector_store.custom_llm_provider, - vector_store_name: response.vector_store.vector_store_name, - vector_store_description: response.vector_store.vector_store_description, - }); - } + form.reset(toFormValues(response.vector_store)); } catch (error) { console.error("Error fetching vector store details:", error); toast.fromError("Error fetching vector store details: " + error); @@ -88,10 +136,16 @@ const VectorStoreInfoView: React.FC = ({ fetchCredentials(); }, [vectorStoreId, accessToken]); - const handleSave = async (values: any) => { + const startEditing = () => { + if (vectorStoreDetails) { + form.reset(toFormValues(vectorStoreDetails)); + } + setIsEditing(true); + }; + + const handleSave = async (values: VectorStoreEditValues) => { if (!accessToken) return; try { - // Parse the metadata JSON string let metadata = {}; try { metadata = metadataString ? JSON.parse(metadataString) : {}; @@ -118,6 +172,14 @@ const VectorStoreInfoView: React.FC = ({ } }; + const credentialOptions: CredentialOption[] = [ + { value: null, label: "None" }, + ...credentials.map((credential) => ({ + value: credential.credential_name, + label: credential.credential_name, + })), + ]; + if (loadFailed) { return ( @@ -125,7 +187,7 @@ const VectorStoreInfoView: React.FC = ({ Back to Vector Stores Vector store not found - + Vector store {vectorStoreId} could not be loaded. It may have been deleted. @@ -144,9 +206,11 @@ const VectorStoreInfoView: React.FC = ({ Back to Vector Stores Vector Store ID: {vectorStoreDetails.vector_store_id} - {vectorStoreDetails.vector_store_description || "No description"} + + {vectorStoreDetails.vector_store_description || "No description"} + - {is_admin && !isEditing && setIsEditing(true)}>Edit Vector Store} + {is_admin && !isEditing && Edit Vector Store} @@ -156,7 +220,6 @@ const VectorStoreInfoView: React.FC = ({ - {/* Details Tab */} {isEditing ? ( @@ -164,117 +227,145 @@ const VectorStoreInfoView: React.FC = ({ Edit Vector Store - - - - + + + + + {({ ref, ...field }) => } + - - - + + {({ ref, value, ...field }) => } + - - - + + {({ ref, value, ...field }) => } + - - Provider{" "} - - - - - } - name="custom_llm_provider" - rules={[{ required: true, message: "Please select a provider" }]} - > - - {Object.entries(Providers).map(([providerEnum, providerDisplayName]) => { - // Currently only showing Bedrock since it's the only supported provider - if (providerEnum === "Bedrock") { - return ( - - - - {providerDisplayName} - - - ); - } - return null; - })} - - + + {({ + id, + value, + onChange, + "aria-invalid": ariaInvalid, + "aria-describedby": ariaDescribedBy, + }) => ( + + + + {(provider: string) => { + const { displayName, logo } = getVectorStoreProviderLogoAndName(provider); + return ( + <> + + {displayName} + > + ); + }} + + + + {Object.entries(Providers) + .filter(([providerEnum]) => providerEnum === "Bedrock") + .map(([providerEnum, providerDisplayName]) => ( + + + {providerDisplayName} + + ))} + + + )} + - {/* Credentials */} - - - Either select existing credentials OR enter provider credentials below - - + + Either select existing credentials OR enter provider credentials below + - - - (option?.label ?? "").toLowerCase().includes(input.toLowerCase()) - } - options={[ - { value: null, label: "None" }, - ...credentials.map((credential) => ({ - value: credential.credential_name, - label: credential.credential_name, - })), - ]} - allowClear - /> - + + {({ + id, + value, + onChange, + "aria-invalid": ariaInvalid, + "aria-describedby": ariaDescribedBy, + }) => ( + option.value === value) ?? null} + onValueChange={(option: CredentialOption | null) => + onChange(option ? option.value : undefined) + } + itemToStringLabel={(option: CredentialOption) => option.label} + isItemEqualToValue={(option: CredentialOption, selected: CredentialOption) => + option.value === selected.value + } + > + + + No matching credentials + + {(option: CredentialOption) => ( + + {option.label} + + )} + + + + )} + - - - OR - - + + + OR + + - - Metadata{" "} - - - - - } - > - setMetadataString(e.target.value)} - placeholder='{"key": "value"}' - /> - + + + {labelWithHint("Metadata", "JSON metadata for the vector store")} + + setMetadataString(event.target.value)} + placeholder='{"key": "value"}' + /> + + - - setIsEditing(false)}>Cancel - - Save Changes - - - + + setIsEditing(false)}> + Cancel + + Save Changes + + + ) : ( Vector Store Details - {is_admin && setIsEditing(true)}>Edit Vector Store} + {is_admin && Edit Vector Store} @@ -308,7 +399,7 @@ const VectorStoreInfoView: React.FC = ({ Metadata - + {metadataString} @@ -330,7 +421,6 @@ const VectorStoreInfoView: React.FC = ({ )} - {/* Test Tab */} diff --git a/ui/litellm-dashboard/src/components/vector_store_management/types.tsx b/ui/litellm-dashboard/src/components/vector_store_management/types.tsx index 5be7825e9a1..2fe1c732ecc 100644 --- a/ui/litellm-dashboard/src/components/vector_store_management/types.tsx +++ b/ui/litellm-dashboard/src/components/vector_store_management/types.tsx @@ -18,6 +18,7 @@ export interface VectorStore { vector_store_name?: string; vector_store_description?: string; vector_store_metadata?: VectorStoreMetadata; + litellm_credential_name?: string; created_at: string; updated_at: string; created_by?: string; diff --git a/ui/litellm-dashboard/src/lib/forms/antdUrl.test.ts b/ui/litellm-dashboard/src/lib/forms/antdUrl.test.ts new file mode 100644 index 00000000000..36891eae198 --- /dev/null +++ b/ui/litellm-dashboard/src/lib/forms/antdUrl.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; + +import { ANTD_URL_REGEX, isAntdUrl, MAX_ANTD_URL_LENGTH } from "./antdUrl"; + +const ASYNC_VALIDATOR_5_1_0_URL_SOURCE = + '(?:^(?:(?:(?:[a-z]+:)?\\/\\/)|www\\.)(?:\\S+(?::\\S*)?@)?(?:localhost|(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}|(?:(?:[a-fA-F\\d]{1,4}:){7}(?:[a-fA-F\\d]{1,4}|:)|(?:[a-fA-F\\d]{1,4}:){6}(?:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}|:[a-fA-F\\d]{1,4}|:)|(?:[a-fA-F\\d]{1,4}:){5}(?::(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}|(?::[a-fA-F\\d]{1,4}){1,2}|:)|(?:[a-fA-F\\d]{1,4}:){4}(?:(?::[a-fA-F\\d]{1,4}){0,1}:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}|(?::[a-fA-F\\d]{1,4}){1,3}|:)|(?:[a-fA-F\\d]{1,4}:){3}(?:(?::[a-fA-F\\d]{1,4}){0,2}:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}|(?::[a-fA-F\\d]{1,4}){1,4}|:)|(?:[a-fA-F\\d]{1,4}:){2}(?:(?::[a-fA-F\\d]{1,4}){0,3}:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}|(?::[a-fA-F\\d]{1,4}){1,5}|:)|(?:[a-fA-F\\d]{1,4}:){1}(?:(?::[a-fA-F\\d]{1,4}){0,4}:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}|(?::[a-fA-F\\d]{1,4}){1,6}|:)|(?::(?:(?::[a-fA-F\\d]{1,4}){0,5}:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}|(?::[a-fA-F\\d]{1,4}){1,7}|:)))(?:%[0-9a-zA-Z]{1,})?|(?:(?:[a-z\\u00a1-\\uffff0-9][-_]*)*[a-z\\u00a1-\\uffff0-9]+)(?:\\.(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)*(?:\\.(?:[a-z\\u00a1-\\uffff]{2,})))(?::\\d{2,5})?(?:[/?#][^\\s"]*)?$)'; + +describe("isAntdUrl", () => { + it("compiles to the exact pattern async-validator 5.1.0 uses for rule type url", () => { + expect(ANTD_URL_REGEX.source).toBe(ASYNC_VALIDATOR_5_1_0_URL_SOURCE); + expect(ANTD_URL_REGEX.flags).toBe("i"); + }); + + it.each([ + "https://guard.example.com/v1/check", + "http://localhost:4000", + "www.example.com", + "//example.com", + "https://127.0.0.1:8080/path?q=1", + "https://user:pass@example.com", + ])("accepts %s the way antd does", (value) => { + expect(isAntdUrl(value)).toBe(true); + }); + + it.each(["example.com", "", "not a url", "https://", "ftp:/example.com", "http://exa mple.com"])( + "rejects %s the way antd does", + (value) => { + expect(isAntdUrl(value)).toBe(false); + }, + ); + + it("rejects a url longer than the 2048 characters antd allows", () => { + const long = `https://example.com/${"a".repeat(MAX_ANTD_URL_LENGTH)}`; + expect(ANTD_URL_REGEX.test(long)).toBe(true); + expect(isAntdUrl(long)).toBe(false); + }); +}); diff --git a/ui/litellm-dashboard/src/lib/forms/antdUrl.ts b/ui/litellm-dashboard/src/lib/forms/antdUrl.ts new file mode 100644 index 00000000000..809b2764ebf --- /dev/null +++ b/ui/litellm-dashboard/src/lib/forms/antdUrl.ts @@ -0,0 +1,29 @@ +const V4 = "(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}"; +const V6SEG = "[a-fA-F\\d]{1,4}"; +const V6 = `(?:${[ + `(?:${V6SEG}:){7}(?:${V6SEG}|:)`, + `(?:${V6SEG}:){6}(?:${V4}|:${V6SEG}|:)`, + `(?:${V6SEG}:){5}(?::${V4}|(?::${V6SEG}){1,2}|:)`, + `(?:${V6SEG}:){4}(?:(?::${V6SEG}){0,1}:${V4}|(?::${V6SEG}){1,3}|:)`, + `(?:${V6SEG}:){3}(?:(?::${V6SEG}){0,2}:${V4}|(?::${V6SEG}){1,4}|:)`, + `(?:${V6SEG}:){2}(?:(?::${V6SEG}){0,3}:${V4}|(?::${V6SEG}){1,5}|:)`, + `(?:${V6SEG}:){1}(?:(?::${V6SEG}){0,4}:${V4}|(?::${V6SEG}){1,6}|:)`, + `(?::(?:(?::${V6SEG}){0,5}:${V4}|(?::${V6SEG}){1,7}|:))`, +].join("|")})(?:%[0-9a-zA-Z]{1,})?`; + +const PROTOCOL = "(?:(?:[a-z]+:)?//)"; +const AUTH = "(?:\\S+(?::\\S*)?@)?"; +const HOST = "(?:(?:[a-z\\u00a1-\\uffff0-9][-_]*)*[a-z\\u00a1-\\uffff0-9]+)"; +const DOMAIN = "(?:\\.(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)*"; +const TLD = "(?:\\.(?:[a-z\\u00a1-\\uffff]{2,}))"; +const PORT = "(?::\\d{2,5})?"; +const PATH = '(?:[/?#][^\\s"]*)?'; + +export const ANTD_URL_REGEX = new RegExp( + `(?:^(?:${PROTOCOL}|www\\.)${AUTH}(?:localhost|${V4}|${V6}|${HOST}${DOMAIN}${TLD})${PORT}${PATH}$)`, + "i", +); + +export const MAX_ANTD_URL_LENGTH = 2048; + +export const isAntdUrl = (value: string): boolean => value.length <= MAX_ANTD_URL_LENGTH && ANTD_URL_REGEX.test(value);
{g.description}
{g.endpoint}
No static headers configured.
+
Submitted by {g.submittedBy} on {g.submittedAt}
When enabled, the caller's LiteLLM API key is forwarded as an{" "} - Authorization header to your guardrail - endpoint. This allows your guardrail to authenticate model calls using the original caller's - credentials. + Authorization header to + your guardrail endpoint. This allows your guardrail to authenticate model calls using the original + caller's credentials.
Authorization
Sent with every request to the guardrail.
Allowed header names to forward from the client request to the guardrail (e.g. x-request-id).
No forward client headers configured.
+ {buildEquivalentConfigYaml(g)} )}
{buildEquivalentConfigYaml(g)}
This guardrail runs on a separate instance. It receives the user request and forwards the result to the next step in the pipeline. See{" "}
Are you sure you want to {action}{" "} - "{guardrailName}"?{" "} + "{guardrailName}"?{" "} {isApprove ? "This will make it active and available for use." : "This will mark it as rejected and notify the team."} @@ -721,7 +784,7 @@ function ConfirmDialog({ action, guardrailName, onConfirm, onCancel }: ConfirmDi Cancel @@ -766,7 +829,7 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) { const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [isSubmitModalOpen, setIsSubmitModalOpen] = useState(false); - const [submitForm] = Form.useForm(); + const submitForm = useZodForm(submitGuardrailSchema, { defaultValues: EMPTY_SUBMIT_VALUES }); const registerGuardrail = useRegisterGuardrail(); const fetchSubmissions = useCallback(async () => { @@ -797,6 +860,29 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) { fetchSubmissions(); }, [fetchSubmissions]); + const handleSubmitGuardrail = submitForm.handleSubmit(async (values) => { + const litellm_params: Record = { + ...(values.extra_litellm_params ? JSON.parse(values.extra_litellm_params) : {}), + guardrail: "generic_guardrail_api", + mode: values.mode, + api_base: values.api_base, + }; + try { + await registerGuardrail.mutateAsync({ + team_id: values.team_id, + guardrail_name: values.guardrail_name, + litellm_params, + guardrail_info: values.guardrail_info ? JSON.parse(values.guardrail_info) : undefined, + }); + toast.success("Guardrail submitted for review"); + setIsSubmitModalOpen(false); + submitForm.reset(); + fetchSubmissions(); + } catch { + return; + } + }); + const filtered = guardrails; const selected = guardrails.find((g) => g.id === selectedId) ?? null; const totalCount = summary.total; @@ -896,28 +982,28 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) { return ( - + - + - + setSearch(e.target.value)} - className="w-full pl-9 pr-4 py-2 border border-gray-200 rounded-md text-sm text-gray-700 placeholder-gray-400 focus:outline-hidden focus:ring-1 focus:ring-blue-500 focus:border-blue-500" + className="w-full pl-9 pr-4 py-2 border border-border rounded-md text-sm text-foreground placeholder:text-muted-foreground focus:outline-hidden focus:ring-1 focus:ring-blue-500 focus:border-blue-500" /> setStatusFilter(e.target.value as typeof statusFilter)} - className="border border-gray-200 rounded-md px-3 py-2 text-sm text-gray-700 focus:outline-hidden focus:ring-1 focus:ring-blue-500 focus:border-blue-500 bg-white" + className="border border-border rounded-md px-3 py-2 text-sm text-foreground focus:outline-hidden focus:ring-1 focus:ring-blue-500 focus:border-blue-500 bg-background" > All Status Pending Review @@ -934,10 +1020,10 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) { - {isLoading && Loading submissions…} + {isLoading && Loading submissions…} {error && {error}} {!isLoading && !error && filtered.length === 0 && ( - No guardrails match your filters. + No guardrails match your filters. )} {!isLoading && !error && @@ -985,119 +1071,86 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) { open={isSubmitModalOpen} onCancel={() => { setIsSubmitModalOpen(false); - submitForm.resetFields(); + submitForm.reset(); }} - onOk={() => submitForm.submit()} + onOk={handleSubmitGuardrail} okText="Submit for Review" > - + Your guardrail will be sent for admin review before it becomes active. - { - const litellm_params: Record = { - ...(values.extra_litellm_params ? JSON.parse(values.extra_litellm_params) : {}), - guardrail: "generic_guardrail_api", - mode: values.mode, - api_base: values.api_base, - }; - try { - await registerGuardrail.mutateAsync({ - team_id: values.team_id, - guardrail_name: values.guardrail_name, - litellm_params, - guardrail_info: values.guardrail_info ? JSON.parse(values.guardrail_info) : undefined, - }); - toast.success("Guardrail submitted for review"); - setIsSubmitModalOpen(false); - submitForm.resetFields(); - fetchSubmissions(); - } catch { - // error already handled by networking layer - } - }} - > - - - - - - - - - Pre Call - Post Call - During Call - - - - - - { - if (!value) return Promise.resolve(); - try { - const parsed = JSON.parse(value); - if (typeof parsed !== "object" || Array.isArray(parsed)) { - return Promise.reject("Must be a JSON object"); - } - return Promise.resolve(); - } catch { - return Promise.reject("Invalid JSON"); - } - }, - }, - ]} - > - - - { - if (!value) return Promise.resolve(); - try { - JSON.parse(value); - return Promise.resolve(); - } catch { - return Promise.reject("Invalid JSON"); - } - }, - }, - ]} - > - - - + + + + + {({ id, value, onChange }) => } + + + {({ ref, ...field }) => } + + + {({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => ( + + + + + + {GUARDRAIL_MODES.map((mode) => ( + + {mode.label} + + ))} + + + )} + + + {({ ref, ...field }) => ( + + )} + + + {({ ref, ...field }) => ( + + )} + + + {({ ref, ...field }) => ( + + )} + + + + ); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/CompetitorIntentConfiguration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/CompetitorIntentConfiguration.test.tsx new file mode 100644 index 00000000000..e1e7a62668e --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/CompetitorIntentConfiguration.test.tsx @@ -0,0 +1,178 @@ +import { useState } from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { getMajorAirlines } from "@/components/networking"; + +import CompetitorIntentConfiguration, { type CompetitorIntentConfig } from "./CompetitorIntentConfiguration"; + +vi.mock("@/components/networking", () => ({ getMajorAirlines: vi.fn() })); + +const mockAirlines = vi.mocked(getMajorAirlines); +const onChange = vi.fn(); + +const DEFAULT_CONFIG: CompetitorIntentConfig = { + competitor_intent_type: "airline", + brand_self: [], + locations: [], + policy: { + competitor_comparison: "refuse", + possible_competitor_comparison: "reframe", + }, + threshold_high: 0.7, + threshold_medium: 0.45, + threshold_low: 0.3, +}; + +const Harness = ({ initialEnabled = true }: { initialEnabled?: boolean }) => { + const [enabled, setEnabled] = useState(initialEnabled); + const [config, setConfig] = useState(initialEnabled ? DEFAULT_CONFIG : null); + const handleChange = (nextEnabled: boolean, nextConfig: CompetitorIntentConfig | null) => { + onChange(nextEnabled, nextConfig); + setEnabled(nextEnabled); + setConfig(nextConfig); + }; + return ( + + ); +}; + +const lastConfig = (): CompetitorIntentConfig => onChange.mock.calls[onChange.mock.calls.length - 1][1]; + +const chooseOption = async (user: ReturnType, index: number, optionText: string) => { + await user.click(screen.getAllByRole("combobox")[index]); + const options = await screen.findAllByText(optionText); + await user.click(options[options.length - 1]); +}; + +describe("CompetitorIntentConfiguration reported config", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockAirlines.mockResolvedValue({ airlines: [] }); + }); + + it("reports the seeded config when switched on and null when switched off", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("switch")); + expect(onChange).toHaveBeenNthCalledWith(1, true, DEFAULT_CONFIG); + + await user.click(screen.getByRole("switch")); + expect(onChange).toHaveBeenNthCalledWith(2, false, null); + }); + + it("keeps every other key when the intent type changes", async () => { + const user = userEvent.setup(); + render(); + + await chooseOption(user, 0, "Generic (specify competitors manually)"); + + expect(lastConfig()).toStrictEqual({ ...DEFAULT_CONFIG, competitor_intent_type: "generic" }); + expect(screen.getByText("Competitors")).toBeInTheDocument(); + expect(screen.queryByText("Locations (optional)")).not.toBeInTheDocument(); + }); + + it("reports a policy change without dropping the other policy key", async () => { + const user = userEvent.setup(); + render(); + + await chooseOption(user, 3, "Reframe (suggest alternative)"); + + expect(lastConfig()).toStrictEqual({ + ...DEFAULT_CONFIG, + policy: { competitor_comparison: "reframe", possible_competitor_comparison: "reframe" }, + }); + }); + + it("commits comma separated brand terms as separate tags", async () => { + const user = userEvent.setup(); + render(); + + const brandSelf = screen.getAllByRole("combobox")[1]; + await user.click(brandSelf); + await user.type(brandSelf, "acme,globex,"); + + expect(lastConfig().brand_self).toStrictEqual(["acme", "globex"]); + }); + + it("commits the pending brand term when the field loses focus", async () => { + const user = userEvent.setup(); + render(); + + const brandSelf = screen.getAllByRole("combobox")[1]; + await user.click(brandSelf); + await user.type(brandSelf, "acme"); + await user.tab(); + + expect(lastConfig().brand_self).toStrictEqual(["acme"]); + }); + + it("expands a picked airline into all of its match variants, lowercased", async () => { + mockAirlines.mockResolvedValue({ airlines: [{ id: "qr", match: "Qatar Airways|qatar|qr", tags: [] }] }); + const user = userEvent.setup(); + render(); + + await user.click(screen.getAllByRole("combobox")[1]); + const options = await screen.findAllByText(/Qatar Airways/); + await user.click(options[options.length - 1]); + + expect(lastConfig().brand_self).toStrictEqual(["qatar airways", "qatar", "qr"]); + }); + + it("reports locations only while the airline type is selected", async () => { + const user = userEvent.setup(); + render(); + + const locations = screen.getAllByRole("combobox")[2]; + await user.click(locations); + await user.type(locations, "doha,"); + + expect(lastConfig()).toStrictEqual({ ...DEFAULT_CONFIG, locations: ["doha"] }); + }); + + it("reports a typed decimal threshold and leaves the other two alone", async () => { + const user = userEvent.setup(); + render(); + + const thresholds = screen.getAllByRole("spinbutton"); + await user.clear(thresholds[0]); + await user.type(thresholds[0], "0.55"); + + expect(lastConfig()).toStrictEqual({ ...DEFAULT_CONFIG, threshold_high: 0.55 }); + }); + + it("falls back to the default threshold when the field is cleared", async () => { + const user = userEvent.setup(); + render(); + + await user.clear(screen.getAllByRole("spinbutton")[1]); + + expect(lastConfig()).toStrictEqual(DEFAULT_CONFIG); + }); + + it("clamps a threshold above the maximum back to 1 when the field is left", async () => { + const user = userEvent.setup(); + render(); + + const thresholds = screen.getAllByRole("spinbutton"); + await user.clear(thresholds[2]); + await user.type(thresholds[2], "5"); + await user.tab(); + + expect(lastConfig()).toStrictEqual({ ...DEFAULT_CONFIG, threshold_low: 1 }); + }); + + it("explains the filter without rendering any control while switched off", () => { + render(); + + expect( + screen.getByText( + "Block or reframe competitor comparison questions. When enabled, airline type auto-loads competitors from IATA; generic type requires manual competitor list.", + ), + ).toBeInTheDocument(); + expect(screen.queryAllByRole("combobox")).toHaveLength(0); + expect(screen.queryAllByRole("spinbutton")).toHaveLength(0); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/CompetitorIntentConfiguration.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/CompetitorIntentConfiguration.tsx index e3867156d01..48ef20cd35b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/CompetitorIntentConfiguration.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/CompetitorIntentConfiguration.tsx @@ -1,9 +1,13 @@ -import React, { useEffect, useState } from "react"; -import { Card, Typography, Select, Switch, Form, Space, InputNumber } from "antd"; -import { getMajorAirlines } from "@/components/networking"; +import React, { useEffect, useId, useState } from "react"; -const { Title, Text } = Typography; -const { Option } = Select; +import { getMajorAirlines } from "@/components/networking"; +import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/shared/form/field"; +import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; + +import { TagsInput } from "./TagsInput"; +import { ThresholdInput } from "./ThresholdInput"; export interface MajorAirline { id: string; @@ -45,6 +49,27 @@ const DEFAULT_CONFIG: CompetitorIntentConfig = { threshold_low: 0.3, }; +const INTENT_TYPES = [ + { value: "airline", label: "Airline (auto-load competitors from IATA)" }, + { value: "generic", label: "Generic (specify competitors manually)" }, +] as const; + +const COMPETITOR_COMPARISON_POLICIES = [ + { value: "refuse", label: "Refuse (block request)" }, + { value: "reframe", label: "Reframe (suggest alternative)" }, +] as const; + +const POSSIBLE_COMPETITOR_COMPARISON_POLICIES = [ + { value: "refuse", label: "Refuse (block request)" }, + { value: "reframe", label: "Reframe (suggest alternative to backend LLM)" }, +] as const; + +const THRESHOLDS = [ + { field: "threshold_high", label: "High", hint: "e.g. 0.7", fallback: 0.7 }, + { field: "threshold_medium", label: "Medium", hint: "e.g. 0.45", fallback: 0.45 }, + { field: "threshold_low", label: "Low", hint: "e.g. 0.3", fallback: 0.3 }, +] as const; + const CompetitorIntentConfiguration: React.FC = ({ enabled, config, @@ -54,6 +79,7 @@ const CompetitorIntentConfiguration: React.FC([]); const [loadingAirlines, setLoadingAirlines] = useState(false); + const fieldId = useId(); useEffect(() => { if (effectiveConfig.competitor_intent_type === "airline" && accessToken && airlineOptions.length === 0) { @@ -111,212 +137,206 @@ const CompetitorIntentConfiguration: React.FC + Competitor Intent Filter + + + + + ); + if (!enabled) { return ( - - - Competitor Intent Filter - - - - } - size="small" - > - - Block or reframe competitor comparison questions. When enabled, airline type auto-loads competitors from IATA; - generic type requires manual competitor list. - + + {header} + + + Block or reframe competitor comparison questions. When enabled, airline type auto-loads competitors from + IATA; generic type requires manual competitor list. + + ); } - return ( - - - Competitor Intent Filter - - - - } - size="small" - > - - Block or reframe competitor comparison questions. Airline type uses major airlines (excluding your brand); - generic requires manual competitor list. - - - - handleConfigChange("competitor_intent_type", v)} - style={{ width: "100%" }} - > - Airline (auto-load competitors from IATA) - Generic (specify competitors manually) - - + const airlineTags = + effectiveConfig.competitor_intent_type === "airline" && airlineOptions.length > 0 + ? airlineOptions.map((a) => { + const primary = a.match.split("|")[0]?.trim() ?? a.id; + const variants = a.match + .split("|") + .map((s) => s.trim().toLowerCase()) + .filter(Boolean); + return { + value: primary.toLowerCase(), + label: `${primary}${variants.length > 1 ? ` (${variants.slice(1).join(", ")})` : ""}`, + }; + }) + : []; - - + {header} + + + Block or reframe competitor comparison questions. Airline type uses major airlines (excluding your brand); + generic requires manual competitor list. + + + + Type + v !== null && handleConfigChange("competitor_intent_type", v)} + > + + + + + {INTENT_TYPES.map((type) => ( + + {type.label} + + ))} + + + + + + Your Brand (brand_self) + + effectiveConfig.competitor_intent_type === "airline" && airlineOptions.length > 0 + ? handleBrandSelfChange(v) + : handleNestedArrayChange("brand_self", v) + } + options={airlineTags} + tokenSeparators={[","]} + loading={loadingAirlines} + placeholder={ + effectiveConfig.competitor_intent_type === "airline" ? "Search or select airline, or type to add custom" : "Type and press Enter to add" - } - value={effectiveConfig.brand_self} - onChange={(v) => - effectiveConfig.competitor_intent_type === "airline" && airlineOptions.length > 0 - ? handleBrandSelfChange(v ?? []) - : handleNestedArrayChange("brand_self", v ?? []) - } - tokenSeparators={[","]} - loading={loadingAirlines} - showSearch - filterOption={(input, option) => - (option?.label?.toString().toLowerCase() ?? "").includes(input.toLowerCase()) - } - optionFilterProp="label" - options={ - effectiveConfig.competitor_intent_type === "airline" && airlineOptions.length > 0 - ? airlineOptions.map((a) => { - const primary = a.match.split("|")[0]?.trim() ?? a.id; - const variants = a.match - .split("|") - .map((s) => s.trim().toLowerCase()) - .filter(Boolean); - return { - value: primary.toLowerCase(), - label: `${primary}${variants.length > 1 ? ` (${variants.slice(1).join(", ")})` : ""}`, - }; - }) - : undefined - } - /> - - - {effectiveConfig.competitor_intent_type === "airline" && ( - - handleNestedArrayChange("locations", v ?? [])} - tokenSeparators={[","]} + } /> - - )} + + {effectiveConfig.competitor_intent_type === "airline" + ? "Select your airline from the list (excluded from competitors) or type to add a custom term" + : "Names/codes users use for your brand"} + + - {effectiveConfig.competitor_intent_type === "generic" && ( - + {effectiveConfig.competitor_intent_type === "airline" && ( + + Locations (optional) + handleNestedArrayChange("locations", v)} + tokenSeparators={[","]} + placeholder="Type and press Enter to add" + /> + Countries, cities, airports for disambiguation (e.g. qatar, doha) + + )} + + {effectiveConfig.competitor_intent_type === "generic" && ( + + Competitors + handleNestedArrayChange("competitors", v)} + tokenSeparators={[","]} + placeholder="Type and press Enter to add" + /> + Competitor names to detect (required for generic type) + + )} + + + Policy: Competitor comparison handleNestedArrayChange("competitors", v ?? [])} - tokenSeparators={[","]} - /> - - )} + value={effectiveConfig.policy?.competitor_comparison ?? "refuse"} + onValueChange={(v: string | null) => v !== null && handlePolicyChange("competitor_comparison", v)} + > + + + + + {COMPETITOR_COMPARISON_POLICIES.map((policy) => ( + + {policy.label} + + ))} + + + - - handlePolicyChange("competitor_comparison", v)} - style={{ width: "100%" }} - > - Refuse (block request) - Reframe (suggest alternative) - - + + + Policy: Possible competitor comparison + + + v !== null && handlePolicyChange("possible_competitor_comparison", v) + } + > + + + + + {POSSIBLE_COMPETITOR_COMPARISON_POLICIES.map((policy) => ( + + {policy.label} + + ))} + + + - - handlePolicyChange("possible_competitor_comparison", v)} - style={{ width: "100%" }} - > - Refuse (block request) - Reframe (suggest alternative to backend LLM) - - - - - Classify competitor intent by confidence (0–1). Higher confidence → stronger intent. - + + Confidence thresholds + + {THRESHOLDS.map((threshold) => ( + + {threshold.label} + handleConfigChange(threshold.field, v ?? threshold.fallback)} + min={0} + max={1} + step={0.05} + /> + {threshold.hint} + + ))} + + + Classify competitor intent by confidence (0–1). Higher confidence -> stronger intent. + - High (≥): Treat as full competitor comparison → uses "Competitor + High (≥): Treat as full competitor comparison -> uses "Competitor comparison" policy - Medium (≥): Treat as possible comparison → uses "Possible competitor + Medium (≥): Treat as possible comparison -> uses "Possible competitor comparison" policy - Low (≥): Log only; allow request. Below Low → allow with no action + Low (≥): Log only; allow request. Below Low -> allow with no action Raise thresholds to be more permissive; lower them to be stricter. - > - } - > - - - handleConfigChange("threshold_high", v ?? 0.7)} - style={{ width: 80 }} - /> - - - handleConfigChange("threshold_medium", v ?? 0.45)} - style={{ width: 80 }} - /> - - - handleConfigChange("threshold_low", v ?? 0.3)} - style={{ width: 80 }} - /> - - - - + + + + ); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/TagsInput.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/TagsInput.test.tsx new file mode 100644 index 00000000000..95b9b2d0f03 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/TagsInput.test.tsx @@ -0,0 +1,88 @@ +import { useState } from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { TagsInput, type TagsInputOption } from "./TagsInput"; + +const Harness = ({ + initial = [], + options, + onValueChange, +}: { + initial?: string[]; + options?: TagsInputOption[]; + onValueChange?: (value: string[]) => void; +}) => { + const [value, setValue] = useState(initial); + return ( + { + onValueChange?.(next); + setValue(next); + }} + /> + ); +}; + +describe("TagsInput", () => { + it("commits each token separated value and keeps the unterminated remainder in the field", async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render(); + + const input = screen.getByRole("combobox"); + await user.type(input, "acme,globex,initech"); + + expect(onValueChange).toHaveBeenLastCalledWith(["acme", "globex"]); + expect(input).toHaveValue("initech"); + }); + + it("commits the pending value when the field loses focus", async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render(); + + await user.type(screen.getByRole("combobox"), "acme"); + await user.tab(); + + expect(onValueChange).toHaveBeenLastCalledWith(["acme"]); + }); + + it("commits the pending value on Enter without submitting the surrounding form", async () => { + const user = userEvent.setup(); + const onSubmit = vi.fn((event: React.FormEvent) => event.preventDefault()); + const onValueChange = vi.fn(); + render( + + + Save + , + ); + + await user.type(screen.getByRole("combobox"), "acme{Enter}"); + + expect(onValueChange).toHaveBeenLastCalledWith(["acme"]); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it("ignores a value that is already a tag", async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render(); + + await user.type(screen.getByRole("combobox"), "acme,"); + + expect(onValueChange).not.toHaveBeenCalled(); + }); + + it("labels a chip with the matching option label rather than the raw value", () => { + render(); + + expect(screen.getByLabelText("Qatar Airways (qr)")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/TagsInput.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/TagsInput.tsx new file mode 100644 index 00000000000..0d799474399 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/TagsInput.tsx @@ -0,0 +1,139 @@ +"use client"; + +import React, { useState } from "react"; + +import { + Combobox, + ComboboxChip, + ComboboxChips, + ComboboxChipsInput, + ComboboxContent, + ComboboxEmpty, + ComboboxItem, + ComboboxList, + ComboboxValue, + useComboboxAnchor, +} from "@/components/ui/combobox"; + +export interface TagsInputOption { + label: string; + value: string; +} + +interface TagsInputProps { + value: string[]; + onValueChange: (value: string[]) => void; + options?: TagsInputOption[]; + placeholder?: string; + emptyText?: string; + tokenSeparators?: string[]; + loading?: boolean; + id?: string; +} + +const splitOnSeparators = (raw: string, separators: string[]): string[] => + separators.reduce((parts, separator) => parts.flatMap((part) => part.split(separator)), [raw]); + +const toOption = (options: TagsInputOption[], value: string): TagsInputOption => + options.find((option) => option.value === value) ?? { label: value, value }; + +const matchesQuery = (option: TagsInputOption, query: string): boolean => + option.label.toLowerCase().includes(query.trim().toLowerCase()); + +export const TagsInput = ({ + value, + onValueChange, + options = [], + placeholder, + emptyText = "No matching options", + tokenSeparators = [], + loading = false, + id, +}: TagsInputProps) => { + const anchor = useComboboxAnchor(); + const [query, setQuery] = useState(""); + + const selected = value.map((tag) => toOption(options, tag)); + const pending = query.trim(); + const isCreatable = pending.length > 0 && !options.some((option) => option.value === pending); + const items = isCreatable ? [{ label: pending, value: pending }, ...options] : options; + + const addTags = (tags: string[]) => { + const additions = tags + .map((tag) => tag.trim()) + .filter(Boolean) + .filter((tag, index, all) => all.indexOf(tag) === index && !value.includes(tag)); + if (additions.length > 0) onValueChange([...value, ...additions]); + }; + + const commitPending = () => { + setQuery(""); + addTags([query]); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key !== "Enter") return; + event.preventDefault(); + if (event.currentTarget.getAttribute("aria-activedescendant")) return; + commitPending(); + }; + + const handleInputValueChange = (next: string) => { + if (!tokenSeparators.some((separator) => next.includes(separator))) { + setQuery(next); + return; + } + const parts = splitOnSeparators(next, tokenSeparators); + setQuery(parts[parts.length - 1] ?? ""); + addTags(parts.slice(0, -1)); + }; + + return ( + { + setQuery(""); + onValueChange(next.map((option) => option.value)); + }} + inputValue={query} + onInputValueChange={handleInputValueChange} + isItemEqualToValue={(option: TagsInputOption, other: TagsInputOption) => option.value === other.value} + itemToStringLabel={(option: TagsInputOption) => option.label} + filter={matchesQuery} + openOnInputClick + > + } className="min-h-8 py-1 text-sm"> + + {(chips: TagsInputOption[]) => ( + <> + {chips.map((option) => ( + + {option.label} + + ))} + + > + )} + + + + {emptyText} + + {(option: TagsInputOption) => ( + + {option.label} + + )} + + + + ); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ThresholdInput.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ThresholdInput.test.tsx new file mode 100644 index 00000000000..c9cd4eda086 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ThresholdInput.test.tsx @@ -0,0 +1,89 @@ +import { useState } from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { ThresholdInput } from "./ThresholdInput"; + +const Harness = ({ + initial = 0.7, + onValueChange, +}: { + initial?: number; + onValueChange?: (v: number | null) => void; +}) => { + const [value, setValue] = useState(initial); + return ( + { + onValueChange?.(next); + setValue(next ?? initial); + }} + /> + ); +}; + +describe("ThresholdInput", () => { + it("displays the value at the precision of the step", () => { + render(); + + expect(screen.getByRole("spinbutton")).toHaveValue("0.70"); + }); + + it("reports the parsed value while typing and null once the field is empty", async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render(); + + const input = screen.getByRole("spinbutton"); + await user.clear(input); + expect(onValueChange).toHaveBeenLastCalledWith(null); + + await user.type(input, "0.55"); + expect(onValueChange).toHaveBeenLastCalledWith(0.55); + }); + + it("clamps a value above the maximum when the field loses focus", async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render(); + + const input = screen.getByRole("spinbutton"); + await user.clear(input); + await user.type(input, "5"); + await user.tab(); + + expect(onValueChange).toHaveBeenLastCalledWith(1); + expect(input).toHaveValue("1.00"); + }); + + it("steps by the step on the arrow keys and stops at the bounds", async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render(); + + const input = screen.getByRole("spinbutton"); + await user.click(input); + await user.keyboard("{ArrowUp}"); + expect(onValueChange).toHaveBeenLastCalledWith(1); + + await user.keyboard("{ArrowUp}"); + expect(onValueChange).toHaveBeenLastCalledWith(1); + + await user.keyboard("{ArrowDown}"); + expect(onValueChange).toHaveBeenLastCalledWith(0.95); + }); + + it("exposes the bounds and the current value to assistive technology", () => { + render(); + + const input = screen.getByRole("spinbutton"); + expect(input).toHaveAttribute("aria-valuemin", "0"); + expect(input).toHaveAttribute("aria-valuemax", "1"); + expect(input).toHaveAttribute("aria-valuenow", "0.45"); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ThresholdInput.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ThresholdInput.tsx new file mode 100644 index 00000000000..a0fdb7e3aa7 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ThresholdInput.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { useState } from "react"; + +import { Input } from "@/components/ui/input"; + +interface ThresholdInputProps { + value: number; + onValueChange: (value: number | null) => void; + min: number; + max: number; + step: number; + id?: string; +} + +const decimalsOf = (step: number): number => (String(step).split(".")[1] ?? "").length; + +const clamp = (value: number, min: number, max: number): number => Math.min(Math.max(value, min), max); + +const parseDecimal = (raw: string): number | null => { + const trimmed = raw.trim(); + if (trimmed === "") return null; + const parsed = Number(trimmed); + return Number.isFinite(parsed) ? parsed : null; +}; + +export const ThresholdInput = ({ value, onValueChange, min, max, step, id }: ThresholdInputProps) => { + const [draft, setDraft] = useState(null); + const decimals = decimalsOf(step); + const display = draft ?? value.toFixed(decimals); + const current = parseDecimal(display); + + const stepBy = (direction: 1 | -1) => { + const next = clamp(Number(((current ?? value) + direction * step).toFixed(decimals)), min, max); + setDraft(next.toFixed(decimals)); + onValueChange(next); + }; + + const handleBlur = () => { + setDraft(null); + if (current === null) { + onValueChange(null); + return; + } + const clamped = clamp(current, min, max); + if (clamped !== current) onValueChange(clamped); + }; + + return ( + { + setDraft(event.target.value); + onValueChange(parseDecimal(event.target.value)); + }} + onBlur={handleBlur} + onKeyDown={(event) => { + if (event.key === "ArrowUp") { + event.preventDefault(); + stepBy(1); + } + if (event.key === "ArrowDown") { + event.preventDefault(); + stepBy(-1); + } + }} + /> + ); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.integration.test.tsx new file mode 100644 index 00000000000..3b9c714d354 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.integration.test.tsx @@ -0,0 +1,191 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { vectorStoreCreateCall } from "@/components/networking"; +import { toast } from "@/lib/toast"; + +import VectorStoreForm from "./VectorStoreForm"; + +vi.mock("@/components/networking", () => ({ + vectorStoreCreateCall: vi.fn(), +})); + +vi.mock("@/components/llm_calls/fetch_models", () => ({ + fetchAvailableModels: vi.fn().mockResolvedValue([ + { model_group: "text-embedding-3-small", mode: "embedding" }, + { model_group: "gpt-4o", mode: "chat" }, + ]), +})); + +const mockCreate = vi.mocked(vectorStoreCreateCall); +const mockToast = vi.mocked(toast); + +const onSuccess = vi.fn(); + +const renderForm = () => + render( + , + ); + +const setupUser = () => userEvent.setup({ pointerEventsCheck: 0 }); + +const chooseFromSelect = async (user: ReturnType, index: number, optionText: string) => { + const trigger = screen.getAllByRole("combobox")[index]; + await user.click(trigger); + if (trigger.getAttribute("aria-expanded") !== "true") { + trigger.focus(); + await user.keyboard("{Enter}"); + } + const options = await screen.findAllByText(optionText); + await user.click(options[options.length - 1]); +}; + +const chooseProvider = (user: ReturnType, providerLabel: string) => + chooseFromSelect(user, 0, providerLabel); + +const submit = async (user: ReturnType) => + user.click(screen.getByRole("button", { name: "Create" })); + +const createdPayload = () => mockCreate.mock.calls[0][1]; + +describe("VectorStoreForm submit payload", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockCreate.mockResolvedValue(undefined); + }); + + it("sends every payload key for the default provider, leaving untouched optional fields undefined", async () => { + const user = setupUser(); + renderForm(); + + await user.type(screen.getByPlaceholderText("Enter vector store ID from your provider"), "vs-bedrock"); + await submit(user); + + await vi.waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1)); + expect(mockCreate.mock.calls[0][0]).toBe("test-token"); + expect(createdPayload()).toStrictEqual({ + vector_store_id: "vs-bedrock", + custom_llm_provider: "bedrock", + vector_store_name: undefined, + vector_store_description: undefined, + vector_store_metadata: {}, + litellm_credential_name: undefined, + litellm_params: {}, + }); + expect(onSuccess).toHaveBeenCalledTimes(1); + }); + + it("sends the filled optional fields, parsed metadata and the selected credential", async () => { + const user = setupUser(); + renderForm(); + + await user.type(screen.getByPlaceholderText("Enter vector store ID from your provider"), "vs-full"); + const textboxes = screen.getAllByRole("textbox"); + await user.type(textboxes[1], "Support docs"); + await user.type(textboxes[2], "Docs for the support team"); + await user.clear(screen.getByPlaceholderText('{"key": "value"}')); + await user.type(screen.getByPlaceholderText('{"key": "value"}'), '{{"tier": "gold"}'); + await chooseFromSelect(user, 1, "bedrock-prod"); + await submit(user); + + await vi.waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1)); + expect(createdPayload()).toStrictEqual({ + vector_store_id: "vs-full", + custom_llm_provider: "bedrock", + vector_store_name: "Support docs", + vector_store_description: "Docs for the support team", + vector_store_metadata: { tier: "gold" }, + litellm_credential_name: "bedrock-prod", + litellm_params: {}, + }); + }); + + it("renames the milvus embedding model to litellm_embedding_model inside litellm_params", async () => { + const user = setupUser(); + renderForm(); + + await chooseProvider(user, "Milvus"); + await user.type(screen.getByPlaceholderText("Enter vector store ID from your provider"), "vs-milvus"); + await user.type(screen.getByPlaceholderText("username:password or api key"), "user:pass"); + await user.type(screen.getByPlaceholderText("https://your-milvus-endpoint.com/"), "https://milvus.example.com"); + await chooseFromSelect(user, 1, "text-embedding-3-small"); + await submit(user); + + await vi.waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1)); + expect(createdPayload().litellm_params).toStrictEqual({ + api_key: "user:pass", + api_base: "https://milvus.example.com", + litellm_embedding_model: "text-embedding-3-small", + }); + expect(createdPayload().custom_llm_provider).toBe("milvus"); + }); + + it("sends a provider field's seeded default even when the user never touches it", async () => { + const user = setupUser(); + renderForm(); + + await chooseProvider(user, "Vertex AI Search"); + await user.type( + screen.getByPlaceholderText('my-datastore_1234567890 (data store ID from Vertex AI / "Agent Search" console)'), + "vs-vertex", + ); + await user.type(screen.getByPlaceholderText("my-gcp-project-id"), "gcp-proj"); + await submit(user); + + await vi.waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1)); + expect(createdPayload().litellm_params).toStrictEqual({ + vertex_project: "gcp-proj", + vertex_location: "global", + vertex_collection_id: undefined, + vertex_engine_id: undefined, + }); + }); + + it("keeps values typed under one provider when a later provider reuses the same field name", async () => { + const user = setupUser(); + renderForm(); + + await chooseProvider(user, "PostgreSQL pgvector (LiteLLM Connector)"); + await user.type(screen.getByPlaceholderText("http://your-deployed-server:8000"), "http://pg:8000"); + await user.type(screen.getByPlaceholderText("your-deployed-api-key"), "pg-key"); + await chooseProvider(user, "Azure OpenAI"); + await user.type(screen.getByPlaceholderText("Enter vector store ID from your provider"), "vs-azure"); + await submit(user); + + await vi.waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1)); + expect(createdPayload().litellm_params).toStrictEqual({ + api_key: "pg-key", + api_base: "http://pg:8000", + }); + }); + + it("blocks the request and reports invalid metadata JSON instead of submitting", async () => { + const user = setupUser(); + renderForm(); + + await user.type(screen.getByPlaceholderText("Enter vector store ID from your provider"), "vs-bad-json"); + await user.clear(screen.getByPlaceholderText('{"key": "value"}')); + await user.type(screen.getByPlaceholderText('{"key": "value"}'), "not json"); + await submit(user); + + await vi.waitFor(() => expect(mockToast.fromError).toHaveBeenCalledWith("Invalid JSON in metadata field")); + expect(mockCreate).not.toHaveBeenCalled(); + }); + + it("keeps the required-field messages that block an empty submit", async () => { + const user = setupUser(); + renderForm(); + + await submit(user); + + expect(await screen.findByText("Please input the vector store ID from your api provider")).toBeInTheDocument(); + expect(mockCreate).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.tsx index 321ff62b1df..97c633f4f13 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.tsx @@ -1,18 +1,37 @@ import React, { useState, useEffect } from "react"; -import { TextInput, Button as TremorButton } from "@tremor/react"; -import { Modal, Form, Select, Tooltip, Input, Alert } from "antd"; -import { InfoCircleOutlined } from "@ant-design/icons"; +import { Modal, Alert } from "antd"; +import { CircleHelp, Eye, EyeOff } from "lucide-react"; +import { useWatch } from "react-hook-form"; +import { z } from "zod/v4"; import { CredentialItem, vectorStoreCreateCall } from "@/components/networking"; import { VectorStoreProviders, vectorStoreProviderLogoMap, vectorStoreProviderMap, getProviderSpecificFields, + getVectorStoreProviderLogoAndName, VectorStoreFieldConfig, } from "@/components/vector_store_providers"; import { Logo } from "@/components/molecules/logo/Logo"; import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models"; import { toast } from "@/lib/toast"; +import { FieldGroup } from "@/components/shared/form/field"; +import { FormField } from "@/components/shared/form/FormField"; +import { Button } from "@/components/ui/button"; +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from "@/components/ui/combobox"; +import { Input } from "@/components/ui/input"; +import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group"; +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 { useZodForm } from "@/lib/forms/useZodForm"; interface VectorStoreFormProps { isVisible: boolean; @@ -22,6 +41,103 @@ interface VectorStoreFormProps { credentials: CredentialItem[]; } +const PROVIDER_FIELD_NAMES = [ + "api_base", + "api_key", + "vertex_project", + "vertex_location", + "vertex_collection_id", + "vertex_engine_id", + "embedding_model", + "vector_bucket_name", + "index_name", + "aws_region_name", +] as const; + +type ProviderFieldName = (typeof PROVIDER_FIELD_NAMES)[number]; + +const isProviderFieldName = (name: string): name is ProviderFieldName => + (PROVIDER_FIELD_NAMES as readonly string[]).includes(name); + +const optionalText = z.string().optional(); + +const vectorStoreShape = { + custom_llm_provider: z.string().min(1, "Please select a provider"), + vector_store_id: z.string().min(1, "Please input the vector store ID from your api provider"), + vector_store_name: optionalText, + vector_store_description: optionalText, + litellm_credential_name: z.string().nullable().optional(), + api_base: optionalText, + api_key: optionalText, + vertex_project: optionalText, + vertex_location: optionalText, + vertex_collection_id: optionalText, + vertex_engine_id: optionalText, + embedding_model: optionalText, + vector_bucket_name: optionalText, + index_name: optionalText, + aws_region_name: optionalText, +}; + +const vectorStoreSchema = z.object(vectorStoreShape).superRefine((values, ctx) => { + getProviderSpecificFields(values.custom_llm_provider) + .filter((field) => field.required && isProviderFieldName(field.name) && !values[field.name]) + .forEach((field) => + ctx.addIssue({ + code: "custom", + path: [field.name], + message: + field.type === "select" + ? `Please select the ${field.label.toLowerCase()}` + : `Please input the ${field.label.toLowerCase()}`, + }), + ); +}); + +type VectorStoreFormValues = z.output; + +const EMPTY_VALUES: VectorStoreFormValues = { + custom_llm_provider: "bedrock", + vector_store_id: "", + vertex_location: "global", +}; + +interface CredentialOption { + label: string; + value: string | null; +} + +const labelWithHint = (label: string, hint: string): React.ReactNode => ( + <> + {label} + + } /> + {hint} + + > +); + +const PasswordInput = React.forwardRef>( + (props, ref) => { + const [revealed, setRevealed] = useState(false); + return ( + + + + setRevealed(!revealed)} + > + {revealed ? : } + + + + ); + }, +); +PasswordInput.displayName = "PasswordInput"; + const VectorStoreForm: React.FC = ({ isVisible, onCancel, @@ -29,11 +145,11 @@ const VectorStoreForm: React.FC = ({ accessToken, credentials, }) => { - const [form] = Form.useForm(); + const form = useZodForm(vectorStoreSchema, { defaultValues: EMPTY_VALUES }); const [metadataJson, setMetadataJson] = useState("{}"); const [selectedProvider, setSelectedProvider] = useState("bedrock"); const [modelInfo, setModelInfo] = useState([]); - const vertexEngineId = Form.useWatch("vertex_engine_id", form); + const vertexEngineId = useWatch({ control: form.control, name: "vertex_engine_id" }); useEffect(() => { if (!accessToken) return; @@ -52,10 +168,23 @@ const VectorStoreForm: React.FC = ({ loadModels(); }, [accessToken]); - const handleCreate = async (formValues: any) => { + const credentialOptions: CredentialOption[] = [ + { value: null, label: "None" }, + ...credentials.map((credential) => ({ + value: credential.credential_name, + label: credential.credential_name, + })), + ]; + + const makeProviderChangeHandler = (onChange: (provider: string) => void) => (provider: string | null) => { + if (provider === null) return; + onChange(provider); + setSelectedProvider(provider); + }; + + const handleCreate = async (formValues: VectorStoreFormValues) => { if (!accessToken) return; try { - // Parse metadata JSON let metadata = {}; try { metadata = metadataJson.trim() ? JSON.parse(metadataJson) : {}; @@ -64,36 +193,28 @@ const VectorStoreForm: React.FC = ({ return; } - // Prepare the payload with provider-specific fields - const payload: any = { + const providerFields = getProviderSpecificFields(formValues.custom_llm_provider); + const litellmParams = Object.fromEntries( + providerFields.filter(isSupportedProviderField).map((field) => { + const value = formValues[field.name]; + if (formValues.custom_llm_provider === "milvus" && field.name === "embedding_model") { + return ["litellm_embedding_model", value]; + } + return [field.name, value]; + }), + ); + + await vectorStoreCreateCall(accessToken, { vector_store_id: formValues.vector_store_id, custom_llm_provider: formValues.custom_llm_provider, vector_store_name: formValues.vector_store_name, vector_store_description: formValues.vector_store_description, vector_store_metadata: metadata, litellm_credential_name: formValues.litellm_credential_name, - }; - - // pass all provider fields as litellm params dict - const providerFields = getProviderSpecificFields(formValues.custom_llm_provider); - const litellmParams = providerFields.reduce( - (acc, field) => { - // Special handling for Milvus: rename embedding_model to litellm_embedding_model - if (formValues.custom_llm_provider === "milvus" && field.name === "embedding_model") { - acc["litellm_embedding_model"] = formValues[field.name]; - } else { - acc[field.name] = formValues[field.name]; - } - return acc; - }, - {} as Record, - ); - - payload["litellm_params"] = litellmParams; - - await vectorStoreCreateCall(accessToken, payload); + litellm_params: litellmParams, + }); toast.success("Vector store created successfully"); - form.resetFields(); + form.reset(EMPTY_VALUES); setMetadataJson("{}"); onSuccess(); } catch (error) { @@ -103,312 +224,333 @@ const VectorStoreForm: React.FC = ({ }; const handleCancel = () => { - form.resetFields(); + form.reset(EMPTY_VALUES); setMetadataJson("{}"); setSelectedProvider("bedrock"); onCancel(); }; + const vectorStoreIdPlaceholder = + selectedProvider === "vertex_rag_engine" + ? '6917529027641081856 (corpus ID from Vertex AI / "RAG Engine" console)' + : selectedProvider === "vertex_ai/search_api" + ? vertexEngineId + ? "Any identifier you'll use to reference this in LiteLLM" + : 'my-datastore_1234567890 (data store ID from Vertex AI / "Agent Search" console)' + : "Enter vector store ID from your provider"; + return ( - - - Provider{" "} - - - - - } - name="custom_llm_provider" - rules={[{ required: true, message: "Please select a provider" }]} - initialValue="bedrock" - > - setSelectedProvider(value)}> - {Object.entries(VectorStoreProviders).map(([providerEnum, providerDisplayName]) => { - return ( - - - - {providerDisplayName} - - - ); - })} - - - - {/* PG Vector Setup Instructions */} - {selectedProvider === "pg_vector" && ( - - LiteLLM provides a server to connect to PG Vector. To use this provider: - - - Deploy the litellm-pgvector server from:{" "} - - https://github.com/BerriAI/litellm-pgvector - - - Configure your PostgreSQL database with pgvector extension - Start the server and note the API base URL and API key - Enter those details in the fields below - - - } - type="info" - showIcon - style={{ marginBottom: "16px" }} - /> - )} - - {/* Vertex RAG Engine Setup Instructions */} - {selectedProvider === "vertex_rag_engine" && ( - - To use Vertex AI RAG Engine: - - Note: Google Cloud has renamed this to "RAG Engine" in its console — the steps below still - apply. - - - - Set up your Vertex AI RAG Engine corpus following the guide:{" "} - - Vertex AI RAG Engine Overview - - - Create a corpus in your Google Cloud project - - Note the corpus ID from the Vertex AI console (now labeled "RAG Engine" in Google Cloud) - - Enter the corpus ID in the Vector Store ID field below - - - } - type="info" - showIcon - style={{ marginBottom: "16px" }} - /> - )} - - {/* Vertex AI Search Setup Instructions */} - {selectedProvider === "vertex_ai/search_api" && ( - - To use Vertex AI Search (Discovery Engine): - - Note: Google Cloud has renamed this to "Agent Search" in its console — the steps below still - apply. - - - - Enable the Discovery Engine API on your Google Cloud project and create a data store following the - guide:{" "} - - Create a Vertex AI Search data store - - - Pick a supported location: global, us, or eu - - For most data store types (Cloud Storage, BigQuery, Media): copy the data store ID and enter it in - the Vector Store ID field below. - - - For website, healthcare, and connector-based sources (Drive, Gmail, Slack, Jira, etc.): create a - search app on top of the data store, then copy the Engine ID and enter it in the - Engine ID field. The Vector Store ID is still required as the LiteLLM-side name for this record, but - it isn't used in the GCP URL when Engine ID is set. - - -
+ Block or reframe competitor comparison questions. When enabled, airline type auto-loads competitors from + IATA; generic type requires manual competitor list. +
+ Block or reframe competitor comparison questions. Airline type uses major airlines (excluding your brand); + generic requires manual competitor list. +
LiteLLM provides a server to connect to PG Vector. To use this provider:
To use Vertex AI RAG Engine:
- Note: Google Cloud has renamed this to "RAG Engine" in its console — the steps below still - apply. -
To use Vertex AI Search (Discovery Engine):
- Note: Google Cloud has renamed this to "Agent Search" in its console — the steps below still - apply. -
+ Note: Google Cloud has renamed this to "RAG Engine" in its console — the steps below + still apply. +
+ Note: Google Cloud has renamed this to "Agent Search" in its console — the steps below + still apply. +
{metadataString}