mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
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:
parent
56d926ac54
commit
3c82791968
10 changed files with 1897 additions and 819 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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"
|
||||
>
|
||||
✕
|
||||
</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"
|
||||
>
|
||||
✕
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
<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
|
||||
<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
|
||||
<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
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue