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) => (