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.
This commit is contained in:
yuneng-jiang 2026-08-18 14:39:36 -07:00 • committed by GitHub
parent 56d926ac54
commit 3c82791968
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1897 additions and 819 deletions

View file

@ -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

View file

@ -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<string, unknown> {
criteria?: GuardrailCriterion[];
}
export type GuardrailFormControl = Control<GuardrailFormValues>;
export type GuardrailFieldRules = Pick<RegisterOptions<GuardrailFormValues, string>, "validate">;
export type GuardrailFieldControlProps = ControllerRenderProps<GuardrailFormValues, string> & {
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<string, unknown>)[key] : undefined;
export const labelWithHint = (label: React.ReactNode, hint: string): React.ReactNode => (
<>
{label}
<Tooltip>
<TooltipTrigger render={<CircleHelp className="size-3.5 shrink-0 cursor-help text-muted-foreground" />} />
<TooltipContent className="max-w-xs">{hint}</TooltipContent>
</Tooltip>
</>
);
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<GuardrailFieldProps> = ({
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 (
<Field data-invalid={invalid || undefined} className={className}>
{label !== undefined && <FieldLabel htmlFor={controlId}>{label}</FieldLabel>}
{children({ ...field, id: controlId, "aria-invalid": invalid || undefined, "aria-describedby": describedBy })}
{description !== undefined && <FieldDescription id={descriptionId}>{description}</FieldDescription>}
<FieldError id={errorId} errors={[fieldState.error]} />
</Field>
);
};
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 (
<Select items={SKIP_MESSAGE_ITEMS} value={asText(value) || null} onValueChange={onChange}>
<SelectTrigger id={id} aria-invalid={ariaInvalid} aria-describedby={ariaDescribedBy} className="w-full">
<SelectValue placeholder="Select an option" />
</SelectTrigger>
<SelectContent>
{SKIP_MESSAGE_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
};

View file

@ -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(
<AddGuardrailForm visible onClose={onClose} accessToken="test-token" onSuccess={onSuccess} preset={undefined} />,
);
return { onSuccess, onClose };
};
const pickProvider = async (user: ReturnType<typeof userEvent.setup>, 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,
},
});
});
});

View file

