From d6fe9712fae480e1eec9c6f5bc808e1523ea762d Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 18 Aug 2026 11:44:15 -0700 Subject: [PATCH] refactor(ui): migrate guardrail and vector store forms to react-hook-form and shadcn (#37306) * refactor(ui): migrate guardrail and vector store forms to react-hook-form and shadcn Ports four antd forms in the guardrails and vector stores pages onto react-hook-form with zod resolvers and the shadcn field kit, and takes the files they live in off light-only Tailwind colors VectorStoreForm and vector_store_info now build their payloads from typed form values instead of an antd FormInstance, seeding the edit view through an explicit mapper rather than spreading the whole server record. The submit modal in TeamGuardrailsTab moves to the same shape, and its URL rule is reproduced exactly: src/lib/forms/antdUrl.ts compiles the pattern async-validator uses for `type: "url"`, with a test asserting the compiled source and flags match, so a protocol-less www host keeps passing and a bare domain keeps failing CompetitorIntentConfiguration had no FormInstance at all: its antd Form was a layout wrapper with no named items and no onFinish, so it moves onto the field primitives directly rather than gaining form state it never had. Its tag and threshold controls are replaced by local TagsInput and ThresholdInput components that reproduce what antd did, comma token separators plus commit on blur for tags, and clamp-on-blur with step-precision display for the thresholds, without introducing the native number constraints that would newly block the surrounding guardrail form Every payload is pinned by a characterization test that was proven green against the antd original before the swap and then re-run unedited * fix(ui): keep the vector store edit form saving when the server sends null The proxy returns null for an unset vector_store_name or vector_store_description rather than omitting the key, and both columns are nullable. z.string().optional() accepts undefined but rejects null, so loading any store whose name or description was never set left the edit form stuck on "Invalid input: expected string, received null" and it could not submit at all. nullish() accepts both and forwards null unchanged, which is what the antd version did Pinned by an untouched-save case that seeds both fields null and clicks Save without typing anything. It fails against the optional() schema with zero requests sent, and passes against both the fix and the antd original, sending vector_store_name and vector_store_description as null Also drops the deep import into @rc-component/async-validator, an undeclared transitive dependency that failed the knip gate. The URL parity assertion now compares against a checked-in snapshot of the pattern async-validator 5.1.0 compiles, so it stays an exact-equality check, and removes a comment that only restated the networking layer's error handling --- ui/litellm-dashboard/eslint-suppressions.json | 13 +- .../TeamGuardrailsTab.integration.test.tsx | 193 +++++ .../_components/TeamGuardrailsTab.tsx | 461 +++++----- .../CompetitorIntentConfiguration.test.tsx | 178 ++++ .../CompetitorIntentConfiguration.tsx | 394 ++++----- .../content_filter/TagsInput.test.tsx | 88 ++ .../_components/content_filter/TagsInput.tsx | 139 ++++ .../content_filter/ThresholdInput.test.tsx | 89 ++ .../content_filter/ThresholdInput.tsx | 76 ++ .../VectorStoreForm.integration.test.tsx | 191 +++++ .../_components/VectorStoreForm.tsx | 784 +++++++++++------- .../vector_store_info.integration.test.tsx | 160 ++++ .../_components/vector_store_info.tsx | 324 +++++--- .../vector_store_management/types.tsx | 1 + .../src/lib/forms/antdUrl.test.ts | 37 + ui/litellm-dashboard/src/lib/forms/antdUrl.ts | 29 + 16 files changed, 2317 insertions(+), 840 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/TeamGuardrailsTab.integration.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/CompetitorIntentConfiguration.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/TagsInput.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/TagsInput.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ThresholdInput.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ThresholdInput.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.integration.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/vector_store_info.integration.test.tsx create mode 100644 ui/litellm-dashboard/src/lib/forms/antdUrl.test.ts create mode 100644 ui/litellm-dashboard/src/lib/forms/antdUrl.ts 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
@@ -301,7 +364,7 @@ function GuardrailCard({ @@ -310,16 +373,16 @@ function GuardrailCard({
-
+
-

{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 && ( @@ -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 && (
)}
-
+
{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{" "}

-
+
@@ -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" />
- - - - - - - - { - 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 }) => ( + + )} + + + {({ ref, ...field }) => ( + + )} + + + {({ ref, ...field }) => ( +