diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index dd90eadad08..5cc055a50ff 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -322,12 +322,6 @@ } }, "src/app/(dashboard)/guardrails/_components/content_filter/CompetitorIntentConfiguration.tsx": { - "no-nested-ternary": { - "count": 1 - }, - "no-restricted-imports": { - "count": 1 - }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -1546,9 +1540,6 @@ "count": 2 }, "no-restricted-imports": { - "count": 2 - }, - "react/no-unescaped-entities": { "count": 1 } }, @@ -1565,7 +1556,7 @@ "count": 1 }, "no-restricted-imports": { - "count": 2 + "count": 1 }, "react-hooks/set-state-in-effect": { "count": 1 @@ -3240,4 +3231,4 @@ "count": 1 } } -} \ No newline at end of file +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/TeamGuardrailsTab.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/TeamGuardrailsTab.integration.test.tsx new file mode 100644 index 00000000000..8eefa6dcf81 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/TeamGuardrailsTab.integration.test.tsx @@ -0,0 +1,193 @@ +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { renderWithProviders } from "@/../tests/test-utils"; +import { listGuardrailSubmissions } from "@/components/networking"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; + +import { TeamGuardrailsTab } from "./TeamGuardrailsTab"; + +const mutateAsync = vi.fn(); + +vi.mock("@/components/networking", () => ({ + listGuardrailSubmissions: vi.fn(), + approveGuardrailSubmission: vi.fn(), + rejectGuardrailSubmission: vi.fn(), + updateGuardrailCall: vi.fn(), +})); + +vi.mock("@/app/(dashboard)/hooks/guardrails/useRegisterGuardrail", () => ({ + useRegisterGuardrail: () => ({ mutateAsync, isPending: false }), +})); + +vi.mock("@/components/common_components/team_dropdown", () => ({ + default: ({ value, onChange }: { value?: string; onChange?: (value: string) => void }) => ( + onChange?.(event.target.value)} /> + ), +})); + +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: vi.fn() })); + +const authorized = { + isLoading: false, + isAuthorized: true, + token: "sk-test", + accessToken: "sk-test", + userId: "user-1", + userEmail: "user@example.com", + userRole: "Admin", + userRoleLabel: "Admin", + isViewOnly: false, + premiumUser: false, + disabledPersonalKeyCreation: null, + showSSOBanner: false, +}; + +const openSubmitModal = async (user: ReturnType) => { + renderWithProviders(); + await user.click(await screen.findByRole("button", { name: /Add Guardrail/ })); + await screen.findByText("Submit Guardrail for Review"); +}; + +const fillRequiredFields = async (user: ReturnType, apiBase: string) => { + await user.type(screen.getByLabelText("team"), "team-1"); + await user.type(screen.getByPlaceholderText("e.g. pii-detection"), "pii-detection"); + await user.type(screen.getByPlaceholderText("https://your-guardrail-api.com/v1/check"), apiBase); +}; + +const submit = (user: ReturnType) => + user.click(screen.getByRole("button", { name: "Submit for Review" })); + +const registeredPayload = () => mutateAsync.mock.calls[0][0]; + +const VALIDATION_MESSAGES = [ + "Select a team", + "Enter a guardrail name", + "Enter the API base URL", + "Must be a valid URL", + "Must be a JSON object", + "Invalid JSON", +]; + +const visibleErrors = () => VALIDATION_MESSAGES.filter((message) => screen.queryByText(message) !== null); + +describe("TeamGuardrailsTab submit payload", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useAuthorized).mockReturnValue(authorized); + vi.mocked(listGuardrailSubmissions).mockResolvedValue({ + submissions: [], + summary: { total: 0, pending_review: 0, active: 0, rejected: 0 }, + }); + }); + + it("sends the guardrail defaults and leaves guardrail_info undefined when the optional fields are blank", async () => { + const user = userEvent.setup(); + await openSubmitModal(user); + + await fillRequiredFields(user, "https://guard.example.com/v1/check"); + await submit(user); + + await vi.waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1)); + expect(registeredPayload()).toStrictEqual({ + team_id: "team-1", + guardrail_name: "pii-detection", + litellm_params: { + guardrail: "generic_guardrail_api", + mode: "pre_call", + api_base: "https://guard.example.com/v1/check", + }, + guardrail_info: undefined, + }); + }); + + it("merges the extra params underneath the form fields so the form's api_base and mode win", async () => { + const user = userEvent.setup(); + await openSubmitModal(user); + + await fillRequiredFields(user, "https://guard.example.com/v1/check"); + await user.type( + screen.getByPlaceholderText('{"forward_api_key": true, "headers": {"X-Custom": "value"}}'), + '{{"forward_api_key": true, "api_base": "https://ignored.example.com", "mode": "post_call"}', + ); + await user.type( + screen.getByPlaceholderText('{"description": "Detects PII in requests"}'), + '{{"description": "Detects PII"}', + ); + await submit(user); + + await vi.waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1)); + expect(registeredPayload()).toStrictEqual({ + team_id: "team-1", + guardrail_name: "pii-detection", + litellm_params: { + forward_api_key: true, + api_base: "https://guard.example.com/v1/check", + mode: "pre_call", + guardrail: "generic_guardrail_api", + }, + guardrail_info: { description: "Detects PII" }, + }); + }); + + it("sends the mode the user picked", async () => { + const user = userEvent.setup(); + await openSubmitModal(user); + + await fillRequiredFields(user, "https://guard.example.com/v1/check"); + await user.click(screen.getAllByRole("combobox")[1]); + const options = await screen.findAllByText("During Call"); + await user.click(options[options.length - 1]); + await submit(user); + + await vi.waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1)); + expect(registeredPayload().litellm_params.mode).toBe("during_call"); + }); + + it("blocks an empty submit and reports every required field", async () => { + const user = userEvent.setup(); + await openSubmitModal(user); + + await submit(user); + + expect(await screen.findByText("Enter a guardrail name")).toBeInTheDocument(); + expect(visibleErrors()).toStrictEqual(["Select a team", "Enter a guardrail name", "Enter the API base URL"]); + expect(mutateAsync).not.toHaveBeenCalled(); + }); + + it("accepts a protocol-less www host but rejects a bare domain", async () => { + const user = userEvent.setup(); + await openSubmitModal(user); + + await user.type(screen.getByPlaceholderText("https://your-guardrail-api.com/v1/check"), "www.example.com"); + await submit(user); + await screen.findByText("Enter a guardrail name"); + expect(visibleErrors()).toStrictEqual(["Select a team", "Enter a guardrail name"]); + + await user.clear(screen.getByPlaceholderText("https://your-guardrail-api.com/v1/check")); + await user.type(screen.getByPlaceholderText("https://your-guardrail-api.com/v1/check"), "example.com"); + await submit(user); + + await vi.waitFor(() => + expect(visibleErrors()).toStrictEqual(["Select a team", "Enter a guardrail name", "Must be a valid URL"]), + ); + expect(mutateAsync).not.toHaveBeenCalled(); + }); + + it("rejects a non-object extra params value and unparsable guardrail info", async () => { + const user = userEvent.setup(); + await openSubmitModal(user); + + await fillRequiredFields(user, "https://guard.example.com/v1/check"); + await user.type( + screen.getByPlaceholderText('{"forward_api_key": true, "headers": {"X-Custom": "value"}}'), + '"a plain string"', + ); + await user.type(screen.getByPlaceholderText('{"description": "Detects PII in requests"}'), "nope"); + await submit(user); + + await vi.waitFor(() => expect(visibleErrors()).toStrictEqual(["Must be a JSON object", "Invalid JSON"])); + expect(mutateAsync).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/TeamGuardrailsTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/TeamGuardrailsTab.tsx index 92fc117ebf8..3909857cc8d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/TeamGuardrailsTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/TeamGuardrailsTab.tsx @@ -15,8 +15,10 @@ import { ServerIcon, AlertCircleIcon, InfoIcon, + CircleHelp, } from "lucide-react"; -import { Modal, Form, Input, Select } from "antd"; +import { Modal } from "antd"; +import { z } from "zod/v4"; import { listGuardrailSubmissions, approveGuardrailSubmission, @@ -29,6 +31,67 @@ import TeamDropdown from "@/components/common_components/team_dropdown"; import { useRegisterGuardrail } from "@/app/(dashboard)/hooks/guardrails/useRegisterGuardrail"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { isProxyAdminRole } from "@/utils/roles"; +import { FieldGroup } from "@/components/shared/form/field"; +import { FormField } from "@/components/shared/form/FormField"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { isAntdUrl } from "@/lib/forms/antdUrl"; +import { useZodForm } from "@/lib/forms/useZodForm"; + +const GUARDRAIL_MODES = [ + { value: "pre_call", label: "Pre Call" }, + { value: "post_call", label: "Post Call" }, + { value: "during_call", label: "During Call" }, +] as const; + +const submitGuardrailSchema = z.object({ + team_id: z.string().min(1, "Select a team"), + guardrail_name: z.string().min(1, "Enter a guardrail name"), + mode: z.string().min(1, "Select a mode"), + api_base: z.string().min(1, "Enter the API base URL").refine(isAntdUrl, "Must be a valid URL"), + extra_litellm_params: z.string().superRefine((value, ctx) => { + if (!value) return; + try { + const parsed: unknown = JSON.parse(value); + if (typeof parsed !== "object" || Array.isArray(parsed)) { + ctx.addIssue({ code: "custom", message: "Must be a JSON object" }); + } + } catch { + ctx.addIssue({ code: "custom", message: "Invalid JSON" }); + } + }), + guardrail_info: z.string().superRefine((value, ctx) => { + if (!value) return; + try { + JSON.parse(value); + } catch { + ctx.addIssue({ code: "custom", message: "Invalid JSON" }); + } + }), +}); + +type SubmitGuardrailValues = z.output; + +const EMPTY_SUBMIT_VALUES: SubmitGuardrailValues = { + team_id: "", + guardrail_name: "", + mode: "pre_call", + api_base: "", + extra_litellm_params: "", + guardrail_info: "", +}; + +const labelWithHint = (label: string, hint: string): React.ReactNode => ( + <> + {label} + + } /> + {hint} + + +); type GuardrailStatus = "active" | "pending" | "rejected"; @@ -116,31 +179,31 @@ function submissionToTeamGuardrail(item: GuardrailSubmissionItem): TeamGuardrail const STATUS_CONFIG: Record = { active: { label: "Active", - bg: "bg-green-50", - text: "text-green-700", + bg: "bg-green-50 dark:bg-green-950", + text: "text-green-700 dark:text-green-300", dot: "bg-green-500", }, pending: { label: "Pending Review", - bg: "bg-yellow-50", - text: "text-yellow-700", + bg: "bg-yellow-50 dark:bg-yellow-950", + text: "text-yellow-700 dark:text-yellow-300", dot: "bg-yellow-500", }, rejected: { label: "Rejected", - bg: "bg-red-50", - text: "text-red-700", + bg: "bg-red-50 dark:bg-red-950", + text: "text-red-700 dark:text-red-300", dot: "bg-red-500", }, }; const TEAM_COLORS: Record = { - "ML Platform": "bg-purple-100 text-purple-700", - "Data Science": "bg-blue-100 text-blue-700", - Security: "bg-red-100 text-red-700", - "Customer Success": "bg-orange-100 text-orange-700", - Legal: "bg-gray-100 text-gray-700", - Finance: "bg-green-100 text-green-700", + "ML Platform": "bg-purple-100 dark:bg-purple-900 text-purple-700 dark:text-purple-300", + "Data Science": "bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300", + Security: "bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-300", + "Customer Success": "bg-orange-100 dark:bg-orange-900 text-orange-700 dark:text-orange-300", + Legal: "bg-muted text-foreground", + Finance: "bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300", }; function buildEquivalentConfigYaml(g: TeamGuardrail): string { @@ -183,9 +246,9 @@ function buildEquivalentConfigYaml(g: TeamGuardrail): string { function StatCard({ label, value, color }: { label: string; value: number; color: string }) { return ( -
+
{value}
-
{label}
+
{label}
); } @@ -207,7 +270,7 @@ function Toggle({ aria-checked={enabled} disabled={disabled} className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 ${ - enabled ? "bg-blue-500" : "bg-gray-200" + enabled ? "bg-blue-500" : "bg-muted" } ${disabled ? "opacity-50 cursor-not-allowed" : ""}`} >
@@ -261,31 +324,31 @@ function GuardrailCard({ {status.label}
-

{g.name}

-

{g.description}

+

{g.name}

+

{g.description}

- - {g.endpoint} + + {g.endpoint}
-
+
- Model: {g.model} + Model: {g.model} - Submitted: {g.submittedAt} + Submitted: {g.submittedAt}
- Forward API Key + Forward API Key
@@ -301,7 +364,7 @@ function GuardrailCard({ @@ -310,16 +373,16 @@ function GuardrailCard({
-
+
-

{g.description}

+

{g.description}

- {g.endpoint} + {g.endpoint}
- + {g.method} -
+
- Forward LiteLLM API Key + Forward LiteLLM API Key
-

+

When enabled, the caller's LiteLLM API key is forwarded as an{" "} - Authorization header to your guardrail - endpoint. This allows your guardrail to authenticate model calls using the original caller's - credentials. + Authorization header to + your guardrail endpoint. This allows your guardrail to authenticate model calls using the original + caller's credentials.

- Static headers + Static headers {g.customHeaders.length > 0 && ( - + {g.customHeaders.length} )}
-

Sent with every request to the guardrail.

+

Sent with every request to the guardrail.

{g.customHeaders.length === 0 ? ( -

No static headers configured.

+

No static headers configured.

) : (
    {g.customHeaders.map((h, i) => (
  • - + {h.key}: {h.value} {isAdmin && ( @@ -545,31 +608,31 @@ function DetailPanel({
- Forward client headers + Forward client headers {g.extraHeaders.length > 0 && ( - + {g.extraHeaders.length} )}
-

+

Allowed header names to forward from the client request to the guardrail (e.g. x-request-id).

{g.extraHeaders.length === 0 ? ( -

No forward client headers configured.

+

No forward client headers configured.

) : (
    {g.extraHeaders.map((name, i) => (
  • - {name} + {name} {isAdmin && (
)}
-
+
{configExpanded && ( -
+              
                 {buildEquivalentConfigYaml(g)}
               
)}
-
- -

+

+ +

This guardrail runs on a separate instance. It receives the user request and forwards the result to the next step in the pipeline. See{" "}

-
+
@@ -766,7 +829,7 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) { const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [isSubmitModalOpen, setIsSubmitModalOpen] = useState(false); - const [submitForm] = Form.useForm(); + const submitForm = useZodForm(submitGuardrailSchema, { defaultValues: EMPTY_SUBMIT_VALUES }); const registerGuardrail = useRegisterGuardrail(); const fetchSubmissions = useCallback(async () => { @@ -797,6 +860,29 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) { fetchSubmissions(); }, [fetchSubmissions]); + const handleSubmitGuardrail = submitForm.handleSubmit(async (values) => { + const litellm_params: Record = { + ...(values.extra_litellm_params ? JSON.parse(values.extra_litellm_params) : {}), + guardrail: "generic_guardrail_api", + mode: values.mode, + api_base: values.api_base, + }; + try { + await registerGuardrail.mutateAsync({ + team_id: values.team_id, + guardrail_name: values.guardrail_name, + litellm_params, + guardrail_info: values.guardrail_info ? JSON.parse(values.guardrail_info) : undefined, + }); + toast.success("Guardrail submitted for review"); + setIsSubmitModalOpen(false); + submitForm.reset(); + fetchSubmissions(); + } catch { + return; + } + }); + const filtered = guardrails; const selected = guardrails.find((g) => g.id === selectedId) ?? null; const totalCount = summary.total; @@ -896,28 +982,28 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) { return (
-
+
- +
- + setSearch(e.target.value)} - className="w-full pl-9 pr-4 py-2 border border-gray-200 rounded-md text-sm text-gray-700 placeholder-gray-400 focus:outline-hidden focus:ring-1 focus:ring-blue-500 focus:border-blue-500" + className="w-full pl-9 pr-4 py-2 border border-border rounded-md text-sm text-foreground placeholder:text-muted-foreground focus:outline-hidden focus:ring-1 focus:ring-blue-500 focus:border-blue-500" />
- - - - - - - - { - if (!value) return Promise.resolve(); - try { - const parsed = JSON.parse(value); - if (typeof parsed !== "object" || Array.isArray(parsed)) { - return Promise.reject("Must be a JSON object"); - } - return Promise.resolve(); - } catch { - return Promise.reject("Invalid JSON"); - } - }, - }, - ]} - > - - - { - if (!value) return Promise.resolve(); - try { - JSON.parse(value); - return Promise.resolve(); - } catch { - return Promise.reject("Invalid JSON"); - } - }, - }, - ]} - > - - - + +
+ + + {({ id, value, onChange }) => } + + + {({ ref, ...field }) => } + + + {({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => ( + + )} + + + {({ ref, ...field }) => ( + + )} + + + {({ ref, ...field }) => ( +