From 3c82791968b6c4b64aba5fb97bf1a7fcc7567f8b Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 18 Aug 2026 14:39:36 -0700 Subject: [PATCH] refactor(ui): migrate the guardrail forms to react-hook-form and shadcn (#37364) Moves the guardrail form graph off antd Form onto react-hook-form with the shadcn field primitives. The graph migrates atomically: add_guardrail_form and guardrail_info own the form instances, and guardrail_provider_fields, guardrail_optional_params and LLMJudgeFields are field groups rendered inside them, so an antd parent could not host a react-hook-form child either way. The submit payload is unchanged. Two characterization suites, 25 cases, pin it: each case was written against the antd original, proven green there, and passes unedited against the migration. Behaviour worth calling out. Nested provider fields are keyed with ":" rather than "." so they stay flat keys the way antd stored them, since a dotted name is a lodash path in react-hook-form and would have started shipping a nested object. antd InputNumber clears to null and clamps on blur where a native number input does neither, so the judge criteria weights reproduce that. The guardrail_info submit handler is read through a ref at validation-resolution time, matching how antd re-read onFinish, so a submit fired by the same click that changed state still sees that state. Two antd behaviours are preserved rather than fixed, both worth their own follow-up: deselecting every mode blocks Next instead of falling back to the seeded default, and a required provider-specific field is never enforced at create time. One is fixed and disclosed: a failed validation now names the problem instead of rendering "[object Object]", and the guardrail name label is associated with its control, which it was not before. MultiSelect takes an optional id so the label can point at the control. SkipMessageSelect was duplicated verbatim in both parents and now lives in the shared field module. --- ui/litellm-dashboard/eslint-suppressions.json | 17 - .../_components/GuardrailFormField.tsx | 125 ++++ ...d_guardrail_form.characterization.test.tsx | 277 ++++++++ .../_components/add_guardrail_form.tsx | 644 ++++++++++-------- .../guardrail_info.characterization.test.tsx | 289 ++++++++ .../guardrails/_components/guardrail_info.tsx | 414 ++++++----- .../_components/guardrail_optional_params.tsx | 338 ++++++--- .../_components/guardrail_provider_fields.tsx | 234 +++++-- .../_components/llm_judge/LLMJudgeFields.tsx | 373 ++++++---- .../src/components/shared/MultiSelect.tsx | 5 +- 10 files changed, 1897 insertions(+), 819 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailFormField.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/add_guardrail_form.characterization.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info.characterization.test.tsx diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index df6fd1ef586..6fc646e0069 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -401,12 +401,6 @@ "local/filename-pascal-case": { "count": 1 }, - "no-nested-ternary": { - "count": 5 - }, - "no-restricted-imports": { - "count": 1 - }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -415,12 +409,6 @@ "local/filename-pascal-case": { "count": 1 }, - "no-nested-ternary": { - "count": 5 - }, - "no-restricted-imports": { - "count": 1 - }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -430,11 +418,6 @@ "count": 1 } }, - "src/app/(dashboard)/guardrails/_components/llm_judge/LLMJudgeFields.tsx": { - "no-restricted-imports": { - "count": 2 - } - }, "src/app/(dashboard)/guardrails/_components/pii_components.tsx": { "local/filename-pascal-case": { "count": 1 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailFormField.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailFormField.tsx new file mode 100644 index 00000000000..0edce78b8fd --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailFormField.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { CircleHelp } from "lucide-react"; +import React, { useId } from "react"; +import { useController, type Control, type ControllerRenderProps, type RegisterOptions } from "react-hook-form"; +import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/shared/form/field"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; + +export interface GuardrailCriterion { + name: string; + weight: number | string; + description?: string; +} + +export interface GuardrailFormValues extends Record { + criteria?: GuardrailCriterion[]; +} +export type GuardrailFormControl = Control; +export type GuardrailFieldRules = Pick, "validate">; + +export type GuardrailFieldControlProps = ControllerRenderProps & { + id: string; + "aria-invalid": true | undefined; + "aria-describedby": string | undefined; +}; + +const isBlank = (value: unknown): boolean => + value === undefined || value === null || value === "" || (Array.isArray(value) && value.length === 0); + +export const requiredRule = (message: string): GuardrailFieldRules => ({ + validate: (value: unknown) => (isBlank(value) ? message : true), +}); + +export const asText = (value: unknown): string => { + if (typeof value === "string") return value; + if (typeof value === "number") return String(value); + return ""; +}; + +export const asStringArray = (value: unknown): string[] => { + if (Array.isArray(value)) return value.filter((entry): entry is string => typeof entry === "string"); + if (typeof value === "string" && value !== "") return [value]; + return []; +}; + +export const readRecord = (source: unknown, key: string): unknown => + source !== null && typeof source === "object" ? (source as Record)[key] : undefined; + +export const labelWithHint = (label: React.ReactNode, hint: string): React.ReactNode => ( + <> + {label} + + } /> + {hint} + + +); + +export interface GuardrailFieldProps { + control: GuardrailFormControl; + name: string; + label?: React.ReactNode; + description?: React.ReactNode; + rules?: GuardrailFieldRules; + defaultValue?: unknown; + className?: string; + children: (control: GuardrailFieldControlProps) => React.ReactNode; +} + +export const GuardrailField: React.FC = ({ + control, + name, + label, + description, + rules, + defaultValue, + className, + children, +}) => { + const reactId = useId(); + const controlId = `${reactId}-control`; + const descriptionId = `${reactId}-description`; + const errorId = `${reactId}-error`; + const { field, fieldState } = useController({ control, name, rules, defaultValue }); + const invalid = fieldState.error !== undefined; + const describedBy = + [description !== undefined ? descriptionId : undefined, invalid ? errorId : undefined] + .filter((id): id is string => id !== undefined) + .join(" ") || undefined; + + return ( + + {label !== undefined && {label}} + {children({ ...field, id: controlId, "aria-invalid": invalid || undefined, "aria-describedby": describedBy })} + {description !== undefined && {description}} + + + ); +}; + +const SKIP_MESSAGE_ITEMS = [ + { label: "Use global default", value: "inherit" }, + { label: "Yes — exclude from guardrail scan", value: "yes" }, + { label: "No — always include in scan", value: "no" }, +]; + +export const SkipMessageSelect: React.FC<{ control: GuardrailFieldControlProps }> = ({ control }) => { + const { id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy } = control; + + return ( + + ); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/add_guardrail_form.characterization.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/add_guardrail_form.characterization.test.tsx new file mode 100644 index 00000000000..c1b419d013d --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/add_guardrail_form.characterization.test.tsx @@ -0,0 +1,277 @@ +import React from "react"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "@/../tests/test-utils"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import AddGuardrailForm from "./add_guardrail_form"; + +vi.mock("@/components/networking", () => ({ + createGuardrailCall: vi.fn(), + getGuardrailProviderSpecificParams: vi.fn(), + getGuardrailUISettings: vi.fn(), + modelAvailableCall: vi.fn(), +})); + +import * as networking from "@/components/networking"; + +const providerParams = { + bedrock: { + ui_friendly_name: "Bedrock Guardrail", + guardrailIdentifier: { description: "The guardrail id on Bedrock", required: true, type: null }, + api_key: { description: "Bedrock API key", required: false, type: null }, + optional_params: { + description: "Optional parameters", + required: false, + type: "nested", + fields: { + severity_threshold: { description: "Severity threshold", required: false, type: "number" }, + }, + }, + }, + llm_as_a_judge: { + ui_friendly_name: "LiteLLM LLM as a Judge", + }, +}; + +const uiSettings = { + supported_entities: [], + supported_actions: [], + supported_modes: ["pre_call", "post_call"], + pii_entity_categories: [], +}; + +const renderForm = () => { + const onSuccess = vi.fn(); + const onClose = vi.fn(); + renderWithProviders( + , + ); + return { onSuccess, onClose }; +}; + +const pickProvider = async (user: ReturnType, label: string) => { + await user.click(screen.getByLabelText("Guardrail Provider")); + await user.click(await screen.findByText(label)); +}; + +const payload = () => vi.mocked(networking.createGuardrailCall).mock.calls.at(-1)?.[1]; + +describe("AddGuardrailForm create payload characterization", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(networking.getGuardrailUISettings).mockResolvedValue(uiSettings); + vi.mocked(networking.getGuardrailProviderSpecificParams).mockResolvedValue(providerParams); + vi.mocked(networking.modelAvailableCall).mockResolvedValue({ data: [{ id: "gpt-5" }] }); + vi.mocked(networking.createGuardrailCall).mockResolvedValue({ guardrail_id: "new" }); + }); + + it("sends the seeded step-0 defaults even though those fields are unmounted at submit time", async () => { + const user = userEvent.setup({ delay: null }); + renderForm(); + + await user.type(await screen.findByLabelText("Guardrail Name"), "my-bedrock"); + await pickProvider(user, "Bedrock Guardrail"); + await user.type(await screen.findByPlaceholderText("The guardrail id on Bedrock"), "gr-123"); + await user.click(screen.getByRole("button", { name: "Next" })); + await user.click(await screen.findByRole("button", { name: "Create Guardrail" })); + + await waitFor(() => expect(networking.createGuardrailCall).toHaveBeenCalledTimes(1)); + expect(payload()).toEqual({ + guardrail_name: "my-bedrock", + litellm_params: { + guardrail: "bedrock", + mode: "pre_call", + default_on: false, + guardrailIdentifier: "gr-123", + }, + guardrail_info: {}, + }); + }); + + it("switches mode from the seeded string to an array once the user touches the multi select", async () => { + const user = userEvent.setup({ delay: null }); + renderForm(); + + await user.type(await screen.findByLabelText("Guardrail Name"), "my-bedrock"); + await pickProvider(user, "Bedrock Guardrail"); + await user.click(screen.getByLabelText("Mode")); + const postCallOption = (await screen.findAllByText("post_call")).at(-1) as HTMLElement; + await user.click(postCallOption); + await user.type(await screen.findByPlaceholderText("The guardrail id on Bedrock"), "gr-123"); + await user.click(screen.getByRole("button", { name: "Next" })); + await user.click(await screen.findByRole("button", { name: "Create Guardrail" })); + + await waitFor(() => expect(networking.createGuardrailCall).toHaveBeenCalledTimes(1)); + expect(payload()).toMatchObject({ litellm_params: { mode: ["pre_call", "post_call"] } }); + }); + + it("blocks Next when the user deselects every mode", async () => { + const user = userEvent.setup({ delay: null }); + renderForm(); + + await user.type(await screen.findByLabelText("Guardrail Name"), "my-bedrock"); + await pickProvider(user, "Bedrock Guardrail"); + await user.click(screen.getByLabelText("Mode")); + const preCallOption = (await screen.findAllByText("pre_call")).at(-1) as HTMLElement; + await user.click(preCallOption); + await user.type(await screen.findByPlaceholderText("The guardrail id on Bedrock"), "gr-123"); + await user.click(screen.getByRole("button", { name: "Next" })); + + expect(await screen.findByText("Please select a mode")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Create Guardrail" })).not.toBeInTheDocument(); + }); + + it("copies a value typed in the optional params step up to the top level of litellm_params", async () => { + const user = userEvent.setup({ delay: null }); + renderForm(); + + await user.type(await screen.findByLabelText("Guardrail Name"), "my-bedrock"); + await pickProvider(user, "Bedrock Guardrail"); + await user.type(await screen.findByPlaceholderText("The guardrail id on Bedrock"), "gr-123"); + await user.click(screen.getByRole("button", { name: "Next" })); + + await user.type(await screen.findByPlaceholderText("Severity threshold"), "4"); + await user.click(screen.getByRole("button", { name: "Create Guardrail" })); + + await waitFor(() => expect(networking.createGuardrailCall).toHaveBeenCalledTimes(1)); + expect(payload()).toMatchObject({ + litellm_params: { guardrailIdentifier: "gr-123", severity_threshold: 4 }, + }); + }); + + it("omits a provider param the user left blank rather than sending an empty string", async () => { + const user = userEvent.setup({ delay: null }); + renderForm(); + + await user.type(await screen.findByLabelText("Guardrail Name"), "my-bedrock"); + await pickProvider(user, "Bedrock Guardrail"); + await user.type(await screen.findByPlaceholderText("The guardrail id on Bedrock"), "gr-123"); + await user.click(screen.getByRole("button", { name: "Next" })); + await user.click(await screen.findByRole("button", { name: "Create Guardrail" })); + + await waitFor(() => expect(networking.createGuardrailCall).toHaveBeenCalledTimes(1)); + expect(payload()).not.toHaveProperty("litellm_params.api_key"); + }); + + it("blocks Next and sends nothing when the required guardrail name is missing", async () => { + const user = userEvent.setup({ delay: null }); + renderForm(); + + await pickProvider(user, "Bedrock Guardrail"); + await user.click(screen.getByRole("button", { name: "Next" })); + + expect(await screen.findByText("Please enter a guardrail name")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Create Guardrail" })).not.toBeInTheDocument(); + }); + + it("creates the guardrail even though a required provider field was left blank", async () => { + const user = userEvent.setup({ delay: null }); + renderForm(); + + await user.type(await screen.findByLabelText("Guardrail Name"), "my-bedrock"); + await pickProvider(user, "Bedrock Guardrail"); + await screen.findByPlaceholderText("The guardrail id on Bedrock"); + await user.click(screen.getByRole("button", { name: "Next" })); + await user.click(await screen.findByRole("button", { name: "Create Guardrail" })); + + await waitFor(() => expect(networking.createGuardrailCall).toHaveBeenCalledTimes(1)); + expect(payload()).toEqual({ + guardrail_name: "my-bedrock", + litellm_params: { guardrail: "bedrock", mode: "pre_call", default_on: false }, + guardrail_info: {}, + }); + }); + + it("keeps a step-0 value the user typed after moving forward and back", async () => { + const user = userEvent.setup({ delay: null }); + renderForm(); + + await user.type(await screen.findByLabelText("Guardrail Name"), "round-trip"); + await pickProvider(user, "Bedrock Guardrail"); + await user.type(await screen.findByPlaceholderText("The guardrail id on Bedrock"), "gr-123"); + await user.click(screen.getByRole("button", { name: "Next" })); + await user.click(await screen.findByRole("button", { name: "Previous" })); + + expect(await screen.findByLabelText("Guardrail Name")).toHaveValue("round-trip"); + }); + + it("sends the llm judge criteria with numeric weights and the seeded threshold default", async () => { + const user = userEvent.setup({ delay: null }); + renderForm(); + + await user.type(await screen.findByLabelText("Guardrail Name"), "judge-1"); + await pickProvider(user, "LiteLLM LLM as a Judge"); + await user.click(screen.getByRole("button", { name: "Next" })); + + await user.type(await screen.findByPlaceholderText("Criterion name (e.g. Policy accuracy)"), "Accuracy"); + await user.type(screen.getByPlaceholderText("What should the judge check for this criterion?"), "Is it right"); + await user.click(screen.getByLabelText("Judge Model")); + await user.click(await screen.findByTitle("gpt-5")); + await user.click(screen.getByRole("button", { name: "Create Guardrail" })); + + await waitFor(() => expect(networking.createGuardrailCall).toHaveBeenCalledTimes(1)); + expect(payload()).toEqual({ + guardrail_name: "judge-1", + litellm_params: { + guardrail: "llm_as_a_judge", + mode: "post_call", + default_on: false, + judge_model: "gpt-5", + overall_threshold: 80, + on_failure: "block", + criteria: [{ name: "Accuracy", weight: 100, description: "Is it right" }], + }, + guardrail_info: {}, + }); + }); + + it("refuses to create an llm judge guardrail whose criterion weights do not total 100", async () => { + const user = userEvent.setup({ delay: null }); + renderForm(); + + await user.type(await screen.findByLabelText("Guardrail Name"), "judge-2"); + await pickProvider(user, "LiteLLM LLM as a Judge"); + await user.click(screen.getByRole("button", { name: "Next" })); + + await user.type(await screen.findByPlaceholderText("Criterion name (e.g. Policy accuracy)"), "Accuracy"); + await user.type(screen.getByPlaceholderText("What should the judge check for this criterion?"), "Is it right"); + const weight = screen.getByPlaceholderText("e.g. 50"); + await user.clear(weight); + await user.type(weight, "60"); + await user.click(screen.getByLabelText("Judge Model")); + await user.click(await screen.findByTitle("gpt-5")); + await user.click(screen.getByRole("button", { name: "Create Guardrail" })); + + await screen.findByText(/Weights total: 60%/); + expect(networking.createGuardrailCall).not.toHaveBeenCalled(); + }); + it("carries the three step-0 selects to the payload when the user moves each off its default", async () => { + const user = userEvent.setup({ delay: null }); + renderForm(); + + await user.type(await screen.findByLabelText("Guardrail Name"), "my-bedrock"); + await pickProvider(user, "Bedrock Guardrail"); + + await user.click(screen.getByLabelText("Always On")); + await user.click((await screen.findAllByText("Yes")).at(-1) as HTMLElement); + + await user.click(screen.getByLabelText("Skip system messages in guardrail")); + await user.click((await screen.findAllByText(/exclude from guardrail scan/)).at(-1) as HTMLElement); + + await user.click(screen.getByLabelText("Skip tool messages in guardrail")); + await user.click((await screen.findAllByText(/always include in scan/)).at(-1) as HTMLElement); + + await user.type(await screen.findByPlaceholderText("The guardrail id on Bedrock"), "gr-123"); + await user.click(screen.getByRole("button", { name: "Next" })); + await user.click(await screen.findByRole("button", { name: "Create Guardrail" })); + + await waitFor(() => expect(networking.createGuardrailCall).toHaveBeenCalledTimes(1)); + expect(payload()).toMatchObject({ + litellm_params: { + default_on: true, + skip_system_message_in_guardrail: true, + skip_tool_message_in_guardrail: false, + }, + }); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/add_guardrail_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/add_guardrail_form.tsx index 7affbded6cc..0ca852feb52 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/add_guardrail_form.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/add_guardrail_form.tsx @@ -1,5 +1,6 @@ -import { Form, Input, Modal, Select, Tag, Button } from "antd"; +import { Modal } from "antd"; import React, { useEffect, useMemo, useState } from "react"; +import { useForm, type UseFormReturn } from "react-hook-form"; import { toast } from "@/lib/toast"; import { createGuardrailCall, @@ -24,14 +25,39 @@ import { toModeArray, } from "./guardrail_info_helpers"; import { Logo } from "@/components/molecules/logo/Logo"; +import { MultiSelect } from "@/components/shared/MultiSelect"; +import { FieldGroup } from "@/components/shared/form/field"; +import { Button } 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 { TooltipProvider } from "@/components/ui/tooltip"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; +import { + asStringArray, + asText, + GuardrailField, + labelWithHint, + readRecord, + requiredRule, + type GuardrailCriterion, + type GuardrailFormValues, + SkipMessageSelect, +} from "./GuardrailFormField"; import GuardrailOptionalParams from "./guardrail_optional_params"; import GuardrailProviderFields from "./guardrail_provider_fields"; import LLMJudgeFields from "./llm_judge/LLMJudgeFields"; import PiiConfiguration from "./pii_configuration"; import ToolPermissionRulesEditor, { ToolPermissionConfig } from "./tool_permission/ToolPermissionRulesEditor"; -const { Option } = Select; - // Define human-friendly descriptions for each mode const modeDescriptions = { pre_call: "Before LLM Call - Runs before the LLM call and checks the input (Recommended)", @@ -110,12 +136,6 @@ interface SelectedContentCategory { severity_threshold: "high" | "medium" | "low"; } -interface JudgeCriterion { - name: string; - weight: number | string; - description?: string; -} - const createEmptyToolPermissionConfig = (): ToolPermissionConfig => ({ rules: [], default_action: "deny", @@ -123,18 +143,44 @@ const createEmptyToolPermissionConfig = (): ToolPermissionConfig => ({ violation_message_template: "", }); -const getStepIndicatorStyle = (isDone: boolean, isCurrent: boolean): React.CSSProperties => { - if (isDone) return { background: "#4f46e5", color: "#fff", border: "none" }; - if (isCurrent) return { background: "#fff", color: "#4f46e5", border: "2px solid #4f46e5" }; - return { background: "#f8fafc", color: "#94a3b8", border: "1px solid #e2e8f0" }; +const getStepIndicatorClass = (isDone: boolean, isCurrent: boolean): string => { + if (isDone) return "bg-indigo-600 text-white dark:bg-indigo-500"; + if (isCurrent) + return "bg-background text-indigo-600 border-2 border-indigo-600 dark:text-indigo-400 dark:border-indigo-400"; + return "bg-muted text-muted-foreground border border-border"; }; -const getStepTitleColor = (isDone: boolean, isCurrent: boolean): string => { - if (isCurrent) return "#1e293b"; - if (isDone) return "#4f46e5"; - return "#94a3b8"; +const getStepTitleClass = (isDone: boolean, isCurrent: boolean): string => { + if (isCurrent) return "font-semibold text-foreground"; + if (isDone) return "font-medium text-indigo-600 dark:text-indigo-400"; + return "font-medium text-muted-foreground"; }; +type SkipMessageChoice = "inherit" | "yes" | "no"; + +const INITIAL_VALUES: GuardrailFormValues = { + mode: "pre_call", + default_on: false, + skip_system_message_choice: "inherit", + skip_tool_message_choice: "inherit", +}; + +const ALWAYS_ON_ITEMS = [ + { label: "Yes", value: true }, + { label: "No", value: false }, +]; + +const DEFAULT_MODES = ["pre_call", "during_call", "post_call", "logging_only"]; + +const CALL_TYPE_ITEMS = [{ label: "/v1/realtime", value: "realtime" }]; + +const applyValues = (form: UseFormReturn, values: Record) => { + Object.entries(values).forEach(([name, value]) => form.setValue(name, value)); +}; + +const asSkipChoice = (value: unknown): SkipMessageChoice | undefined => + value === "inherit" || value === "yes" || value === "no" ? value : undefined; + // Mapping of provider -> list of param descriptors interface ProviderParam { param: string; @@ -153,7 +199,7 @@ interface ProviderParamsResponse { } const AddGuardrailForm: React.FC = ({ visible, onClose, accessToken, onSuccess, preset }) => { - const [form] = Form.useForm(); + const form = useForm({ defaultValues: INITIAL_VALUES }); const [loading, setLoading] = useState(false); const [selectedProvider, setSelectedProvider] = useState(null); const [guardrailSettings, setGuardrailSettings] = useState(null); @@ -238,7 +284,7 @@ const AddGuardrailForm: React.FC = ({ visible, onClose, a if (preset.provider === "BlockCodeExecution") { baseValues.confidence_threshold = 0.5; } - form.setFieldsValue(baseValues); + applyValues(form, baseValues); // Pre-select content category if specified if (preset.categoryName && guardrailSettings.content_filter_settings?.content_categories) { @@ -278,14 +324,14 @@ const AddGuardrailForm: React.FC = ({ visible, onClose, a ? guardrailSettings.supported_modes_by_provider[newProviderKey] : undefined; if (newProviderModes) { - const selectedModes = toModeArray(form.getFieldValue("mode")); + const selectedModes = toModeArray(form.getValues("mode")); const keptModes = selectedModes.filter((m) => newProviderModes.includes(m)); if (keptModes.length !== selectedModes.length) { resetValues.mode = keptModes.length > 0 ? keptModes : undefined; } } - form.setFieldsValue(resetValues); + applyValues(form, resetValues); // Reset PII selections when changing provider setSelectedEntities([]); @@ -303,7 +349,7 @@ const AddGuardrailForm: React.FC = ({ visible, onClose, a // Default LLM-as-a-Judge to post_call mode if (value === "LlmAsAJudge") { - form.setFieldsValue({ mode: "post_call" }); + form.setValue("mode", "post_call"); } }; @@ -325,34 +371,25 @@ const AddGuardrailForm: React.FC = ({ visible, onClose, a }; const nextStep = async () => { - try { - // Validate current step fields - if (currentStep === 0) { - await form.validateFields(["guardrail_name", "provider", "mode", "default_on"]); - // Also validate provider-specific fields if applicable - if (selectedProvider) { - // This will automatically validate any required fields for the selected provider - const fieldsToValidate = ["guardrail_name", "provider", "mode", "default_on"]; - - if (selectedProvider === "PresidioPII") { - fieldsToValidate.push("presidio_analyzer_api_base", "presidio_anonymizer_api_base"); - } - await form.validateFields(fieldsToValidate); - } + // Validate current step fields + if (currentStep === 0) { + const presidioFields = + selectedProvider === "PresidioPII" ? ["presidio_analyzer_api_base", "presidio_anonymizer_api_base"] : []; + const isValid = await form.trigger(["guardrail_name", "provider", "mode", "default_on", ...presidioFields]); + if (!isValid) { + return; } - - // Validate configuration steps - if (currentStep === 1) { - if (shouldRenderPIIConfigSettings(selectedProvider) && selectedEntities.length === 0) { - toast.fromError("Please select at least one PII entity to continue"); - return; - } - } - - setCurrentStep(currentStep + 1); - } catch (error) { - console.error("Form validation failed:", error); } + + // Validate configuration steps + if (currentStep === 1) { + if (shouldRenderPIIConfigSettings(selectedProvider) && selectedEntities.length === 0) { + toast.fromError("Please select at least one PII entity to continue"); + return; + } + } + + setCurrentStep(currentStep + 1); }; const prevStep = () => { @@ -360,7 +397,7 @@ const AddGuardrailForm: React.FC = ({ visible, onClose, a }; const resetForm = () => { - form.resetFields(); + form.reset(INITIAL_VALUES); setSelectedProvider(null); setSelectedEntities([]); setSelectedActions({}); @@ -386,26 +423,28 @@ const AddGuardrailForm: React.FC = ({ visible, onClose, a try { setLoading(true); // First validate currently visible fields - await form.validateFields(); + if (!(await form.trigger())) { + toast.fromError("Failed to create guardrail: please fix the highlighted fields"); + return; + } // After validation, fetch *all* form values (including those from previous steps) - const values = form.getFieldsValue(true); + const values = form.getValues(); + const providerKey = asText(values.provider); // Get the guardrail provider value from the map - const guardrailProvider = guardrail_provider_map[values.provider]; + const guardrailProvider = guardrail_provider_map[providerKey]; // Prepare the guardrail data with proper typings const guardrailData: { guardrail_name: string; litellm_params: { guardrail: string; - mode: string; - default_on: boolean; [key: string]: unknown; // Allow dynamic properties }; guardrail_info: Record; } = { - guardrail_name: values.guardrail_name, + guardrail_name: asText(values.guardrail_name), litellm_params: { guardrail: guardrailProvider, mode: values.mode, @@ -414,18 +453,18 @@ const AddGuardrailForm: React.FC = ({ visible, onClose, a guardrail_info: {}, }; - const skipForCreate = choiceToSkipSystemForCreate(values.skip_system_message_choice); + const skipForCreate = choiceToSkipSystemForCreate(asSkipChoice(values.skip_system_message_choice)); if (skipForCreate !== undefined) { guardrailData.litellm_params.skip_system_message_in_guardrail = skipForCreate; } - const skipToolForCreate = choiceToSkipToolForCreate(values.skip_tool_message_choice); + const skipToolForCreate = choiceToSkipToolForCreate(asSkipChoice(values.skip_tool_message_choice)); if (skipToolForCreate !== undefined) { guardrailData.litellm_params.skip_tool_message_in_guardrail = skipToolForCreate; } // For Presidio PII, add the entity and action configurations - if (values.provider === "PresidioPII" && selectedEntities.length > 0) { + if (providerKey === "PresidioPII" && selectedEntities.length > 0) { const piiEntitiesConfig: { [key: string]: string } = {}; selectedEntities.forEach((entity) => { piiEntitiesConfig[entity] = selectedActions[entity] || "MASK"; // Default to MASK if no action selected @@ -443,7 +482,7 @@ const AddGuardrailForm: React.FC = ({ visible, onClose, a } // For Content Filter, add patterns, blocked words, categories, and optionally competitor intent - if (shouldRenderContentFilterConfigSettings(values.provider)) { + if (shouldRenderContentFilterConfigSettings(providerKey)) { // Validate that at least one content filter setting is configured const hasCompetitorIntent = competitorIntentEnabled && (competitorIntentConfig?.brand_self?.length ?? 0) > 0; const hasContentFilterSelections = @@ -501,7 +540,7 @@ const AddGuardrailForm: React.FC = ({ visible, onClose, a // Add config values to the guardrail_info if provided else if (values.config) { try { - const configObj = JSON.parse(values.config); + const configObj = JSON.parse(asText(values.config)); // For some guardrails, the config values need to be in litellm_params guardrailData.guardrail_info = configObj; } catch (error) { @@ -512,7 +551,7 @@ const AddGuardrailForm: React.FC = ({ visible, onClose, a } if (guardrailProvider === "llm_as_a_judge") { - const criteria: JudgeCriterion[] = values.criteria || []; + const criteria: GuardrailCriterion[] = values.criteria ?? []; if (criteria.length === 0) { toast.fromError("Add at least one evaluation criterion"); setLoading(false); @@ -549,7 +588,7 @@ const AddGuardrailForm: React.FC = ({ visible, onClose, a } // Endpoint Settings (realtime) — content filter only - if (shouldRenderContentFilterConfigSettings(values.provider)) { + if (shouldRenderContentFilterConfigSettings(providerKey)) { if (endSessionAfterNFails !== undefined && endSessionAfterNFails > 0) { guardrailData.litellm_params.end_session_after_n_fails = endSessionAfterNFails; } @@ -594,10 +633,11 @@ const AddGuardrailForm: React.FC = ({ visible, onClose, a allowedParams.forEach((paramName) => { // Check for both direct parameter name and nested optional_params object - let paramValue = values[paramName]; - if (paramValue === undefined || paramValue === null || paramValue === "") { - paramValue = values.optional_params?.[paramName]; - } + const directValue = values[paramName]; + const paramValue = + directValue === undefined || directValue === null || directValue === "" + ? readRecord(values.optional_params, paramName) + : directValue; if (paramValue !== undefined && paramValue !== null && paramValue !== "") { guardrailData.litellm_params[paramName] = paramValue; @@ -630,151 +670,143 @@ const AddGuardrailForm: React.FC = ({ visible, onClose, a !isToolPermissionProvider && !shouldRenderContentFilterConfigSettings(selectedProvider) && !shouldRenderLLMJudgeFields(selectedProvider); + const providerLabels: Record = getGuardrailProviders(); + const providerKeys = Object.keys(providerLabels); + const supportedModes = getSupportedModesForProvider(guardrailSettings, selectedProvider) ?? DEFAULT_MODES; return ( - <> - + - - + {({ ref, value, ...field }) => ( + + )} + - - - + {({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => ( + providerLabels[key] ?? key} + value={asText(value) || null} + onValueChange={(key: string | null) => { + onChange(key ?? ""); + if (key) { + handleProviderChange(key); + } + }} + > + + + No matching providers + + {(key: string) => ( + + + + {providerLabels[key]} + + + )} + + + + )} + - - - + {({ id, value, onChange }) => ( + ({ + label: mode, + value: mode, + description: modeDescriptions[mode as keyof typeof modeDescriptions], + }))} + value={asStringArray(value)} + onValueChange={onChange} + placeholder="" + /> + )} + - - - + {({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => ( + + )} + - - - + {(fieldControl) => } + - - - + {(fieldControl) => } + {/* Use the GuardrailProviderFields component to render provider-specific fields */} {showProviderFields && ( )} - + ); }; @@ -857,7 +889,13 @@ const AddGuardrailForm: React.FC = ({ visible, onClose, a if (!providerFields || !providerFields.optional_params) return null; - return ; + return ( + + ); }; const renderStepContent = () => { @@ -872,7 +910,7 @@ const AddGuardrailForm: React.FC = ({ visible, onClose, a return renderContentFilterConfiguration("categories"); } if (shouldRenderLLMJudgeFields(selectedProvider)) { - return ; + return ; } return renderOptionalParams(); case 2: @@ -896,38 +934,48 @@ const AddGuardrailForm: React.FC = ({ visible, onClose, a return (
-

+

Configure settings for a specific call type. Most guardrails don't need this — skip it unless you're using a specific endpoint like /v1/realtime.

- + +

More call types coming soon.

{selectedEndpointType === "realtime" && ( -
+
{endpointSettingsOpen && ( -
+
- -

+ +

Automatically close the session after this many guardrail violations. Leave empty to never auto-close.

- = ({ visible, onClose, a onChange={(e) => setEndSessionAfterNFails(e.target.value ? parseInt(e.target.value, 10) : undefined) } - className="border border-gray-300 rounded-sm px-3 py-1.5 text-sm w-32" + className="w-32" />
- +
{(["warn", "end_session"] as const).map((opt) => (