feat(ui): configure logging_only_scope on guardrails

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
yucheng 2026-09-29 09:48:51 +00:00
parent 1d0db0fbc7
commit fa5f7904a9
9 changed files with 302 additions and 18 deletions

View file

@ -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<string, unknown> {
criteria?: GuardrailCriterion[];
logging_only_scope_choice?: LoggingOnlyScopeChoice;
}
export type GuardrailFormControl = Control<GuardrailFormValues>;
export type GuardrailFieldRules = Pick<RegisterOptions<GuardrailFormValues, string>, "validate">;
@ -123,3 +125,49 @@ export const SkipMessageSelect: React.FC<{ control: GuardrailFieldControlProps }
</Select>
);
};
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 (
<Select items={LOGGING_ONLY_SCOPE_ITEMS} value={asText(value) || "default"} onValueChange={onChange}>
<SelectTrigger id={id} aria-invalid={ariaInvalid} aria-describedby={ariaDescribedBy} className="w-full">
<SelectValue placeholder="Select an option" />
</SelectTrigger>
<SelectContent>
{LOGGING_ONLY_SCOPE_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
};
export const LoggingOnlyScopeField: React.FC<{ control: GuardrailFormControl; mode: unknown }> = ({
control,
mode,
}) => {
if (!modeIncludesLoggingOnly(mode)) return null;
return (
<GuardrailField
control={control}
name="logging_only_scope_choice"
label={labelWithHint(
"Logging only scope",
"Which direction a logging_only scan observes. Observe-only scans never block; pre_call and post_call on this guardrail still block.",
)}
>
{(fieldControl) => <LoggingOnlyScopeSelect control={fieldControl} />}
</GuardrailField>
);
};

View file

@ -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 }) => (
<Card className="block p-6">
<p>Mode</p>
<div className="mt-2">
<h3 className="text-lg font-medium">{formatGuardrailMode(litellmParams.mode) || "-"}</h3>
<Badge variant={litellmParams.default_on ? "secondary" : "outline"}>
{litellmParams.default_on ? "Default On" : "Default Off"}
</Badge>
</div>
{modeIncludesLoggingOnly(litellmParams.mode) && (
<div className="mt-4">
<p>Logging only scope</p>
<h3 className="text-lg font-medium">{formatLoggingOnlyScope(litellmParams.logging_only_scope)}</h3>
</div>
)}
</Card>
);
export const GuardrailModeRows: React.FC<{ litellmParams: GuardrailModeParams }> = ({ litellmParams }) => (
<>
<div>
<p className="font-medium">Mode</p>
<div>{formatGuardrailMode(litellmParams.mode) || "-"}</div>
</div>
{modeIncludesLoggingOnly(litellmParams.mode) && (
<div>
<p className="font-medium">Logging only scope</p>
<div>{formatLoggingOnlyScope(litellmParams.logging_only_scope)}</div>
</div>
)}
</>
);

View file

@ -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();

View file

@ -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<AddGuardrailFormProps> = ({ visible, onClose, accessToken, onSuccess, preset }) => {
const form = useForm<GuardrailFormValues>({ defaultValues: INITIAL_VALUES });
const watchedMode = useWatch({ control: form.control, name: "mode" });
const [loading, setLoading] = useState(false);
const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
const [guardrailSettings, setGuardrailSettings] = useState<GuardrailSettings | null>(null);
@ -277,6 +284,7 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ 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<AddGuardrailFormProps> = ({ visible, onClose, a
guardrail_name: string;
litellm_params: {
guardrail: string;
logging_only_scope?: LoggingOnlyScope | null;
[key: string]: unknown; // Allow dynamic properties
};
guardrail_info: Record<string, unknown>;
@ -462,6 +471,13 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ 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<AddGuardrailFormProps> = ({ visible, onClose, a
{(fieldControl) => <SkipMessageSelect control={fieldControl} />}
</GuardrailField>
<LoggingOnlyScopeField control={form.control} mode={watchedMode} />
{/* Use the GuardrailProviderFields component to render provider-specific fields */}
{showProviderFields && (
<GuardrailProviderFields

View file

@ -2,6 +2,7 @@ import React, { useState, useRef, useEffect } from "react";
import { CheckCircle2, ChevronRight, Code, ExternalLink, PlayCircle, Save, Users, XCircle } from "lucide-react";
import { createGuardrailCall, updateGuardrailCall, testCustomCodeGuardrail } from "@/components/networking";
import { toast } from "@/lib/toast";
import { type LoggingOnlyScope } from "../guardrail_info_helpers";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import {
@ -178,6 +179,7 @@ export interface EditGuardrailData {
mode?: string | string[];
default_on?: boolean;
custom_code?: string;
logging_only_scope?: LoggingOnlyScope | null;
[key: string]: any;
};
}

View file

@ -164,6 +164,51 @@ describe("GuardrailInfoView update payload characterization", () => {
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();

View file

@ -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<GuardrailInfoProps> = ({ 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<GuardrailInfoProps> = ({ 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<GuardrailInfoProps> = ({ guardrailId, onClose,
</div>
</Card>
<Card className="block p-6">
<p>Mode</p>
<div className="mt-2">
<h3 className="text-lg font-medium">
{formatGuardrailMode(guardrailData.litellm_params?.mode) || "-"}
</h3>
<Badge variant={guardrailData.litellm_params?.default_on ? "secondary" : "outline"}>
{guardrailData.litellm_params?.default_on ? "Default On" : "Default Off"}
</Badge>
</div>
</Card>
<GuardrailModeCard litellmParams={guardrailData.litellm_params} />
<Card className="block p-6">
<p>Created At</p>
@ -745,6 +739,10 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
>
{(fieldControl) => <SkipMessageSelect control={fieldControl} />}
</GuardrailField>
<LoggingOnlyScopeField
control={form.control}
mode={guardrailData.litellm_params?.mode}
/>
{guardrailData.litellm_params?.guardrail === "presidio" && (
<>
<SectionHeading>PII Protection</SectionHeading>
@ -856,10 +854,7 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
<p className="font-medium">Provider</p>
<div>{displayName}</div>
</div>
<div>
<p className="font-medium">Mode</p>
<div>{formatGuardrailMode(guardrailData.litellm_params?.mode) || "-"}</div>
</div>
<GuardrailModeRows litellmParams={guardrailData.litellm_params} />
<div>
<p className="font-medium">Default On</p>
<Badge variant={guardrailData.litellm_params?.default_on ? "secondary" : "outline"}>

View file

@ -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");

View file

@ -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<string, unknown>; 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(", ");