diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailFormField.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailFormField.tsx index 0afd9aab0cf..53a624d4422 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailFormField.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailFormField.tsx @@ -6,6 +6,7 @@ import { useController, type Control, type ControllerRenderProps, type RegisterO import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { modeIncludesLoggingOnly, type LoggingOnlyScopeChoice } from "./guardrail_info_helpers"; export interface GuardrailCriterion { name: string; @@ -15,6 +16,7 @@ export interface GuardrailCriterion { export interface GuardrailFormValues extends Record { criteria?: GuardrailCriterion[]; + logging_only_scope_choice?: LoggingOnlyScopeChoice; } export type GuardrailFormControl = Control; export type GuardrailFieldRules = Pick, "validate">; @@ -123,3 +125,49 @@ export const SkipMessageSelect: React.FC<{ control: GuardrailFieldControlProps } ); }; + +const LOGGING_ONLY_SCOPE_ITEMS: Array<{ label: string; value: LoggingOnlyScopeChoice }> = [ + { label: "Default (request and response)", value: "default" }, + { label: "Input only (request)", value: "input" }, + { label: "Output only (response)", value: "output" }, + { label: "Both (request and response)", value: "both" }, +]; + +export const LoggingOnlyScopeSelect: React.FC<{ control: GuardrailFieldControlProps }> = ({ control }) => { + const { id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy } = control; + + return ( + + ); +}; + +export const LoggingOnlyScopeField: React.FC<{ control: GuardrailFormControl; mode: unknown }> = ({ + control, + mode, +}) => { + if (!modeIncludesLoggingOnly(mode)) return null; + + return ( + + {(fieldControl) => } + + ); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailModeDisplay.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailModeDisplay.tsx new file mode 100644 index 00000000000..fe45e183e82 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailModeDisplay.tsx @@ -0,0 +1,47 @@ +import React from "react"; +import { Badge } from "@/components/ui/badge"; +import { Card } from "@/components/ui/card"; +import { + formatGuardrailMode, + formatLoggingOnlyScope, + modeIncludesLoggingOnly, +} from "./guardrail_info_helpers"; + +type GuardrailModeParams = { + mode?: unknown; + default_on?: boolean; + logging_only_scope?: string | null; +}; + +export const GuardrailModeCard: React.FC<{ litellmParams: GuardrailModeParams }> = ({ litellmParams }) => ( + +

Mode

+
+

{formatGuardrailMode(litellmParams.mode) || "-"}

+ + {litellmParams.default_on ? "Default On" : "Default Off"} + +
+ {modeIncludesLoggingOnly(litellmParams.mode) && ( +
+

Logging only scope

+

{formatLoggingOnlyScope(litellmParams.logging_only_scope)}

+
+ )} +
+); + +export const GuardrailModeRows: React.FC<{ litellmParams: GuardrailModeParams }> = ({ litellmParams }) => ( + <> +
+

Mode

+
{formatGuardrailMode(litellmParams.mode) || "-"}
+
+ {modeIncludesLoggingOnly(litellmParams.mode) && ( +
+

Logging only scope

+
{formatLoggingOnlyScope(litellmParams.logging_only_scope)}
+
+ )} + +); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/add_guardrail_form.characterization.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/add_guardrail_form.characterization.test.tsx index aadc9ec0213..89a00b08d6b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/add_guardrail_form.characterization.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/add_guardrail_form.characterization.test.tsx @@ -105,6 +105,48 @@ describe("AddGuardrailForm create payload characterization", () => { expect(payload()).toMatchObject({ litellm_params: { mode: ["pre_call", "post_call"] } }); }); + it("sends the selected output logging-only scope", async () => { + vi.mocked(networking.getGuardrailUISettings).mockResolvedValue({ + ...uiSettings, + supported_modes: ["pre_call", "logging_only"], + }); + 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")); + await user.click((await screen.findAllByText("logging_only")).at(-1) as HTMLElement); + await chooseSelectOption( + user, + await screen.findByLabelText("Logging only scope"), + "Output only (response)", + ); + + 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()?.litellm_params.logging_only_scope).toBe("output"); + }); + + it("hides logging-only scope and omits it from a pre-call payload", async () => { + const user = userEvent.setup({ delay: null }); + renderForm(); + + await user.type(await screen.findByLabelText("Guardrail Name"), "my-bedrock"); + await pickProvider(user, "Bedrock Guardrail"); + expect(screen.queryByLabelText("Logging only scope")).not.toBeInTheDocument(); + + 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()?.litellm_params).not.toHaveProperty("logging_only_scope"); + }); + it("blocks Next when the user deselects every mode", async () => { const user = userEvent.setup({ delay: null }); renderForm(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/add_guardrail_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/add_guardrail_form.tsx index 29df7c8bf3d..689dbbfefa2 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/add_guardrail_form.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/add_guardrail_form.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useMemo, useState } from "react"; -import { useForm, type UseFormReturn } from "react-hook-form"; +import { useForm, useWatch, type UseFormReturn } from "react-hook-form"; import { toast } from "@/lib/toast"; import { createGuardrailCall, @@ -10,18 +10,22 @@ import { import ContentFilterConfiguration from "./content_filter/ContentFilterConfiguration"; import { type CompetitorIntentConfig } from "./content_filter/CompetitorIntentConfiguration"; import { + choiceToLoggingOnlyScope, choiceToSkipSystemForCreate, choiceToSkipToolForCreate, getGuardrailLogo, getGuardrailProviders, getSupportedModesForProvider, guardrail_provider_map, + modeIncludesLoggingOnly, populateGuardrailProviderMap, populateGuardrailProviders, shouldRenderContentFilterConfigSettings, shouldRenderLLMJudgeFields, shouldRenderPIIConfigSettings, toModeArray, + type LoggingOnlyScope, + type LoggingOnlyScopeChoice, } from "./guardrail_info_helpers"; import { Logo } from "@/components/molecules/logo/Logo"; import { MultiSelect } from "@/components/shared/MultiSelect"; @@ -49,6 +53,7 @@ import { requiredRule, type GuardrailCriterion, type GuardrailFormValues, + LoggingOnlyScopeField, SkipMessageSelect, } from "./GuardrailFormField"; import GuardrailOptionalParams from "./guardrail_optional_params"; @@ -160,6 +165,7 @@ type SkipMessageChoice = "inherit" | "yes" | "no"; const INITIAL_VALUES: GuardrailFormValues = { mode: "pre_call", default_on: false, + logging_only_scope_choice: "default", skip_system_message_choice: "inherit", skip_tool_message_choice: "inherit", }; @@ -199,6 +205,7 @@ interface ProviderParamsResponse { const AddGuardrailForm: React.FC = ({ visible, onClose, accessToken, onSuccess, preset }) => { const form = useForm({ defaultValues: INITIAL_VALUES }); + const watchedMode = useWatch({ control: form.control, name: "mode" }); const [loading, setLoading] = useState(false); const [selectedProvider, setSelectedProvider] = useState(null); const [guardrailSettings, setGuardrailSettings] = useState(null); @@ -277,6 +284,7 @@ const AddGuardrailForm: React.FC = ({ visible, onClose, a guardrail_name: preset.guardrailNameSuggestion, mode: preset.mode, default_on: preset.defaultOn, + logging_only_scope_choice: "default", skip_system_message_choice: "inherit", skip_tool_message_choice: "inherit", }; @@ -439,6 +447,7 @@ const AddGuardrailForm: React.FC = ({ visible, onClose, a guardrail_name: string; litellm_params: { guardrail: string; + logging_only_scope?: LoggingOnlyScope | null; [key: string]: unknown; // Allow dynamic properties }; guardrail_info: Record; @@ -462,6 +471,13 @@ const AddGuardrailForm: React.FC = ({ visible, onClose, a guardrailData.litellm_params.skip_tool_message_in_guardrail = skipToolForCreate; } + const loggingOnlyScope = choiceToLoggingOnlyScope( + values.logging_only_scope_choice as LoggingOnlyScopeChoice | undefined, + ); + if (modeIncludesLoggingOnly(values.mode) && loggingOnlyScope !== null) { + guardrailData.litellm_params.logging_only_scope = loggingOnlyScope; + } + // For Presidio PII, add the entity and action configurations if (providerKey === "PresidioPII" && selectedEntities.length > 0) { const piiEntitiesConfig: { [key: string]: string } = {}; @@ -796,6 +812,8 @@ const AddGuardrailForm: React.FC = ({ visible, onClose, a {(fieldControl) => } + + {/* Use the GuardrailProviderFields component to render provider-specific fields */} {showProviderFields && ( { expect(lastPayload()).toEqual({ litellm_params: { skip_system_message_in_guardrail: true } }); }); + it("shows and updates the logging-only scope", async () => { + vi.mocked(networking.getGuardrailInfo).mockResolvedValue( + guardrail({ + guardrailIdentifier: "gr-abc", + api_key: "sk-old", + mode: "logging_only", + logging_only_scope: "input", + }), + ); + const user = userEvent.setup({ delay: null }); + renderView(); + + expect(await screen.findAllByText("Input only (request)")).toHaveLength(2); + await openEditor(user); + await chooseSelectOption( + user, + screen.getByLabelText("Logging only scope"), + "Output only (response)", + ); + await saveChanges(user); + + await waitFor(() => expect(networking.updateGuardrailCall).toHaveBeenCalledTimes(1)); + expect(lastPayload()).toEqual({ litellm_params: { logging_only_scope: "output" } }); + }); + + it("clears the logging-only scope when the edit choice returns to default", async () => { + vi.mocked(networking.getGuardrailInfo).mockResolvedValue( + guardrail({ + guardrailIdentifier: "gr-abc", + api_key: "sk-old", + mode: "logging_only", + logging_only_scope: "input", + }), + ); + const user = userEvent.setup({ delay: null }); + renderView(); + await openEditor(user); + + await chooseSelectOption(user, screen.getByLabelText("Logging only scope"), "Default (request and response)"); + await saveChanges(user); + + await waitFor(() => expect(networking.updateGuardrailCall).toHaveBeenCalledTimes(1)); + expect(lastPayload()).toEqual({ litellm_params: { logging_only_scope: null } }); + }); + it("parses the guardrail information textarea into an object", async () => { const user = userEvent.setup({ delay: null }); renderView(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info.tsx index c9162d99934..e75e55244de 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info.tsx @@ -28,14 +28,17 @@ import { readRecord, requiredRule, type GuardrailFormValues, + LoggingOnlyScopeField, SkipMessageSelect, } from "./GuardrailFormField"; import ContentFilterManager, { formatContentFilterDataForAPI } from "./content_filter/ContentFilterManager"; import CustomCodeModal, { EditGuardrailData } from "./custom_code/CustomCodeModal"; +import { GuardrailModeCard, GuardrailModeRows } from "./GuardrailModeDisplay"; import { - formatGuardrailMode, + getLoggingOnlyScopeUpdate, getGuardrailLogoAndName, guardrail_provider_map, + loggingOnlyScopeToChoice, skipSystemMessageToChoice, skipToolMessageToChoice, type SkipSystemMessageChoice, @@ -219,6 +222,7 @@ const GuardrailInfoView: React.FC = ({ guardrailId, onClose, if (!guardrailData) return; form.setValue("guardrail_name", guardrailData.guardrail_name); form.setValue("default_on", guardrailData.litellm_params?.default_on); + form.setValue("logging_only_scope_choice", loggingOnlyScopeToChoice(guardrailData.litellm_params?.logging_only_scope)); form.setValue( "skip_system_message_choice", skipSystemMessageToChoice(guardrailData.litellm_params?.skip_system_message_in_guardrail), @@ -282,7 +286,7 @@ const GuardrailInfoView: React.FC = ({ guardrailId, onClose, // Prepare update data object - only include changed fields const updateData: any = { - litellm_params: {}, + litellm_params: getLoggingOnlyScopeUpdate(guardrailData.litellm_params, values.logging_only_scope_choice), }; // Only include guardrail_name if it has changed @@ -556,17 +560,7 @@ const GuardrailInfoView: React.FC = ({ guardrailId, onClose, - -

Mode

-
-

- {formatGuardrailMode(guardrailData.litellm_params?.mode) || "-"} -

- - {guardrailData.litellm_params?.default_on ? "Default On" : "Default Off"} - -
-
+

Created At

@@ -745,6 +739,10 @@ const GuardrailInfoView: React.FC = ({ guardrailId, onClose, > {(fieldControl) => } + {guardrailData.litellm_params?.guardrail === "presidio" && ( <> PII Protection @@ -856,10 +854,7 @@ const GuardrailInfoView: React.FC = ({ guardrailId, onClose,

Provider

{displayName}
-
-

Mode

-
{formatGuardrailMode(guardrailData.litellm_params?.mode) || "-"}
-
+

Default On

diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info_helpers.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info_helpers.test.tsx index c5e07fe9624..95c66e40d6b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info_helpers.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info_helpers.test.tsx @@ -15,6 +15,11 @@ import { skipToolMessageToChoice, choiceToSkipToolForCreate, formatGuardrailMode, + loggingOnlyScopeToChoice, + choiceToLoggingOnlyScope, + getLoggingOnlyScopeUpdate, + formatLoggingOnlyScope, + modeIncludesLoggingOnly, } from "./guardrail_info_helpers"; describe("guardrail_info_helpers", () => { @@ -239,6 +244,52 @@ describe("guardrail_info_helpers", () => { }); }); + describe("logging-only scope helpers", () => { + it("maps API scope values to choices and back", () => { + expect(loggingOnlyScopeToChoice("input")).toBe("input"); + expect(loggingOnlyScopeToChoice("output")).toBe("output"); + expect(loggingOnlyScopeToChoice("both")).toBe("both"); + expect(loggingOnlyScopeToChoice(undefined)).toBe("default"); + expect(loggingOnlyScopeToChoice(null)).toBe("default"); + expect(loggingOnlyScopeToChoice("invalid")).toBe("default"); + + expect(choiceToLoggingOnlyScope("default")).toBeNull(); + expect(choiceToLoggingOnlyScope(undefined)).toBeNull(); + expect(choiceToLoggingOnlyScope("input")).toBe("input"); + expect(choiceToLoggingOnlyScope("output")).toBe("output"); + expect(choiceToLoggingOnlyScope("both")).toBe("both"); + + expect(getLoggingOnlyScopeUpdate({ logging_only_scope: "input" }, "input")).toEqual({}); + expect(getLoggingOnlyScopeUpdate({ logging_only_scope: "input" }, "output")).toEqual({ + logging_only_scope: "output", + }); + expect(getLoggingOnlyScopeUpdate({ logging_only_scope: "input" }, "default")).toEqual({ + logging_only_scope: null, + }); + }); + + it("formats every scope and falls back to default for missing or unknown values", () => { + expect(formatLoggingOnlyScope("input")).toBe("Input only (request)"); + expect(formatLoggingOnlyScope("output")).toBe("Output only (response)"); + expect(formatLoggingOnlyScope("both")).toBe("Both (request and response)"); + expect(formatLoggingOnlyScope(undefined)).toBe("Default (request and response)"); + expect(formatLoggingOnlyScope(null)).toBe("Default (request and response)"); + expect(formatLoggingOnlyScope("invalid")).toBe("Default (request and response)"); + }); + + it("detects logging_only in string, array, and tagged mode values", () => { + expect(modeIncludesLoggingOnly("logging_only")).toBe(true); + expect(modeIncludesLoggingOnly(["pre_call", "logging_only"])).toBe(true); + expect( + modeIncludesLoggingOnly({ + tags: { "Service-Type: internal-service": "logging_only" }, + default: "pre_call", + }), + ).toBe(true); + expect(modeIncludesLoggingOnly("pre_call")).toBe(false); + }); + }); + describe("skipSystemMessageToChoice / choiceToSkipSystemForCreate", () => { it("maps API values to form choices and back for create", () => { expect(skipSystemMessageToChoice(undefined)).toBe("inherit"); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info_helpers.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info_helpers.tsx index 476bcd3a8ae..95d463a55a9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info_helpers.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info_helpers.tsx @@ -114,6 +114,42 @@ export const toModeArray = (raw: unknown): string[] => { return []; }; +export type LoggingOnlyScope = "input" | "output" | "both"; +export type LoggingOnlyScopeChoice = "default" | LoggingOnlyScope; + +export const loggingOnlyScopeToChoice = (v: string | null | undefined): LoggingOnlyScopeChoice => + v === "input" || v === "output" || v === "both" ? v : "default"; + +export const choiceToLoggingOnlyScope = (choice: LoggingOnlyScopeChoice | undefined): LoggingOnlyScope | null => + choice === "input" || choice === "output" || choice === "both" ? choice : null; + +export const getLoggingOnlyScopeUpdate = ( + litellmParams: { logging_only_scope?: string | null } | null | undefined, + choice: LoggingOnlyScopeChoice | undefined, +): { logging_only_scope?: LoggingOnlyScope | null } => { + if (choice === undefined || choice === loggingOnlyScopeToChoice(litellmParams?.logging_only_scope)) return {}; + return { logging_only_scope: choiceToLoggingOnlyScope(choice) }; +}; + +export const formatLoggingOnlyScope = (v: string | null | undefined): string => { + if (v === "input") return "Input only (request)"; + if (v === "output") return "Output only (response)"; + if (v === "both") return "Both (request and response)"; + return "Default (request and response)"; +}; + +export const modeIncludesLoggingOnly = (raw: unknown): boolean => { + if (toModeArray(raw).includes("logging_only")) return true; + if (raw === null || typeof raw !== "object") return false; + + const { tags, default: fallback } = raw as { tags?: Record; default?: unknown }; + const taggedModes = + tags && typeof tags === "object" + ? Object.values(tags).some((mode) => toModeArray(mode).includes("logging_only")) + : false; + return toModeArray(fallback).includes("logging_only") || taggedModes; +}; + export const formatGuardrailMode = (raw: unknown): string => { const flat: string[] = toModeArray(raw); if (flat.length > 0) return flat.join(", ");