@ -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<GuardrailFormValues>, values: Record<string, unknown>) => {
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<AddGuardrailFormProps> = ({ visible, onClose, accessToken, onSuccess, preset }) => {
const [form] = Form.useForm();
const form = useForm<GuardrailFormValues>({ defaultValues: INITIAL_VALUES });
const [loading, setLoading] = useState(false);
const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
const [guardrailSettings, setGuardrailSettings] = useState<GuardrailSettings | null>(null);
@ -238,7 +284,7 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ 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<AddGuardrailFormProps> = ({ 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<AddGuardrailFormProps> = ({ 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<AddGuardrailFormProps> = ({ 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<AddGuardrailFormProps> = ({ visible, onClose, a
};
const resetForm = () => {
form.resetFields();
form.reset(INITIAL_VALUES);
setSelectedProvider(null);
setSelectedEntities([]);
setSelectedActions({});
@ -386,26 +423,28 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ 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<string, unknown>;
} = {
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<AddGuardrailFormProps> = ({ 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<AddGuardrailFormProps> = ({ 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<AddGuardrailFormProps> = ({ 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<AddGuardrailFormProps> = ({ 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<AddGuardrailFormProps> = ({ 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<AddGuardrailFormProps> = ({ 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<AddGuardrailFormProps> = ({ visible, onClose, a
!isToolPermissionProvider &&
!shouldRenderContentFilterConfigSettings(selectedProvider) &&
!shouldRenderLLMJudgeFields(selectedProvider);
const providerLabels: Record<string, string> = getGuardrailProviders();
const providerKeys = Object.keys(providerLabels);
const supportedModes = getSupportedModesForProvider(guardrailSettings, selectedProvider) ?? DEFAULT_MODES;
return (
<>
<Form.Item
<FieldGroup>
<GuardrailField
control={form.control}
name="guardrail_name"
label="Guardrail Name"
rules={[{ required: true, message: "Please enter a guardrail name" }]}
rules={requiredRule("Please enter a guardrail name")}
>
<Input placeholder="Enter a name for this guardrail" />
</Form.Item>
{({ ref, value, ...field }) => (
<Input {...field} ref={ref} value={asText(value)} placeholder="Enter a name for this guardrail" />
)}
</GuardrailField>
<Form.Item
<GuardrailField
control={form.control}
name="provider"
label="Guardrail Provider"
rules={[{ required: true, message: "Please select a provider" }]}
rules={requiredRule("Please select a provider")}
>
<Select
placeholder="Select a guardrail provider"
onChange={handleProviderChange}
labelInValue={false}
optionLabelProp="label"
dropdownRender={(menu) => menu}
showSearch={true}
>
{Object.entries(getGuardrailProviders()).map(([key, value]) => {
const optionContent = (
<div style={{ display: "flex", alignItems: "center" }}>
<Logo src={getGuardrailLogo(value)} label={value} className="h-5 w-5 mr-2 object-contain shrink-0" />
<span>{value}</span>
</div>
);
return (
<Option key={key} value={key} label={optionContent}>
{optionContent}
</Option>
);
})}
</Select>
</Form.Item>
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
<Combobox
items={providerKeys}
itemToStringLabel={(key: string) => providerLabels[key] ?? key}
value={asText(value) || null}
onValueChange={(key: string | null) => {
onChange(key ?? "");
if (key) {
handleProviderChange(key);
}
}}
>
<ComboboxInput
id={id}
aria-invalid={ariaInvalid}
aria-describedby={ariaDescribedBy}
placeholder="Select a guardrail provider"
className="w-full"
/>
<ComboboxContent>
<ComboboxEmpty>No matching providers</ComboboxEmpty>
<ComboboxList>
{(key: string) => (
<ComboboxItem key={key} value={key}>
<span className="flex items-center">
<Logo
src={getGuardrailLogo(providerLabels[key])}
label={providerLabels[key]}
className="mr-2 h-5 w-5 shrink-0 object-contain"
/>
<span>{providerLabels[key]}</span>
</span>
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
)}
</GuardrailField>
<Form.Item
<GuardrailField
control={form.control}
name="mode"
label="Mode"
tooltip="How the guardrail should be applied"
rules={[{ required: true, message: "Please select a mode" }]}
label={labelWithHint("Mode", "How the guardrail should be applied")}
rules={requiredRule("Please select a mode")}
>
<Select optionLabelProp="label" mode="multiple">
{getSupportedModesForProvider(guardrailSettings, selectedProvider)?.map((mode) => (
<Option key={mode} value={mode} label={mode}>
<div>
<div>
<strong>{mode}</strong>
{mode === "pre_call" && (
<Tag color="green" style={{ marginLeft: "8px" }}>
Recommended
</Tag>
)}
</div>
<div style={{ fontSize: "12px", color: "#888" }}>
{modeDescriptions[mode as keyof typeof modeDescriptions]}
</div>
</div>
</Option>
)) || (
<>
<Option value="pre_call" label="pre_call">
<div>
<div>
<strong>pre_call</strong> <Tag color="green">Recommended</Tag>
</div>
<div style={{ fontSize: "12px", color: "#888" }}>{modeDescriptions.pre_call}</div>
</div>
</Option>
<Option value="during_call" label="during_call">
<div>
<div>
<strong>during_call</strong>
</div>
<div style={{ fontSize: "12px", color: "#888" }}>{modeDescriptions.during_call}</div>
</div>
</Option>
<Option value="post_call" label="post_call">
<div>
<div>
<strong>post_call</strong>
</div>
<div style={{ fontSize: "12px", color: "#888" }}>{modeDescriptions.post_call}</div>
</div>
</Option>
<Option value="logging_only" label="logging_only">
<div>
<div>
<strong>logging_only</strong>
</div>
<div style={{ fontSize: "12px", color: "#888" }}>{modeDescriptions.logging_only}</div>
</div>
</Option>
</>
)}
</Select>
</Form.Item>
{({ id, value, onChange }) => (
<MultiSelect
id={id}
options={supportedModes.map((mode) => ({
label: mode,
value: mode,
description: modeDescriptions[mode as keyof typeof modeDescriptions],
}))}
value={asStringArray(value)}
onValueChange={onChange}
placeholder=""
/>
)}
</GuardrailField>
<Form.Item
<GuardrailField
control={form.control}
name="default_on"
label="Always On"
tooltip="If enabled, this guardrail will be applied to all requests by default."
label={labelWithHint("Always On", "If enabled, this guardrail will be applied to all requests by default.")}
>
<Select>
<Select.Option value={true}>Yes</Select.Option>
<Select.Option value={false}>No</Select.Option>
</Select>
</Form.Item>
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
<Select
items={ALWAYS_ON_ITEMS}
value={typeof value === "boolean" ? value : null}
onValueChange={(next: boolean | null) => onChange(next)}
>
<SelectTrigger id={id} aria-invalid={ariaInvalid} aria-describedby={ariaDescribedBy} className="w-full">
<SelectValue placeholder="Select an option" />
</SelectTrigger>
<SelectContent>
<SelectItem value={true}>Yes</SelectItem>
<SelectItem value={false}>No</SelectItem>
</SelectContent>
</Select>
)}
</GuardrailField>
<Form.Item
<GuardrailField
control={form.control}
name="skip_system_message_choice"
label="Skip system messages in guardrail"
tooltip="Unified guardrails only: omit role: system from guardrail evaluation input (OpenAI chat + Anthropic messages). The model still receives full messages. Use global default follows litellm_settings.skip_system_message_in_guardrail."
label={labelWithHint(
"Skip system messages in guardrail",
"Unified guardrails only: omit role: system from guardrail evaluation input (OpenAI chat + Anthropic messages). The model still receives full messages. Use global default follows litellm_settings.skip_system_message_in_guardrail.",
)}
>
<Select>
<Select.Option value="inherit">Use global default</Select.Option>
<Select.Option value="yes">Yes — exclude from guardrail scan</Select.Option>
<Select.Option value="no">No — always include in scan</Select.Option>
</Select>
</Form.Item>
{(fieldControl) => <SkipMessageSelect control={fieldControl} />}
</GuardrailField>
<Form.Item
<GuardrailField
control={form.control}
name="skip_tool_message_choice"
label="Skip tool messages in guardrail"
tooltip="Unified guardrails only: omit role: tool from guardrail evaluation input (OpenAI chat + Anthropic messages). The model still receives full messages. Use global default follows litellm_settings.skip_tool_message_in_guardrail."
label={labelWithHint(
"Skip tool messages in guardrail",
"Unified guardrails only: omit role: tool from guardrail evaluation input (OpenAI chat + Anthropic messages). The model still receives full messages. Use global default follows litellm_settings.skip_tool_message_in_guardrail.",
)}
>
<Select>
<Select.Option value="inherit">Use global default</Select.Option>
<Select.Option value="yes">Yes — exclude from guardrail scan</Select.Option>
<Select.Option value="no">No — always include in scan</Select.Option>
</Select>
</Form.Item>
{(fieldControl) => <SkipMessageSelect control={fieldControl} />}
</GuardrailField>
{/* Use the GuardrailProviderFields component to render provider-specific fields */}
{showProviderFields && (
<GuardrailProviderFields
selectedProvider={selectedProvider}
control={form.control}
accessToken={accessToken}
providerParams={providerParams}
/>
)}
</>
</FieldGroup>
);
};
@ -857,7 +889,13 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
if (!providerFields || !providerFields.optional_params) return null;
return <GuardrailOptionalParams optionalParams={providerFields.optional_params} parentFieldKey="optional_params" />;
return (
<GuardrailOptionalParams
optionalParams={providerFields.optional_params}
parentFieldKey="optional_params"
control={form.control}
/>
);
};
const renderStepContent = () => {
@ -872,7 +910,7 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
return renderContentFilterConfiguration("categories");
}
if (shouldRenderLLMJudgeFields(selectedProvider)) {
return <LLMJudgeFields availableModels={availableModels} form={form} />;
return <LLMJudgeFields availableModels={availableModels} control={form.control} />;
}
return renderOptionalParams();
case 2:
@ -896,38 +934,48 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
return (
<div className="space-y-6">
<div>
<p className="text-sm text-gray-500">
<p className="text-sm text-muted-foreground">
Configure settings for a specific call type. Most guardrails don't need this — skip it unless you're using a
specific endpoint like <code>/v1/realtime</code>.
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Call type</label>
<label htmlFor="guardrail-call-type" className="mb-1 block text-sm font-medium text-foreground">
Call type
</label>
<Select
placeholder="Select a call type"
value={selectedEndpointType || undefined}
onChange={(v) => {
setSelectedEndpointType(v);
items={CALL_TYPE_ITEMS}
value={selectedEndpointType || null}
onValueChange={(next: string | null) => {
setSelectedEndpointType(next ?? "");
setEndpointSettingsOpen(false);
}}
style={{ width: 260 }}
allowClear
options={[{ value: "realtime", label: "/v1/realtime" }]}
/>
<p className="text-xs text-gray-400 mt-1">More call types coming soon.</p>
>
<SelectTrigger id="guardrail-call-type" className="w-65">
<SelectValue placeholder="Select a call type" />
</SelectTrigger>
<SelectContent>
{CALL_TYPE_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-xs text-muted-foreground">More call types coming soon.</p>
</div>
{selectedEndpointType === "realtime" && (
<div className="border border-gray-200 rounded-lg overflow-hidden">
<div className="overflow-hidden rounded-lg border border-border">
<button
type="button"
onClick={() => setEndpointSettingsOpen((o) => !o)}
className="w-full flex items-center justify-between px-4 py-3 bg-gray-50 hover:bg-gray-100 text-sm font-medium text-gray-700"
className="flex w-full items-center justify-between bg-muted px-4 py-3 text-sm font-medium text-foreground hover:bg-muted/70"
>
<span>/v1/realtime settings</span>
<svg
className={`w-4 h-4 text-gray-500 transition-transform ${endpointSettingsOpen ? "rotate-180" : ""}`}
className={`w-4 h-4 text-muted-foreground transition-transform ${endpointSettingsOpen ? "rotate-180" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@ -938,14 +986,20 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
</button>
{endpointSettingsOpen && (
<div className="space-y-5 px-4 py-4 border-t border-gray-200">
<div className="space-y-5 border-t border-border px-4 py-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">End session after X violations</label>
<p className="text-xs text-gray-400 mb-2">
<label
htmlFor="guardrail-end-session-after"
className="mb-1 block text-sm font-medium text-foreground"
>
End session after X violations
</label>
<p className="mb-2 text-xs text-muted-foreground">
Automatically close the session after this many guardrail violations. Leave empty to never
auto-close.
</p>
<input
<Input
id="guardrail-end-session-after"
type="number"
min={1}
placeholder="e.g. 3"
@ -953,12 +1007,12 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">On violation</label>
<label className="mb-2 block text-sm font-medium text-foreground">On violation</label>
<div className="space-y-2">
{(["warn", "end_session"] as const).map((opt) => (
<label key={opt} className="flex items-start gap-2 cursor-pointer">
@ -971,10 +1025,10 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
className="mt-0.5"
/>
<div>
<span className="text-sm font-medium text-gray-800">
<span className="text-sm font-medium text-foreground">
{opt === "warn" ? "Warn" : "End session"}
</span>
<p className="text-xs text-gray-400 m-0">
<p className="m-0 text-xs text-muted-foreground">
{opt === "warn"
? "Bot speaks the message, session continues"
: "Bot speaks the message, connection closes immediately"}
@ -986,17 +1040,23 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Message the user hears</label>
<p className="text-xs text-gray-400 mb-2">
<label
htmlFor="guardrail-realtime-message"
className="mb-1 block text-sm font-medium text-foreground"
>
Message the user hears
</label>
<p className="mb-2 text-xs text-muted-foreground">
What the bot says aloud when this guardrail fires. Falls back to the default violation message if
empty.
</p>
<textarea
<Textarea
id="guardrail-realtime-message"
rows={3}
placeholder="e.g. I'm not able to continue this conversation. Please contact us at 1-800-774-2678."
value={realtimeViolationMessage}
onChange={(e) => setRealtimeViolationMessage(e.target.value)}
className="border border-gray-300 rounded-sm px-3 py-2 text-sm w-full resize-none"
className="w-full resize-none"
/>
</div>
</div>
@ -1045,103 +1105,93 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
body: { padding: 0 },
}}
>
<div className="flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h3 className="text-base font-semibold text-gray-900 m-0">Create guardrail</h3>
<button
onClick={handleClose}
className="text-gray-400 hover:text-gray-600 bg-transparent border-none cursor-pointer text-base leading-none p-1"
>
&#x2715;
</button>
</div>
<TooltipProvider>
<div className="flex flex-col">
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-6 py-4">
<h3 className="m-0 text-base font-semibold text-foreground">Create guardrail</h3>
<button
type="button"
onClick={handleClose}
className="cursor-pointer border-none bg-transparent p-1 text-base leading-none text-muted-foreground hover:text-foreground"
>
&#x2715;
</button>
</div>
{/* Scrollable content - inline vertical stepper */}
<div className="overflow-auto px-6 py-4" style={{ maxHeight: "calc(80vh - 120px)" }}>
<Form
form={form}
layout="vertical"
initialValues={{
mode: "pre_call",
default_on: false,
skip_system_message_choice: "inherit",
skip_tool_message_choice: "inherit",
}}
>
{stepConfigs.map((step, index) => {
const isDone = index < currentStep;
const isCurrent = index === currentStep;
const isLast = index === stepConfigs.length - 1;
return (
<div key={index} className="relative flex gap-4" style={{ paddingBottom: isLast ? 0 : 8 }}>
{/* Vertical line + step indicator */}
<div className="flex flex-col items-center shrink-0" style={{ width: 24 }}>
<div
className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium shrink-0"
style={getStepIndicatorStyle(isDone, isCurrent)}
>
{isDone ? "\u2713" : index + 1}
</div>
{!isLast && (
{/* Scrollable content - inline vertical stepper */}
<div className="max-h-[calc(80vh-120px)] overflow-auto px-6 py-4">
<form onSubmit={(event) => event.preventDefault()}>
{stepConfigs.map((step, index) => {
const isDone = index < currentStep;
const isCurrent = index === currentStep;
const isLast = index === stepConfigs.length - 1;
return (
<div key={index} className={`relative flex gap-4 ${isLast ? "" : "pb-2"}`}>
{/* Vertical line + step indicator */}
<div className="flex w-6 shrink-0 flex-col items-center">
<div
className="flex-1"
style={{
width: 1,
background: isDone ? "#4f46e5" : "#e2e8f0",
minHeight: 16,
}}
/>
)}
</div>
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-medium ${getStepIndicatorClass(isDone, isCurrent)}`}
>
{isDone ? "\u2713" : index + 1}
</div>
{!isLast && (
<div
className={`min-h-4 w-px flex-1 ${isDone ? "bg-indigo-600 dark:bg-indigo-500" : "bg-border"}`}
/>
)}
</div>
{/* Step content */}
<div className="flex-1 min-w-0" style={{ paddingBottom: isLast ? 0 : 16 }}>
{/* Step header - clickable for completed steps */}
<div
className={`flex items-center gap-2 ${isDone ? "cursor-pointer" : ""}`}
onClick={() => {
if (isDone) setCurrentStep(index);
}}
style={{ minHeight: 24 }}
>
<span
className="text-sm"
style={{
fontWeight: isCurrent ? 600 : 500,
color: getStepTitleColor(isDone, isCurrent),
{/* Step content */}
<div className={`min-w-0 flex-1 ${isLast ? "" : "pb-4"}`}>
{/* Step header - clickable for completed steps */}
<div
className={`flex min-h-6 items-center gap-2 ${isDone ? "cursor-pointer" : ""}`}
onClick={() => {
if (isDone) setCurrentStep(index);
}}
>
{step.title}
</span>
{step.optional && !isCurrent && <span className="text-[11px] text-slate-400">optional</span>}
{isDone && <span className="text-[11px] text-indigo-500 hover:underline">Edit</span>}
<span className={`text-sm ${getStepTitleClass(isDone, isCurrent)}`}>{step.title}</span>
{step.optional && !isCurrent && (
<span className="text-[11px] text-muted-foreground">optional</span>
)}
{isDone && (
<span className="text-[11px] text-indigo-600 hover:underline dark:text-indigo-400">Edit</span>
)}
</div>
{/* Expanded form content for current step */}
{isCurrent && <div className="mt-3">{renderStepContent()}</div>}
</div>
{/* Expanded form content for current step */}
{isCurrent && <div className="mt-3">{renderStepContent()}</div>}
</div>
</div>
);
})}
</Form>
</div>
);
})}
</form>
</div>
{/* Bottom bar */}
<div className="flex items-center justify-end space-x-3 px-6 py-3 border-t border-gray-200">
<Button onClick={handleClose}>Cancel</Button>
{currentStep > 0 && <Button onClick={prevStep}>Previous</Button>}
{currentStep < stepConfigs.length - 1 ? (
<Button type="primary" onClick={nextStep}>
Next
{/* Bottom bar */}
<div className="flex items-center justify-end space-x-3 border-t border-border px-6 py-3">
<Button type="button" variant="outline" onClick={handleClose}>
Cancel
</Button>
) : (
<Button type="primary" onClick={handleSubmit} loading={loading}>
Create Guardrail
</Button>
)}
{currentStep > 0 && (
<Button type="button" variant="outline" onClick={prevStep}>
Previous
</Button>
)}
{currentStep < stepConfigs.length - 1 ? (
<Button type="button" onClick={nextStep}>
Next
</Button>
) : (
<Button type="button" onClick={handleSubmit} disabled={loading}>
{loading && <UiLoadingSpinner className="size-4" />}
Create Guardrail
</Button>
)}
</div>
</div>
</div>
</TooltipProvider>
</Modal>
);
};

View file

@ -0,0 +1,289 @@
import * as networking from "@/components/networking";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import GuardrailInfoView from "./guardrail_info";
vi.mock("@/components/networking", () => ({
getGuardrailInfo: vi.fn(),
getGuardrailUISettings: vi.fn(),
getGuardrailProviderSpecificParams: vi.fn(),
updateGuardrailCall: vi.fn(),
}));
vi.mock("./content_filter/ContentFilterManager", () => ({
__esModule: true,
default: ({ isEditing }: { isEditing: boolean }) => (
<div data-testid="mock-content-filter-manager">{isEditing && <button>Stray Action</button>}</div>
),
formatContentFilterDataForAPI: () => ({ patterns: [], blocked_words: [], categories: [] }),
}));
const uiSettings = {
supported_entities: [],
supported_actions: [],
pii_entity_categories: [],
supported_modes: ["pre_call", "post_call"],
};
const bedrockParams = {
bedrock: {
guardrailIdentifier: { description: "The guardrail id on Bedrock", required: true, type: null },
api_key: { description: "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" },
},
},
},
};
const numericProviderParams = {
bedrock: {
guardrailIdentifier: { description: "The guardrail id on Bedrock", required: true, type: null },
max_tokens: { description: "Token ceiling", required: false, type: "number" },
},
};
const nestedProviderParams = {
bedrock: {
guardrailIdentifier: { description: "The guardrail id on Bedrock", required: true, type: null },
tuning: {
description: "Nested tuning block",
required: false,
type: "nested",
fields: {
retries: { description: "How many retries", required: false, type: null },
},
},
},
};
const guardrail = (litellmParams: Record<string, unknown>, guardrailInfo?: Record<string, unknown>) => ({
guardrail_id: "123",
guardrail_name: "Test Guardrail",
litellm_params: { guardrail: "bedrock", mode: "pre_call", default_on: true, ...litellmParams },
guardrail_info: guardrailInfo,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
guardrail_definition_location: "database",
});
const openEditor = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(await screen.findByText("Settings"));
await user.click(await screen.findByText("Edit Settings"));
await screen.findByLabelText("Guardrail Name");
};
const saveChanges = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(screen.getByText("Save Changes"));
};
const renderView = () => render(<GuardrailInfoView guardrailId="123" onClose={() => {}} accessToken="123" isAdmin />);
const lastPayload = () => vi.mocked(networking.updateGuardrailCall).mock.calls.at(-1)?.[2];
describe("GuardrailInfoView update payload characterization", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(networking.getGuardrailUISettings).mockResolvedValue(uiSettings);
vi.mocked(networking.getGuardrailProviderSpecificParams).mockResolvedValue(bedrockParams);
vi.mocked(networking.updateGuardrailCall).mockResolvedValue({ status: "success" });
vi.mocked(networking.getGuardrailInfo).mockResolvedValue(
guardrail({ guardrailIdentifier: "gr-abc", api_key: "sk-old" }),
);
});
it("sends nothing at all when the user saves without touching a field", async () => {
const user = userEvent.setup({ delay: null });
renderView();
await openEditor(user);
await saveChanges(user);
await screen.findByText("Edit Settings");
expect(networking.updateGuardrailCall).not.toHaveBeenCalled();
});
it("sends only guardrail_name and drops the empty litellm_params object", async () => {
const user = userEvent.setup({ delay: null });
renderView();
await openEditor(user);
const nameInput = screen.getByLabelText("Guardrail Name");
await user.clear(nameInput);
await user.type(nameInput, "Renamed Guardrail");
await saveChanges(user);
await waitFor(() => expect(networking.updateGuardrailCall).toHaveBeenCalledTimes(1));
expect(lastPayload()).toEqual({ guardrail_name: "Renamed Guardrail" });
});
it("never leaks the seeded guardrail, mode or created_at keys into litellm_params", async () => {
const user = userEvent.setup({ delay: null });
renderView();
await openEditor(user);
const identifier = screen.getByLabelText("guardrailIdentifier");
await user.clear(identifier);
await user.type(identifier, "gr-new");
await saveChanges(user);
await waitFor(() => expect(networking.updateGuardrailCall).toHaveBeenCalledTimes(1));
expect(lastPayload()).toEqual({ litellm_params: { guardrailIdentifier: "gr-new" } });
});
it("sends null for a provider param the user cleared", async () => {
const user = userEvent.setup({ delay: null });
renderView();
await openEditor(user);
await user.clear(screen.getByLabelText("api_key"));
await saveChanges(user);
await waitFor(() => expect(networking.updateGuardrailCall).toHaveBeenCalledTimes(1));
expect(lastPayload()).toEqual({ litellm_params: { api_key: null } });
});
it("maps the skip system message choice to an explicit boolean", async () => {
const user = userEvent.setup({ delay: null });
renderView();
await openEditor(user);
await user.click(screen.getByLabelText("Skip system messages in guardrail"));
await user.click(await screen.findByText("Yes — exclude from guardrail scan"));
await saveChanges(user);
await waitFor(() => expect(networking.updateGuardrailCall).toHaveBeenCalledTimes(1));
expect(lastPayload()).toEqual({ litellm_params: { skip_system_message_in_guardrail: true } });
});
it("parses the guardrail information textarea into an object", async () => {
const user = userEvent.setup({ delay: null });
renderView();
await openEditor(user);
const infoBox = screen.getByLabelText("Guardrail Information");
await user.clear(infoBox);
await user.type(infoBox, '{{"team":"platform"}');
await saveChanges(user);
await waitFor(() => expect(networking.updateGuardrailCall).toHaveBeenCalledTimes(1));
expect(lastPayload()).toEqual({ guardrail_info: { team: "platform" } });
});
it("reads a value typed into the optional params section out of the nested optional_params object", async () => {
const user = userEvent.setup({ delay: null });
renderView();
await openEditor(user);
const threshold = screen.getByPlaceholderText("Severity threshold");
await user.type(threshold, "4");
await saveChanges(user);
await waitFor(() => expect(networking.updateGuardrailCall).toHaveBeenCalledTimes(1));
expect(lastPayload()).toEqual({ litellm_params: { severity_threshold: 4 } });
});
it("keeps a dotted nested provider field out of the payload entirely", async () => {
vi.mocked(networking.getGuardrailProviderSpecificParams).mockResolvedValue(nestedProviderParams);
const user = userEvent.setup({ delay: null });
renderView();
await openEditor(user);
await user.type(screen.getByLabelText("retries"), "7");
const identifier = screen.getByLabelText("guardrailIdentifier");
await user.clear(identifier);
await user.type(identifier, "gr-new");
await saveChanges(user);
await waitFor(() => expect(networking.updateGuardrailCall).toHaveBeenCalledTimes(1));
expect(lastPayload()).toEqual({ litellm_params: { guardrailIdentifier: "gr-new" } });
});
it("clears a stored nested param to null because no form field ever binds it", async () => {
vi.mocked(networking.getGuardrailProviderSpecificParams).mockResolvedValue(nestedProviderParams);
vi.mocked(networking.getGuardrailInfo).mockResolvedValue(
guardrail({ guardrailIdentifier: "gr-abc", tuning: { retries: 2 } }),
);
const user = userEvent.setup({ delay: null });
renderView();
await openEditor(user);
const identifier = screen.getByLabelText("guardrailIdentifier");
await user.clear(identifier);
await user.type(identifier, "gr-new");
await saveChanges(user);
await waitFor(() => expect(networking.updateGuardrailCall).toHaveBeenCalledTimes(1));
expect(lastPayload()).toEqual({ litellm_params: { guardrailIdentifier: "gr-new", tuning: null } });
});
it("sends an unnormalised numeric provider field as the raw string the input produced", async () => {
vi.mocked(networking.getGuardrailProviderSpecificParams).mockResolvedValue(numericProviderParams);
const user = userEvent.setup({ delay: null });
renderView();
await openEditor(user);
await user.type(screen.getByPlaceholderText("Token ceiling"), "5");
await saveChanges(user);
await waitFor(() => expect(networking.updateGuardrailCall).toHaveBeenCalledTimes(1));
expect(lastPayload()).toEqual({ litellm_params: { max_tokens: "5" } });
});
it("blocks the save when the required guardrail name is cleared", async () => {
const user = userEvent.setup({ delay: null });
renderView();
await openEditor(user);
await user.clear(screen.getByLabelText("Guardrail Name"));
await saveChanges(user);
await screen.findByText("Please input a guardrail name");
expect(networking.updateGuardrailCall).not.toHaveBeenCalled();
});
it("saves when the user presses Enter in a text field", async () => {
const user = userEvent.setup({ delay: null });
renderView();
await openEditor(user);
const nameInput = screen.getByLabelText("Guardrail Name");
await user.clear(nameInput);
await user.type(nameInput, "Renamed Guardrail{Enter}");
await waitFor(() => expect(networking.updateGuardrailCall).toHaveBeenCalledTimes(1));
expect(lastPayload()).toEqual({ guardrail_name: "Renamed Guardrail" });
});
it("also saves when any other button inside the form is clicked", async () => {
const user = userEvent.setup({ delay: null });
renderView();
await openEditor(user);
const nameInput = screen.getByLabelText("Guardrail Name");
await user.clear(nameInput);
await user.type(nameInput, "Renamed Guardrail");
await user.click(screen.getByText("Stray Action"));
await waitFor(() => expect(networking.updateGuardrailCall).toHaveBeenCalledTimes(1));
expect(lastPayload()).toEqual({ guardrail_name: "Renamed Guardrail" });
});
it("keeps edits made before Cancel when the editor is reopened", async () => {
const user = userEvent.setup({ delay: null });
renderView();
await openEditor(user);
const nameInput = screen.getByLabelText("Guardrail Name");
await user.clear(nameInput);
await user.type(nameInput, "Draft Name");
await user.click(screen.getByText("Cancel"));
await user.click(await screen.findByText("Edit Settings"));
expect(await screen.findByLabelText("Guardrail Name")).toHaveValue("Draft Name");
});
});

View file

@ -8,11 +8,28 @@ import { copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils";
import { CodeOutlined, EyeInvisibleOutlined, InfoCircleOutlined, StopOutlined } from "@ant-design/icons";
import { ArrowLeftIcon } from "@heroicons/react/outline";
import { Badge, Card, Grid, Tab, TabGroup, TabList, TabPanel, TabPanels, Text, Title } from "@tremor/react";
import { Button, Divider, Form, Input, Select, Tooltip } from "antd";
import { Button as AntdButton, Tooltip } from "antd";
import { CheckIcon, CopyIcon } from "lucide-react";
import React, { useCallback, useEffect, useState } from "react";
import React, { useCallback, useEffect, useLayoutEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "@/lib/toast";
import { Logo } from "@/components/molecules/logo/Logo";
import { FieldGroup } from "@/components/shared/form/field";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Textarea } from "@/components/ui/textarea";
import { TooltipProvider } from "@/components/ui/tooltip";
import {
asText,
GuardrailField,
labelWithHint,
readRecord,
requiredRule,
type GuardrailFormValues,
SkipMessageSelect,
} from "./GuardrailFormField";
import ContentFilterManager, { formatContentFilterDataForAPI } from "./content_filter/ContentFilterManager";
import CustomCodeModal, { EditGuardrailData } from "./custom_code/CustomCodeModal";
import {
@ -28,6 +45,18 @@ import GuardrailProviderFields from "./guardrail_provider_fields";
import PiiConfiguration from "./pii_configuration";
import ToolPermissionRulesEditor, { ToolPermissionConfig } from "./tool_permission/ToolPermissionRulesEditor";
const DEFAULT_ON_ITEMS = [
{ label: "Yes", value: true },
{ label: "No", value: false },
];
const SectionHeading: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<div className="my-6 flex items-center gap-3">
<span className="shrink-0 text-sm font-medium text-foreground">{children}</span>
<Separator className="flex-1" />
</div>
);
export interface GuardrailInfoProps {
guardrailId: string;
onClose: () => void;
@ -40,7 +69,7 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
const [guardrailProviderSpecificParams, setGuardrailProviderSpecificParams] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [isEditing, setIsEditing] = useState(false);
const [form] = Form.useForm();
const form = useForm<GuardrailFormValues>({ defaultValues: {} });
const [selectedPiiEntities, setSelectedPiiEntities] = useState<string[]>([]);
const [selectedPiiActions, setSelectedPiiActions] = useState<{ [key: string]: string }>({});
const [guardrailSettings, setGuardrailSettings] = useState<{
@ -183,25 +212,26 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
fetchGuardrailUISettings();
}, [guardrailId, accessToken]);
// Reset form when guardrail data or provider params change
// Reset form when guardrail data or provider params change. Only the names this form actually
// binds are seeded: an unbound key would otherwise be submitted as if the user had set it.
useEffect(() => {
if (guardrailData && form) {
const lp = { ...(guardrailData.litellm_params || {}) };
delete lp.skip_system_message_in_guardrail;
delete lp.skip_tool_message_in_guardrail;
form.setFieldsValue({
guardrail_name: guardrailData.guardrail_name,
...lp,
skip_system_message_choice: skipSystemMessageToChoice(
guardrailData.litellm_params?.skip_system_message_in_guardrail,
),
skip_tool_message_choice: skipToolMessageToChoice(guardrailData.litellm_params?.skip_tool_message_in_guardrail),
guardrail_info: guardrailData.guardrail_info ? JSON.stringify(guardrailData.guardrail_info, null, 2) : "",
// Include any optional_params if they exist
...(guardrailData.litellm_params?.optional_params && {
optional_params: guardrailData.litellm_params.optional_params,
}),
});
if (!guardrailData) return;
form.setValue("guardrail_name", guardrailData.guardrail_name);
form.setValue("default_on", guardrailData.litellm_params?.default_on);
form.setValue(
"skip_system_message_choice",
skipSystemMessageToChoice(guardrailData.litellm_params?.skip_system_message_in_guardrail),
);
form.setValue(
"skip_tool_message_choice",
skipToolMessageToChoice(guardrailData.litellm_params?.skip_tool_message_in_guardrail),
);
form.setValue(
"guardrail_info",
guardrailData.guardrail_info ? JSON.stringify(guardrailData.guardrail_info, null, 2) : "",
);
if (guardrailData.litellm_params?.optional_params) {
form.setValue("optional_params", guardrailData.litellm_params.optional_params);
}
}, [guardrailData, guardrailProviderSpecificParams, form]);
@ -245,7 +275,7 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
}));
};
const handleGuardrailUpdate = async (values: any) => {
const handleGuardrailUpdate = async (values: GuardrailFormValues) => {
try {
if (!accessToken) return;
@ -290,7 +320,7 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
// Only include guardrail_info if it has changed
const originalGuardrailInfo = guardrailData.guardrail_info;
const newGuardrailInfo = values.guardrail_info ? JSON.parse(values.guardrail_info) : undefined;
const newGuardrailInfo = values.guardrail_info ? JSON.parse(asText(values.guardrail_info)) : undefined;
if (JSON.stringify(originalGuardrailInfo) !== JSON.stringify(newGuardrailInfo)) {
updateData.guardrail_info = newGuardrailInfo;
}
@ -390,10 +420,11 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
return;
}
// 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;
// Get the original value for comparison
const originalValue = guardrailData.litellm_params?.[paramName];
@ -437,6 +468,14 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
}
};
// antd re-read onFinish at validation-resolution time, so a submit fired by the same click that
// updated state saw that state; a captured handler would not.
const submitRef = React.useRef(handleGuardrailUpdate);
useLayoutEffect(() => {
submitRef.current = handleGuardrailUpdate;
});
const submitLatest = useCallback((values: GuardrailFormValues) => submitRef.current(values), []);
if (loading) {
return <div className="p-4">Loading...</div>;
}
@ -470,22 +509,22 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
return (
<div className="p-4">
<div>
<Button type="text" icon={<ArrowLeftIcon className="w-4 h-4" />} onClick={onClose} className="mb-4">
<AntdButton type="text" icon={<ArrowLeftIcon className="w-4 h-4" />} onClick={onClose} className="mb-4">
Back to Guardrails
</Button>
</AntdButton>
<Title>{guardrailData.guardrail_name || "Unnamed Guardrail"}</Title>
<div className="flex items-center cursor-pointer">
<Text className="text-gray-500 font-mono">{guardrailData.guardrail_id}</Text>
<Text className="text-muted-foreground font-mono">{guardrailData.guardrail_id}</Text>
<Button
<AntdButton
type="text"
size="small"
icon={copiedStates["guardrail-id"] ? <CheckIcon size={12} /> : <CopyIcon size={12} />}
onClick={() => copyToClipboard(guardrailData.guardrail_id, "guardrail-id")}
className={`left-2 z-10 transition-all duration-200 ${
copiedStates["guardrail-id"]
? "text-green-600 bg-green-50 border-green-200"
: "text-gray-500 hover:text-gray-700 hover:bg-gray-100"
? "text-green-600 bg-green-50 border-green-200 dark:text-green-400 dark:bg-green-950/40 dark:border-green-900"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
}`}
/>
</div>
@ -545,14 +584,14 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
<Card className="mt-6">
<Text className="mb-4 text-lg font-semibold">PII Entity Configuration</Text>
<div className="border rounded-lg overflow-hidden shadow-xs">
<div className="bg-gray-50 px-5 py-3 border-b flex">
<Text className="flex-1 font-semibold text-gray-700">Entity Type</Text>
<Text className="flex-1 font-semibold text-gray-700">Configuration</Text>
<div className="bg-muted px-5 py-3 border-b flex">
<Text className="flex-1 font-semibold text-foreground">Entity Type</Text>
<Text className="flex-1 font-semibold text-foreground">Configuration</Text>
</div>
<div className="max-h-[400px] overflow-y-auto">
{Object.entries(guardrailData.litellm_params?.pii_entities_config).map(([key, value]) => (
<div key={key} className="px-5 py-3 flex border-b hover:bg-gray-50 transition-colors">
<Text className="flex-1 font-medium text-gray-900">{key}</Text>
<div key={key} className="px-5 py-3 flex border-b hover:bg-muted/50 transition-colors">
<Text className="flex-1 font-medium text-foreground">{key}</Text>
<Text className="flex-1">
<span
className={`inline-flex items-center gap-1.5 ${
@ -585,9 +624,9 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
<Text className="font-medium text-lg">Custom Code</Text>
</div>
{isAdmin && !isConfigGuardrail && (
<Button size="small" icon={<CodeOutlined />} onClick={() => setCustomCodeModalVisible(true)}>
<AntdButton size="small" icon={<CodeOutlined />} onClick={() => setCustomCodeModalVisible(true)}>
Edit Code
</Button>
</AntdButton>
)}
</div>
<div className="relative rounded-lg overflow-hidden border border-gray-700 bg-[#1e1e1e]">
@ -624,172 +663,171 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
{!isEditing &&
!isConfigGuardrail &&
(guardrailData.litellm_params?.guardrail === "custom_code" ? (
<Button icon={<CodeOutlined />} onClick={() => setCustomCodeModalVisible(true)}>
<AntdButton icon={<CodeOutlined />} onClick={() => setCustomCodeModalVisible(true)}>
Edit Code
</Button>
</AntdButton>
) : (
<Button onClick={() => setIsEditing(true)}>Edit Settings</Button>
<AntdButton onClick={() => setIsEditing(true)}>Edit Settings</AntdButton>
))}
</div>
{isEditing ? (
<Form
form={form}
onFinish={handleGuardrailUpdate}
initialValues={{
guardrail_name: guardrailData.guardrail_name,
...(() => {
const lp = { ...(guardrailData.litellm_params || {}) };
delete lp.skip_system_message_in_guardrail;
delete lp.skip_tool_message_in_guardrail;
return lp;
})(),
skip_system_message_choice: skipSystemMessageToChoice(
guardrailData.litellm_params?.skip_system_message_in_guardrail,
),
skip_tool_message_choice: skipToolMessageToChoice(
guardrailData.litellm_params?.skip_tool_message_in_guardrail,
),
guardrail_info: guardrailData.guardrail_info
? JSON.stringify(guardrailData.guardrail_info, null, 2)
: "",
// Include any optional_params if they exist
...(guardrailData.litellm_params?.optional_params && {
optional_params: guardrailData.litellm_params.optional_params,
}),
}}
layout="vertical"
>
<Form.Item
label="Guardrail Name"
name="guardrail_name"
rules={[{ required: true, message: "Please input a guardrail name" }]}
>
<Input placeholder="Enter guardrail name" />
</Form.Item>
<Form.Item label="Default On" name="default_on">
<Select>
<Select.Option value={true}>Yes</Select.Option>
<Select.Option value={false}>No</Select.Option>
</Select>
</Form.Item>
<Form.Item
label="Skip system messages in guardrail"
name="skip_system_message_choice"
tooltip="Unified guardrails: omit role: system from guardrail input (LLM still gets full messages). Use global default follows litellm_settings.skip_system_message_in_guardrail."
>
<Select>
<Select.Option value="inherit">Use global default</Select.Option>
<Select.Option value="yes">Yes — exclude from guardrail scan</Select.Option>
<Select.Option value="no">No — always include in scan</Select.Option>
</Select>
</Form.Item>
<Form.Item
label="Skip tool messages in guardrail"
name="skip_tool_message_choice"
tooltip="Unified guardrails: omit role: tool from guardrail input (LLM still gets full messages). Use global default follows litellm_settings.skip_tool_message_in_guardrail."
>
<Select>
<Select.Option value="inherit">Use global default</Select.Option>
<Select.Option value="yes">Yes — exclude from guardrail scan</Select.Option>
<Select.Option value="no">No — always include in scan</Select.Option>
</Select>
</Form.Item>
{guardrailData.litellm_params?.guardrail === "presidio" && (
<>
<Divider orientation="left">PII Protection</Divider>
<div className="mb-6">
{guardrailSettings && (
<PiiConfiguration
entities={guardrailSettings.supported_entities}
actions={guardrailSettings.supported_actions}
selectedEntities={selectedPiiEntities}
selectedActions={selectedPiiActions}
onEntitySelect={handlePiiEntitySelect}
onActionSelect={handlePiiActionSelect}
entityCategories={guardrailSettings.pii_entity_categories}
/>
<TooltipProvider>
{/* eslint-disable-next-line react-hooks/refs -- latest-handler ref, read only after validation resolves */}
<form onSubmit={form.handleSubmit(submitLatest)}>
<FieldGroup>
<GuardrailField
control={form.control}
name="guardrail_name"
label="Guardrail Name"
rules={requiredRule("Please input a guardrail name")}
>
{({ ref, value, ...field }) => (
<Input {...field} ref={ref} value={asText(value)} placeholder="Enter guardrail name" />
)}
</div>
</>
)}
</GuardrailField>
<ContentFilterManager
guardrailData={guardrailData}
guardrailSettings={guardrailSettings}
isEditing={true}
accessToken={accessToken}
onDataChange={handleContentFilterDataChange}
onUnsavedChanges={setHasUnsavedContentFilterChanges}
/>
<GuardrailField control={form.control} name="default_on" label="Default On">
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": describedBy }) => (
<Select
items={DEFAULT_ON_ITEMS}
value={typeof value === "boolean" ? value : null}
onValueChange={(next: boolean | null) => onChange(next)}
>
<SelectTrigger
id={id}
aria-invalid={ariaInvalid}
aria-describedby={describedBy}
className="w-full"
>
<SelectValue placeholder="Select an option" />
</SelectTrigger>
<SelectContent>
<SelectItem value={true}>Yes</SelectItem>
<SelectItem value={false}>No</SelectItem>
</SelectContent>
</Select>
)}
</GuardrailField>
{(guardrailData.litellm_params?.guardrail === "tool_permission" ||
guardrailProviderSpecificParams) && <Divider orientation="left">Provider Settings</Divider>}
<GuardrailField
control={form.control}
name="skip_system_message_choice"
label={labelWithHint(
"Skip system messages in guardrail",
"Unified guardrails: omit role: system from guardrail input (LLM still gets full messages). Use global default follows litellm_settings.skip_system_message_in_guardrail.",
)}
>
{(fieldControl) => <SkipMessageSelect control={fieldControl} />}
</GuardrailField>
{guardrailData.litellm_params?.guardrail === "tool_permission" ? (
<ToolPermissionRulesEditor value={toolPermissionConfig} onChange={setToolPermissionConfig} />
) : (
<>
{/* Provider-specific fields */}
<GuardrailProviderFields
selectedProvider={
Object.keys(guardrail_provider_map).find(
(key) => guardrail_provider_map[key] === guardrailData.litellm_params?.guardrail,
) || null
}
<GuardrailField
control={form.control}
name="skip_tool_message_choice"
label={labelWithHint(
"Skip tool messages in guardrail",
"Unified guardrails: omit role: tool from guardrail input (LLM still gets full messages). Use global default follows litellm_settings.skip_tool_message_in_guardrail.",
)}
>
{(fieldControl) => <SkipMessageSelect control={fieldControl} />}
</GuardrailField>
{guardrailData.litellm_params?.guardrail === "presidio" && (
<>
<SectionHeading>PII Protection</SectionHeading>
<div className="mb-6">
{guardrailSettings && (
<PiiConfiguration
entities={guardrailSettings.supported_entities}
actions={guardrailSettings.supported_actions}
selectedEntities={selectedPiiEntities}
selectedActions={selectedPiiActions}
onEntitySelect={handlePiiEntitySelect}
onActionSelect={handlePiiActionSelect}
entityCategories={guardrailSettings.pii_entity_categories}
/>
)}
</div>
</>
)}
<ContentFilterManager
guardrailData={guardrailData}
guardrailSettings={guardrailSettings}
isEditing={true}
accessToken={accessToken}
providerParams={guardrailProviderSpecificParams}
value={guardrailData.litellm_params}
onDataChange={handleContentFilterDataChange}
onUnsavedChanges={setHasUnsavedContentFilterChanges}
/>
{/* Optional parameters */}
{guardrailProviderSpecificParams &&
(() => {
const currentProvider = Object.keys(guardrail_provider_map).find(
(key) => guardrail_provider_map[key] === guardrailData.litellm_params?.guardrail,
);
if (!currentProvider) return null;
{(guardrailData.litellm_params?.guardrail === "tool_permission" ||
guardrailProviderSpecificParams) && <SectionHeading>Provider Settings</SectionHeading>}
const providerKey = guardrail_provider_map[currentProvider]?.toLowerCase();
const providerFields = guardrailProviderSpecificParams[providerKey];
{guardrailData.litellm_params?.guardrail === "tool_permission" ? (
<ToolPermissionRulesEditor value={toolPermissionConfig} onChange={setToolPermissionConfig} />
) : (
<>
{/* Provider-specific fields */}
<GuardrailProviderFields
selectedProvider={
Object.keys(guardrail_provider_map).find(
(key) => guardrail_provider_map[key] === guardrailData.litellm_params?.guardrail,
) || null
}
control={form.control}
accessToken={accessToken}
providerParams={guardrailProviderSpecificParams}
value={guardrailData.litellm_params}
/>
if (!providerFields || !providerFields.optional_params) return null;
{/* Optional parameters */}
{guardrailProviderSpecificParams &&
(() => {
const currentProvider = Object.keys(guardrail_provider_map).find(
(key) => guardrail_provider_map[key] === guardrailData.litellm_params?.guardrail,
);
if (!currentProvider) return null;
return (
<GuardrailOptionalParams
optionalParams={providerFields.optional_params}
parentFieldKey="optional_params"
values={guardrailData.litellm_params}
/>
);
})()}
</>
)}
const providerKey = guardrail_provider_map[currentProvider]?.toLowerCase();
const providerFields = guardrailProviderSpecificParams[providerKey];
<Divider orientation="left">Advanced Settings</Divider>
<Form.Item label="Guardrail Information" name="guardrail_info">
<Input.TextArea rows={5} />
</Form.Item>
if (!providerFields || !providerFields.optional_params) return null;
<div className="flex justify-end gap-2 mt-6">
<Button
onClick={() => {
setIsEditing(false);
setHasUnsavedContentFilterChanges(false);
resetToolPermissionEditor();
}}
>
Cancel
</Button>
<Button type="primary" htmlType="submit">
Save Changes
</Button>
</div>
</Form>
return (
<GuardrailOptionalParams
optionalParams={providerFields.optional_params}
parentFieldKey="optional_params"
control={form.control}
values={guardrailData.litellm_params}
/>
);
})()}
</>
)}
<SectionHeading>Advanced Settings</SectionHeading>
<GuardrailField control={form.control} name="guardrail_info" label="Guardrail Information">
{({ ref, value, ...field }) => (
<Textarea {...field} ref={ref} value={asText(value)} rows={5} />
)}
</GuardrailField>
<div className="mt-6 flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
setIsEditing(false);
setHasUnsavedContentFilterChanges(false);
resetToolPermissionEditor();
}}
>
Cancel
</Button>
<Button type="submit">Save Changes</Button>
</div>
</FieldGroup>
</form>
</TooltipProvider>
) : (
<div className="space-y-4">
<div>

View file

@ -1,8 +1,19 @@
import React from "react";
import { Form, Select, Typography, Input, Button } from "antd";
import { MultiSelect } from "@/components/shared/MultiSelect";
import { PasswordInput } from "@/components/shared/PasswordInput";
import NumericalInput from "@/components/shared/numerical_input";
const { Title } = Typography;
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
asStringArray,
asText,
GuardrailField,
readRecord,
requiredRule,
type GuardrailFieldControlProps,
type GuardrailFormControl,
} from "./GuardrailFormField";
interface ProviderParam {
param: string;
@ -19,17 +30,55 @@ interface ProviderParam {
interface GuardrailOptionalParamsProps {
optionalParams: ProviderParam;
parentFieldKey: string;
values?: Record<string, any>;
control: GuardrailFormControl;
values?: Record<string, unknown>;
}
interface DictFieldProps {
field: ProviderParam;
fieldKey: string;
fullFieldKey: string | string[];
value: any | null;
fullFieldKey: string;
control: GuardrailFormControl;
value: unknown;
}
const DictField: React.FC<DictFieldProps> = ({ field, fieldKey, fullFieldKey, value }) => {
const BOOLEAN_ITEMS = [
{ label: "True", value: true },
{ label: "False", value: false },
];
const isSecretKey = (fieldKey: string): boolean =>
fieldKey.includes("password") || fieldKey.includes("secret") || fieldKey.includes("key");
const toNumberValue = (raw: unknown): unknown => {
if (raw === null || raw === undefined || raw === "") return undefined;
const num = Number(raw);
return isNaN(num) ? raw : num;
};
const BooleanSelect: React.FC<{ control: GuardrailFieldControlProps; placeholder: string }> = ({
control,
placeholder,
}) => {
const { id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy } = control;
return (
<Select
items={BOOLEAN_ITEMS}
value={typeof value === "boolean" ? value : null}
onValueChange={(next: boolean | null) => onChange(next)}
>
<SelectTrigger id={id} aria-invalid={ariaInvalid} aria-describedby={ariaDescribedBy} className="w-full">
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
<SelectItem value={true}>True</SelectItem>
<SelectItem value={false}>False</SelectItem>
</SelectContent>
</Select>
);
};
const DictField: React.FC<DictFieldProps> = ({ field, fullFieldKey, control, value }) => {
const [selectedEntries, setSelectedEntries] = React.useState<Array<{ key: string; id: string }>>([]);
const [availableKeys, setAvailableKeys] = React.useState<string[]>(field.dict_key_options || []);
@ -69,36 +118,58 @@ const DictField: React.FC<DictFieldProps> = ({ field, fieldKey, fullFieldKey, va
<div className="space-y-3">
{/* Existing entries */}
{selectedEntries.map((entry) => (
<div key={entry.id} className="flex items-center space-x-3 p-3 border rounded-lg">
<div className="w-24 font-medium text-sm">{entry.key}</div>
<div className="flex-1">
<Form.Item
name={Array.isArray(fullFieldKey) ? [...fullFieldKey, entry.key] : [fullFieldKey, entry.key]}
style={{ marginBottom: 0 }}
initialValue={value && typeof value === "object" ? value[entry.key] : undefined}
normalize={
field.dict_value_type === "number"
? (value) => {
if (value === null || value === undefined || value === "") return undefined;
const num = Number(value);
return isNaN(num) ? value : num;
<div key={entry.id} className="flex items-center space-x-3 rounded-lg border border-border p-3">
<GuardrailField
control={control}
name={`${fullFieldKey}.${entry.key}`}
label={entry.key}
defaultValue={readRecord(value, entry.key)}
className="flex-1"
>
{(fieldControl) => {
if (field.dict_value_type === "number") {
return (
<NumericalInput
id={fieldControl.id}
name={fieldControl.name}
step={1}
placeholder={`Enter ${entry.key} value`}
value={asText(fieldControl.value)}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
fieldControl.onChange(toNumberValue(event.target.value))
}
: undefined
onBlur={fieldControl.onBlur}
aria-invalid={fieldControl["aria-invalid"]}
aria-describedby={fieldControl["aria-describedby"]}
/>
);
}
>
{field.dict_value_type === "number" ? (
<NumericalInput step={1} width={200} placeholder={`Enter ${entry.key} value`} />
) : field.dict_value_type === "boolean" ? (
<Select placeholder={`Select ${entry.key} value`}>
<Select.Option value={true}>True</Select.Option>
<Select.Option value={false}>False</Select.Option>
</Select>
) : (
<Input placeholder={`Enter ${entry.key} value`} />
)}
</Form.Item>
</div>
<Button type="text" danger size="small" onClick={() => removeEntry(entry.id, entry.key)}>
if (field.dict_value_type === "boolean") {
return <BooleanSelect control={fieldControl} placeholder={`Select ${entry.key} value`} />;
}
return (
<Input
id={fieldControl.id}
name={fieldControl.name}
ref={fieldControl.ref}
placeholder={`Enter ${entry.key} value`}
value={asText(fieldControl.value)}
onChange={fieldControl.onChange}
onBlur={fieldControl.onBlur}
aria-invalid={fieldControl["aria-invalid"]}
aria-describedby={fieldControl["aria-describedby"]}
/>
);
}}
</GuardrailField>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => removeEntry(entry.id, entry.key)}
>
Remove
</Button>
</div>
@ -106,97 +177,152 @@ const DictField: React.FC<DictFieldProps> = ({ field, fieldKey, fullFieldKey, va
{/* Add new entry */}
{availableKeys.length > 0 && (
<div className="flex items-center space-x-3 mt-2">
<div className="mt-2 flex items-center space-x-3">
<Select
placeholder="Select category to configure"
style={{ width: 200 }}
onSelect={(value: string | undefined) => value && addEntry(value)}
value={undefined}
items={availableKeys.map((key) => ({ label: key, value: key }))}
value={null}
onValueChange={(next: string | null) => next && addEntry(next)}
>
{availableKeys.map((key) => (
<Select.Option key={key} value={key}>
{key}
</Select.Option>
))}
<SelectTrigger className="w-50">
<SelectValue placeholder="Select category to configure" />
</SelectTrigger>
<SelectContent>
{availableKeys.map((key) => (
<SelectItem key={key} value={key}>
{key}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-sm text-gray-500">Select a category to add threshold configuration</span>
<span className="text-sm text-muted-foreground">Select a category to add threshold configuration</span>
</div>
)}
</div>
);
};
interface OptionalParamInputProps {
descriptor: ProviderParam;
fieldKey: string;
control: GuardrailFieldControlProps;
}
const OptionalParamInput: React.FC<OptionalParamInputProps> = ({ descriptor, fieldKey, control }) => {
const { id, value, onChange, onBlur, ref, name, ...aria } = control;
if (descriptor.type === "select" && descriptor.options) {
return (
<Select
items={descriptor.options.map((option) => ({ label: option, value: option }))}
value={asText(value) || null}
onValueChange={(next: string | null) => onChange(next)}
>
<SelectTrigger id={id} className="w-full" {...aria}>
<SelectValue placeholder={descriptor.description} />
</SelectTrigger>
<SelectContent>
{descriptor.options.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
if (descriptor.type === "multiselect" && descriptor.options) {
return (
<MultiSelect
id={id}
options={descriptor.options.map((option) => ({ label: option, value: option }))}
value={asStringArray(value)}
onValueChange={onChange}
placeholder={descriptor.description}
/>
);
}
if (descriptor.type === "bool" || descriptor.type === "boolean") {
return <BooleanSelect control={control} placeholder={descriptor.description} />;
}
if (descriptor.type === "number") {
return (
<NumericalInput
id={id}
name={name}
step={1}
placeholder={descriptor.description}
value={asText(value)}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => onChange(toNumberValue(event.target.value))}
onBlur={onBlur}
{...aria}
/>
);
}
if (isSecretKey(fieldKey)) {
return (
<PasswordInput
id={id}
name={name}
ref={ref}
placeholder={descriptor.description}
value={asText(value)}
onChange={onChange}
onBlur={onBlur}
{...aria}
/>
);
}
return (
<Input
id={id}
name={name}
ref={ref}
placeholder={descriptor.description}
value={asText(value)}
onChange={onChange}
onBlur={onBlur}
{...aria}
/>
);
};
const GuardrailOptionalParams: React.FC<GuardrailOptionalParamsProps> = ({
optionalParams,
parentFieldKey,
control,
values,
}) => {
const renderField = (fieldKey: string, field: ProviderParam) => {
const fullFieldKey = `${parentFieldKey}.${fieldKey}`;
const value = values?.[fieldKey];
// Handle dict fields separately since they manage their own Form.Items
// Handle dict fields separately since they manage their own fields
if (field.type === "dict" && field.dict_key_options) {
return (
<div key={fullFieldKey} className="mb-8 p-6 bg-gray-50 rounded-lg border border-gray-200">
<div className="mb-4 font-medium text-gray-900 text-base">{fieldKey}</div>
<p className="text-sm text-gray-600 mb-4">{field.description}</p>
<DictField field={field} fieldKey={fieldKey} fullFieldKey={[parentFieldKey, fieldKey]} value={value} />
<div key={fullFieldKey} className="mb-8 rounded-lg border border-border bg-muted/40 p-6">
<div className="mb-4 text-base font-medium text-foreground">{fieldKey}</div>
<p className="mb-4 text-sm text-muted-foreground">{field.description}</p>
<DictField field={field} fullFieldKey={fullFieldKey} control={control} value={value} />
</div>
);
}
return (
<div key={fullFieldKey} className="mb-8 p-6 bg-white rounded-lg border border-gray-200 shadow-xs">
<Form.Item
name={[parentFieldKey, fieldKey]}
label={
<div className="mb-2">
<div className="font-medium text-gray-900 text-base">{fieldKey}</div>
<p className="text-sm text-gray-600 mt-1">{field.description}</p>
</div>
}
rules={field.required ? [{ required: true, message: `${fieldKey} is required` }] : undefined}
className="mb-0"
initialValue={value !== undefined ? value : field.default_value}
normalize={
field.type === "number"
? (value) => {
if (value === null || value === undefined || value === "") return undefined;
const num = Number(value);
return isNaN(num) ? value : num;
}
: undefined
}
<div key={fullFieldKey} className="mb-8 rounded-lg border border-border bg-card p-6 shadow-xs">
<GuardrailField
control={control}
name={fullFieldKey}
label={<span className="text-base">{fieldKey}</span>}
description={field.description}
rules={field.required ? requiredRule(`${fieldKey} is required`) : undefined}
defaultValue={value !== undefined ? value : field.default_value}
>
{field.type === "select" && field.options ? (
<Select placeholder={field.description}>
{field.options.map((option) => (
<Select.Option key={option} value={option}>
{option}
</Select.Option>
))}
</Select>
) : field.type === "multiselect" && field.options ? (
<Select mode="multiple" placeholder={field.description}>
{field.options.map((option) => (
<Select.Option key={option} value={option}>
{option}
</Select.Option>
))}
</Select>
) : field.type === "bool" || field.type === "boolean" ? (
<Select placeholder={field.description}>
<Select.Option value={true}>True</Select.Option>
<Select.Option value={false}>False</Select.Option>
</Select>
) : field.type === "number" ? (
<NumericalInput step={1} width={400} placeholder={field.description} />
) : fieldKey.includes("password") || fieldKey.includes("secret") || fieldKey.includes("key") ? (
<Input.Password placeholder={field.description} />
) : (
<Input placeholder={field.description} />
)}
</Form.Item>
{(fieldControl) => <OptionalParamInput descriptor={field} fieldKey={fieldKey} control={fieldControl} />}
</GuardrailField>
</div>
);
};
@ -207,11 +333,9 @@ const GuardrailOptionalParams: React.FC<GuardrailOptionalParamsProps> = ({
return (
<div className="guardrail-optional-params">
<div className="mb-8 pb-4 border-b border-gray-100">
<Title level={3} className="mb-2 font-semibold text-gray-900">
Optional Parameters
</Title>
<p className="text-gray-600 text-sm">
<div className="mb-8 border-b border-border pb-4">
<h3 className="mb-2 text-lg font-semibold text-foreground">Optional Parameters</h3>
<p className="text-sm text-muted-foreground">
{optionalParams.description || "Configure additional settings for this guardrail provider"}
</p>
</div>

View file

@ -1,5 +1,4 @@
import React, { useState, useEffect } from "react";
import { Form, Select, Spin, Input, Slider } from "antd";
import {
guardrail_provider_map,
populateGuardrailProviders,
@ -7,13 +6,31 @@ import {
shouldRenderContentFilterConfigSettings,
} from "./guardrail_info_helpers";
import { getGuardrailProviderSpecificParams } from "@/components/networking";
import { MultiSelect } from "@/components/shared/MultiSelect";
import { PasswordInput } from "@/components/shared/PasswordInput";
import NumericalInput from "@/components/shared/numerical_input";
import { FieldGroup } from "@/components/shared/form/field";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Slider } from "@/components/ui/slider";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import {
asStringArray,
asText,
GuardrailField,
labelWithHint,
readRecord,
requiredRule,
type GuardrailFieldControlProps,
type GuardrailFormControl,
} from "./GuardrailFormField";
interface GuardrailProviderFieldsProps {
selectedProvider: string | null;
control: GuardrailFormControl;
accessToken?: string | null;
providerParams?: ProviderParamsResponse | null;
value?: Record<string, any> | null;
value?: Record<string, unknown> | null;
}
interface ProviderParam {
@ -35,8 +52,142 @@ interface ProviderParamsResponse {
[provider: string]: { [key: string]: ProviderParam };
}
const BOOLEAN_ITEMS = [
{ label: "True", value: true },
{ label: "False", value: false },
];
const isSecretKey = (fieldKey: string): boolean =>
fieldKey.includes("password") || fieldKey.includes("secret") || fieldKey.includes("key");
interface ProviderFieldInputProps {
descriptor: ProviderParam;
fieldKey: string;
control: GuardrailFieldControlProps;
}
const ProviderFieldInput: React.FC<ProviderFieldInputProps> = ({ descriptor, fieldKey, control }) => {
const { id, value, onChange, onBlur, ref, name, ...aria } = control;
if (descriptor.type === "select" && descriptor.options) {
return (
<Select
items={descriptor.options.map((option) => ({ label: option, value: option }))}
value={asText(value) || null}
onValueChange={(next: string | null) => onChange(next)}
>
<SelectTrigger id={id} className="w-full" {...aria}>
<SelectValue placeholder={descriptor.description} />
</SelectTrigger>
<SelectContent>
{descriptor.options.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
if (descriptor.type === "multiselect" && descriptor.options) {
return (
<MultiSelect
id={id}
options={descriptor.options.map((option) => ({ label: option, value: option }))}
value={asStringArray(value)}
onValueChange={onChange}
placeholder={descriptor.description}
/>
);
}
if (descriptor.type === "bool" || descriptor.type === "boolean") {
return (
<Select
items={BOOLEAN_ITEMS}
value={typeof value === "boolean" ? value : null}
onValueChange={(next: boolean | null) => onChange(next)}
>
<SelectTrigger id={id} className="w-full" {...aria}>
<SelectValue placeholder={descriptor.description} />
</SelectTrigger>
<SelectContent>
<SelectItem value={true}>True</SelectItem>
<SelectItem value={false}>False</SelectItem>
</SelectContent>
</Select>
);
}
if (descriptor.type === "percentage" && descriptor.min != null && descriptor.max != null) {
return (
<div className="w-full">
<Slider
id={id}
min={descriptor.min}
max={descriptor.max}
step={descriptor.step ?? 0.1}
value={typeof value === "number" ? value : descriptor.min}
onValueChange={(next: number | readonly number[]) => onChange(Array.isArray(next) ? next[0] : next)}
onBlur={onBlur}
/>
<div className="mt-1 flex justify-between text-xs text-muted-foreground">
<span>0%</span>
<span>50%</span>
<span>100%</span>
</div>
</div>
);
}
if (descriptor.type === "number") {
return (
<NumericalInput
id={id}
name={name}
step={1}
placeholder={descriptor.description}
value={asText(value)}
onChange={onChange}
onBlur={onBlur}
{...aria}
/>
);
}
if (isSecretKey(fieldKey)) {
return (
<PasswordInput
id={id}
name={name}
ref={ref}
placeholder={descriptor.description}
value={asText(value)}
onChange={onChange}
onBlur={onBlur}
{...aria}
/>
);
}
return (
<Input
id={id}
name={name}
ref={ref}
placeholder={descriptor.description}
value={asText(value)}
onChange={onChange}
onBlur={onBlur}
{...aria}
/>
);
};
const GuardrailProviderFields: React.FC<GuardrailProviderFieldsProps> = ({
selectedProvider,
control,
accessToken,
providerParams: providerParamsProp = null,
value = null,
@ -87,12 +238,17 @@ const GuardrailProviderFields: React.FC<GuardrailProviderFieldsProps> = ({
// Show loading state
if (loading) {
return <Spin tip="Loading provider parameters..." />;
return (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<UiLoadingSpinner className="size-4" />
Loading provider parameters...
</div>
);
}
// Show error state
if (error) {
return <div className="text-red-500">{error}</div>;
return <div className="text-destructive">{error}</div>;
}
// Get the provider key matching the selected provider in the guardrail_provider_map
@ -119,10 +275,11 @@ const GuardrailProviderFields: React.FC<GuardrailProviderFieldsProps> = ({
const isContentFilterProvider = shouldRenderContentFilterConfigSettings(selectedProvider);
// Convert object to array of entries and render fields
const renderFields = (fields: { [key: string]: ProviderParam }, parentKey = "", parentValue?: any) => {
const renderFields = (fields: { [key: string]: ProviderParam }, parentKey = "", parentValue?: unknown) => {
return Object.entries(fields).map(([fieldKey, field]) => {
const fullFieldKey = parentKey ? `${parentKey}.${fieldKey}` : fieldKey;
const fieldValue = parentValue ? parentValue[fieldKey] : value?.[fieldKey];
// ":" keeps nested children out of the submitted object graph: nothing binds the parent
const fullFieldKey = parentKey ? `${parentKey}:${fieldKey}` : fieldKey;
const fieldValue = parentValue ? readRecord(parentValue, fieldKey) : value?.[fieldKey];
// Skip ui_friendly_name - it's metadata for the UI dropdown, not a user configuration field
if (fieldKey === "ui_friendly_name") {
return null;
@ -143,9 +300,9 @@ const GuardrailProviderFields: React.FC<GuardrailProviderFieldsProps> = ({
return (
<div key={fullFieldKey}>
<div className="mb-2 font-medium">{fieldKey}</div>
<div className="ml-4 border-l-2 border-gray-200 pl-4">
<FieldGroup className="ml-4 border-l-2 border-border pl-4">
{renderFields(field.fields, fullFieldKey, fieldValue)}
</div>
</FieldGroup>
</div>
);
}
@ -154,64 +311,21 @@ const GuardrailProviderFields: React.FC<GuardrailProviderFieldsProps> = ({
fieldValue !== undefined ? fieldValue : field.default_value ?? (field.type === "percentage" ? 0.5 : undefined);
return (
<Form.Item
<GuardrailField
key={fullFieldKey}
control={control}
name={fullFieldKey}
label={fieldKey}
tooltip={field.description}
rules={field.required ? [{ required: true, message: `${fieldKey} is required` }] : undefined}
initialValue={resolvedInitialValue}
label={labelWithHint(fieldKey, field.description)}
rules={field.required ? requiredRule(`${fieldKey} is required`) : undefined}
defaultValue={resolvedInitialValue}
>
{field.type === "select" && field.options ? (
<Select placeholder={field.description} defaultValue={fieldValue || field.default_value}>
{field.options.map((option) => (
<Select.Option key={option} value={option}>
{option}
</Select.Option>
))}
</Select>
) : field.type === "multiselect" && field.options ? (
<Select mode="multiple" placeholder={field.description} defaultValue={fieldValue || field.default_value}>
{field.options.map((option) => (
<Select.Option key={option} value={option}>
{option}
</Select.Option>
))}
</Select>
) : field.type === "bool" || field.type === "boolean" ? (
<Select placeholder={field.description}>
<Select.Option value={true}>True</Select.Option>
<Select.Option value={false}>False</Select.Option>
</Select>
) : field.type === "percentage" && field.min != null && field.max != null ? (
<Slider
min={field.min}
max={field.max}
step={field.step ?? 0.1}
marks={{
[field.min]: "0%",
[(field.min + field.max) / 2]: "50%",
[field.max]: "100%",
}}
/>
) : field.type === "number" ? (
<NumericalInput
step={1}
width={400}
placeholder={field.description}
defaultValue={fieldValue !== undefined ? Number(fieldValue) : undefined}
/>
) : fieldKey.includes("password") || fieldKey.includes("secret") || fieldKey.includes("key") ? (
<Input.Password placeholder={field.description} defaultValue={fieldValue || ""} />
) : (
<Input placeholder={field.description} defaultValue={fieldValue || ""} />
)}
</Form.Item>
{(fieldControl) => <ProviderFieldInput descriptor={field} fieldKey={fieldKey} control={fieldControl} />}
</GuardrailField>
);
});
};
return <>{renderFields(providerFields)}</>;
return <FieldGroup>{renderFields(providerFields)}</FieldGroup>;
};
export default GuardrailProviderFields;

View file

@ -1,176 +1,251 @@
"use client";
import { Plus, X } from "lucide-react";
import React from "react";
import { Form, Select, InputNumber, Input, Tooltip } from "antd";
import { PlusOutlined, QuestionCircleOutlined } from "@ant-design/icons";
import { Button } from "antd";
import { useController } from "react-hook-form";
import { Field, FieldGroup, FieldLabel } 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 { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
asText,
GuardrailField,
labelWithHint,
requiredRule,
type GuardrailCriterion,
type GuardrailFieldControlProps,
type GuardrailFormControl,
} from "../GuardrailFormField";
interface LLMJudgeFieldsProps {
availableModels: string[];
form: any;
control: GuardrailFormControl;
}
const LLMJudgeFields: React.FC<LLMJudgeFieldsProps> = ({ availableModels, form }) => {
const DEFAULT_CRITERIA: GuardrailCriterion[] = [{ name: "", weight: 100, description: "" }];
const ON_FAILURE_ITEMS = [
{ label: "Block (return 422)", value: "block" },
{ label: "Log only", value: "log" },
];
const clampToRange = (value: unknown, min: number, max: number): number | null => {
if (typeof value !== "number" || Number.isNaN(value)) return null;
return Math.min(max, Math.max(min, value));
};
interface BoundedNumberInputProps {
control: GuardrailFieldControlProps;
min: number;
max: number;
suffix: string;
placeholder?: string;
}
const BoundedNumberInput: React.FC<BoundedNumberInputProps> = ({ control, min, max, suffix, placeholder }) => {
const { id, name, value, onChange, onBlur, ...aria } = control;
return (
<>
<div
style={{
background: "#f6ffed",
border: "1px solid #b7eb8f",
borderRadius: 6,
padding: "10px 14px",
marginBottom: 16,
fontSize: 13,
color: "#389e0d",
<InputGroup>
<InputGroupInput
id={id}
name={name}
type="number"
min={min}
max={max}
placeholder={placeholder}
value={asText(value)}
onChange={(event) => onChange(event.target.value === "" ? null : Number(event.target.value))}
onBlur={() => {
onChange(clampToRange(value, min, max));
onBlur();
}}
>
{...aria}
/>
<InputGroupAddon align="inline-end">{suffix}</InputGroupAddon>
</InputGroup>
);
};
const LLMJudgeFields: React.FC<LLMJudgeFieldsProps> = ({ availableModels, control }) => {
const { field } = useController({ control, name: "criteria", defaultValue: DEFAULT_CRITERIA });
const criteria: GuardrailCriterion[] = Array.isArray(field.value) ? field.value : [];
const setCriteria = field.onChange;
const weightTotal = criteria.reduce((sum, entry) => sum + (Number(entry?.weight) || 0), 0);
const weightOk = weightTotal === 100;
return (
<FieldGroup>
<div className="rounded-md border border-green-200 bg-green-50 px-3.5 py-2.5 text-[13px] text-green-700 dark:border-green-900 dark:bg-green-950/40 dark:text-green-300">
After each LLM response, the <strong>Judge Model</strong> scores it 0–100 against your criteria. If the weighted
average falls below the threshold, the response is blocked (or logged).
</div>
<Form.Item
<GuardrailField
control={control}
name="judge_model"
label={
<span>
Judge Model&nbsp;
<Tooltip title="The LLM that reads each response and grades it. Pick a capable model — it never sees end-user data beyond what the LLM returned.">
<QuestionCircleOutlined style={{ color: "#8c8c8c" }} />
</Tooltip>
</span>
}
rules={[{ required: true, message: "Select a judge model" }]}
label={labelWithHint(
"Judge Model",
"The LLM that reads each response and grades it. Pick a capable model — it never sees end-user data beyond what the LLM returned.",
)}
rules={requiredRule("Select a judge model")}
>
<Select
showSearch
placeholder="Select a model"
options={availableModels.map((m) => ({ label: m, value: m }))}
/>
</Form.Item>
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
<Combobox items={availableModels} value={asText(value) || null} onValueChange={onChange}>
<ComboboxInput
id={id}
aria-invalid={ariaInvalid}
aria-describedby={ariaDescribedBy}
placeholder="Select a model"
className="w-full"
/>
<ComboboxContent>
<ComboboxEmpty>No matching models</ComboboxEmpty>
<ComboboxList>
{(model: string) => (
<ComboboxItem key={model} value={model} title={model}>
{model}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
)}
</GuardrailField>
<Form.Item
<GuardrailField
control={control}
name="overall_threshold"
label={
<span>
Minimum Score to Pass&nbsp;
<Tooltip title="0–100. If the weighted average of criterion scores falls below this, the guardrail triggers. 80 is a good default.">
<QuestionCircleOutlined style={{ color: "#8c8c8c" }} />
</Tooltip>
</span>
}
initialValue={80}
label={labelWithHint(
"Minimum Score to Pass",
"0–100. If the weighted average of criterion scores falls below this, the guardrail triggers. 80 is a good default.",
)}
defaultValue={80}
>
<InputNumber min={0} max={100} addonAfter="/ 100" style={{ width: "100%" }} />
</Form.Item>
{(fieldControl) => <BoundedNumberInput control={fieldControl} min={0} max={100} suffix="/ 100" />}
</GuardrailField>
<Form.Item
<GuardrailField
control={control}
name="on_failure"
label={
<span>
On Failure&nbsp;
<Tooltip title="Block: return HTTP 422 when the score is too low. Log: record the result but let the response through.">
<QuestionCircleOutlined style={{ color: "#8c8c8c" }} />
</Tooltip>
</span>
}
initialValue="block"
label={labelWithHint(
"On Failure",
"Block: return HTTP 422 when the score is too low. Log: record the result but let the response through.",
)}
defaultValue="block"
>
<Select>
<Select.Option value="block">Block (return 422)</Select.Option>
<Select.Option value="log">Log only</Select.Option>
</Select>
</Form.Item>
<Form.Item
label={
<span>
Evaluation Criteria&nbsp;
<Tooltip title="Each criterion is something the judge checks. Weights must add up to 100%.">
<QuestionCircleOutlined style={{ color: "#8c8c8c" }} />
</Tooltip>
</span>
}
>
<Form.List name="criteria" initialValue={[{ name: "", weight: 100, description: "" }]}>
{(fields, { add, remove }) => (
<>
{fields.map(({ key, name, ...restField }) => (
<div
key={key}
style={{
border: "1px solid #f0f0f0",
borderRadius: 6,
padding: "12px 12px 0",
marginBottom: 8,
}}
>
<div style={{ display: "flex", gap: 8, alignItems: "flex-end" }}>
<Form.Item
{...restField}
name={[name, "name"]}
rules={[{ required: true, message: "Enter criterion name" }]}
style={{ flex: 2, marginBottom: 8 }}
>
<Input placeholder="Criterion name (e.g. Policy accuracy)" />
</Form.Item>
<Form.Item
{...restField}
name={[name, "weight"]}
label={
<Tooltip title="How much this criterion counts toward the final score. All weights must add up to 100%.">
<span style={{ fontSize: 12, color: "#595959" }}>
Weight <QuestionCircleOutlined style={{ color: "#bfbfbf" }} />
</span>
</Tooltip>
}
rules={[{ required: true, message: "Enter weight" }]}
style={{ flex: 1, marginBottom: 8 }}
>
<InputNumber min={0} max={100} addonAfter="%" style={{ width: "100%" }} placeholder="e.g. 50" />
</Form.Item>
<div style={{ marginBottom: 8 }}>
<Button type="text" danger size="small" onClick={() => remove(name)}>
×
</Button>
</div>
</div>
<Form.Item
{...restField}
name={[name, "description"]}
rules={[{ required: true, message: "Describe what to check" }]}
style={{ marginBottom: 8 }}
>
<Input placeholder="What should the judge check for this criterion?" />
</Form.Item>
</div>
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
<Select items={ON_FAILURE_ITEMS} value={asText(value) || null} onValueChange={onChange}>
<SelectTrigger id={id} aria-invalid={ariaInvalid} aria-describedby={ariaDescribedBy} className="w-full">
<SelectValue placeholder="Select an action" />
</SelectTrigger>
<SelectContent>
{ON_FAILURE_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
<Button
type="dashed"
block
style={{ marginTop: 4 }}
onClick={() => add({ name: "", weight: 0, description: "" })}
icon={<PlusOutlined />}
>
Add Criterion
</Button>
{fields.length > 0 && (
<Form.Item shouldUpdate noStyle>
{() => {
const allCriteria: any[] = form.getFieldValue("criteria") || [];
const weightTotal = allCriteria.reduce((sum: number, c: any) => sum + (Number(c?.weight) || 0), 0);
const weightOk = weightTotal === 100;
return (
<div style={{ marginTop: 6, fontSize: 12, color: weightOk ? "#52c41a" : "#faad14" }}>
Weights total: {weightTotal}%{weightOk ? " ✓" : " — must add up to 100%"}
</div>
);
}}
</Form.Item>
)}
</>
</SelectContent>
</Select>
)}
</GuardrailField>
<Field>
<FieldLabel>
{labelWithHint(
"Evaluation Criteria",
"Each criterion is something the judge checks. Weights must add up to 100%.",
)}
</Form.List>
</Form.Item>
</>
</FieldLabel>
{criteria.map((_, index) => (
<div key={index} className="mb-2 rounded-md border border-border p-3">
<div className="flex items-end gap-2">
<GuardrailField
control={control}
name={`criteria.${index}.name`}
rules={requiredRule("Enter criterion name")}
className="flex-2"
>
{({ ref, value, ...field }) => (
<Input
{...field}
ref={ref}
value={asText(value)}
placeholder="Criterion name (e.g. Policy accuracy)"
/>
)}
</GuardrailField>
<GuardrailField
control={control}
name={`criteria.${index}.weight`}
label={labelWithHint(
<span className="text-xs text-muted-foreground">Weight</span>,
"How much this criterion counts toward the final score. All weights must add up to 100%.",
)}
rules={requiredRule("Enter weight")}
className="flex-1"
>
{(fieldControl) => (
<BoundedNumberInput control={fieldControl} min={0} max={100} suffix="%" placeholder="e.g. 50" />
)}
</GuardrailField>
<Button
variant="ghost"
size="sm"
aria-label="Remove criterion"
className="mb-1 text-destructive hover:text-destructive"
onClick={() => setCriteria(criteria.filter((_, position) => position !== index))}
>
<X className="size-4" />
</Button>
</div>
<GuardrailField
control={control}
name={`criteria.${index}.description`}
rules={requiredRule("Describe what to check")}
className="mt-2"
>
{({ ref, value, ...field }) => (
<Input
{...field}
ref={ref}
value={asText(value)}
placeholder="What should the judge check for this criterion?"
/>
)}
</GuardrailField>
</div>
))}
<Button
variant="outline"
className="mt-1 w-full border-dashed"
onClick={() => setCriteria([...criteria, { name: "", weight: 0, description: "" }])}
>
<Plus className="size-4" />
Add Criterion
</Button>
{criteria.length > 0 && (
<div
className={`mt-1.5 text-xs ${weightOk ? "text-green-600 dark:text-green-400" : "text-amber-600 dark:text-amber-400"}`}
>
Weights total: {weightTotal}%{weightOk ? " ✓" : " — must add up to 100%"}
</div>
)}
</Field>
</FieldGroup>
);
};

View file

@ -21,6 +21,7 @@ export interface MultiSelectOption {
}
interface MultiSelectProps {
id?: string;
options: MultiSelectOption[];
value?: string[];
onValueChange: (value: string[]) => void;
@ -43,6 +44,7 @@ const matchesQuery = (option: MultiSelectOption, query: string): boolean => {
};
export function MultiSelect({
id,
options,
value = [],
onValueChange,
@ -101,9 +103,10 @@ export function MultiSelect({
</ComboboxChip>
))}
<ComboboxChipsInput
id={id}
placeholder={loading ? "Loading..." : placeholder}
className="min-w-24"
aria-label={placeholder}
aria-label={placeholder || undefined}
/>
</>
)}